Compare commits

...
Author SHA1 Message Date
besendorf c57179a62c Merge branch 'main' into add-shell-completion-command 2026-07-01 12:34:36 +02:00
besendorf 2689176c0e Add custom module loading (#816) 2026-07-01 12:33:38 +02:00
besendorf 638937e838 Handle malformed AndroidQF backups (#824) 2026-06-24 12:41:00 +02:00
Janik Besendorf f95f7b1283 Add shell completion command 2026-06-19 16:51:01 +02:00
dependabot[bot] b9f13b8146 Bump pydantic-settings from 2.13.1 to 2.14.0
Bumps [pydantic-settings](https://github.com/pydantic/pydantic-settings) from 2.13.1 to 2.14.0.
- [Release notes](https://github.com/pydantic/pydantic-settings/releases)
- [Commits](https://github.com/pydantic/pydantic-settings/compare/v2.13.1...v2.14.0)

---
updated-dependencies:
- dependency-name: pydantic-settings
  dependency-version: 2.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:17:22 +02:00
dependabot[bot] 58558fdfb7 Bump click from 8.3.2 to 8.3.3
Bumps [click](https://github.com/pallets/click) from 8.3.2 to 8.3.3.
- [Release notes](https://github.com/pallets/click/releases)
- [Changelog](https://github.com/pallets/click/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/click/compare/8.3.2...8.3.3)

---
updated-dependencies:
- dependency-name: click
  dependency-version: 8.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:16:14 +02:00
dependabot[bot] 7c7e332e7b Bump ruff from 0.9.10 to 0.15.16
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.10 to 0.15.16.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.9.10...0.15.16)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.12
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:12:58 +02:00
dependabot[bot] a232c988f9 Bump packaging from 26.0 to 26.2
Bumps [packaging](https://github.com/pypa/packaging) from 26.0 to 26.2.
- [Release notes](https://github.com/pypa/packaging/releases)
- [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pypa/packaging/compare/26.0...26.2)

---
updated-dependencies:
- dependency-name: packaging
  dependency-version: '26.2'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:11:51 +02:00
dependabot[bot] 2f4123222f Bump tld from 0.13.1 to 0.13.2
Bumps [tld](https://github.com/barseghyanartur/tld) from 0.13.1 to 0.13.2.
- [Release notes](https://github.com/barseghyanartur/tld/releases)
- [Changelog](https://github.com/barseghyanartur/tld/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/barseghyanartur/tld/compare/0.13.1...0.13.2)

---
updated-dependencies:
- dependency-name: tld
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:09:37 +02:00
dependabot[bot] 81a7bc977b Bump pyahocorasick from 2.2.0 to 2.3.1
Bumps [pyahocorasick](https://github.com/WojciechMula/pyahocorasick) from 2.2.0 to 2.3.1.
- [Release notes](https://github.com/WojciechMula/pyahocorasick/releases)
- [Changelog](https://github.com/WojciechMula/pyahocorasick/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/WojciechMula/pyahocorasick/compare/v2.2.0...v2.3.1)

---
updated-dependencies:
- dependency-name: pyahocorasick
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:08:29 +02:00
dependabot[bot] 3b20bd944b Bump types-requests from 2.33.0.20260503 to 2.33.0.20260508
Bumps [types-requests](https://github.com/python/typeshed) from 2.33.0.20260503 to 2.33.0.20260508.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-version: 2.33.0.20260508
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:06:44 +02:00
besendorf 6bbb5957af Fix dumpsys battery daily downgrade detection (#805) 2026-06-17 17:54:05 +02:00
6a6c1758c3 intrusion_logs: alert on certificate events and run heuristics without IOCs (#811)
* intrusion_logs: alert on certificate events and run heuristics without IOCs

SecurityEvent.check_indicators() returned early when no indicator set was
loaded, so none of its heuristic alerts (key integrity, wipe failure, crypto
self-test, certificate events) reached the alert store on a default run. On
top of that, cert_authority_installed and cert_validation_failure only emitted
log.warning and never alerted even when indicators were present.

Run the heuristic alerts independently of the loaded indicators (matching the
accessibility fix in #807) and surface the two certificate events through the
alert store at medium severity. A successfully installed root CA and a
certificate validation failure are interception/MITM-relevant signals that
belong in the alert report.

Adds regression tests for both certificate events and for heuristics firing
with no indicators loaded.

* intrusion_logs: gate certificate authority install alert on success

Failed install attempts log a warning instead of raising the
"Certificate authority installed" alert. Add a regression test
covering success encoded as bool and as int.

---------

Co-authored-by: John Kavanagh <668351+kavanista@users.noreply.github.com>
Co-authored-by: besendorf <janik@besendorf.org>
2026-06-17 17:24:06 +02:00
besendorf 174ce08812 Run mypy on pull requests (#810) 2026-06-17 17:15:11 +02:00
32 changed files with 1523 additions and 188 deletions

No files matched your search

+4 -1
View File
@@ -1,5 +1,8 @@
name: Mypy
on: workflow_dispatch
on:
pull_request:
branches: [main]
workflow_dispatch:
jobs:
mypy_py3:
+22
View File
@@ -60,6 +60,28 @@ For alternative installation options and known issues, please refer to the [docu
MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
### Shell completion
MVT can generate shell completion scripts for Bash, Zsh, and Fish:
```bash
mvt-ios completion
mvt-android completion
```
The commands print setup instructions by default. To generate a completion script directly, pass the shell name:
```bash
mvt-ios completion bash
mvt-android completion zsh
```
MVT only writes completion files or shell configuration when `--install` is passed. See the [command completion documentation](https://docs.mvt.re/en/latest/command_completion/) for details.
Module-running `check-*` commands can load custom Python modules with
`--load-module PATH` or from a folder set in `MVT_CUSTOM_MODULES`. See the
[development documentation](https://docs.mvt.re/en/latest/development/) for
details.
## License
+36 -13
View File
@@ -1,43 +1,66 @@
# Command Completion
# Command Completion
MVT utilizes the [Click](https://click.palletsprojects.com/en/stable/) library for creating its command line interface.
MVT utilizes the [Click](https://click.palletsprojects.com/en/stable/) library for creating its command line interface.
Click provides tab completion support for Bash (version 4.4 and up), Zsh, and Fish.
To enable it, you need to manually register a special function with your shell, which varies depending on the shell you are using.
To enable it, you need to register a completion script with your shell, which varies depending on the shell you are using.
The following describes how to generate the command completion scripts and add them to your shell configuration.
The following describes how to generate the command completion scripts and add them to your shell configuration.
> **Note: You will need to start a new shell for the changes to take effect.**
### For Bash
```bash
# Generates bash completion scripts
echo "$(_MVT_IOS_COMPLETE=bash_source mvt-ios)" > ~/.mvt-ios-complete.bash &&
echo "$(_MVT_ANDROID_COMPLETE=bash_source mvt-android)" > ~/.mvt-android-complete.bash
# Generate bash completion scripts
mvt-ios completion bash > ~/.mvt-ios-complete.bash
mvt-android completion bash > ~/.mvt-android-complete.bash
```
Add the following to `~/.bashrc`:
```bash
# source mvt completion scripts
. ~/.mvt-ios-complete.bash && . ~/.mvt-android-complete.bash
[ -f ~/.mvt-ios-complete.bash ] && . ~/.mvt-ios-complete.bash
[ -f ~/.mvt-android-complete.bash ] && . ~/.mvt-android-complete.bash
```
### For Zsh
```bash
# Generates zsh completion scripts
echo "$(_MVT_IOS_COMPLETE=zsh_source mvt-ios)" > ~/.mvt-ios-complete.zsh &&
echo "$(_MVT_ANDROID_COMPLETE=zsh_source mvt-android)" > ~/.mvt-android-complete.zsh
# Generate zsh completion scripts
mvt-ios completion zsh > ~/.mvt-ios-complete.zsh
mvt-android completion zsh > ~/.mvt-android-complete.zsh
```
Add the following to `~/.zshrc`:
```bash
# source mvt completion scripts
. ~/.mvt-ios-complete.zsh && . ~/.mvt-android-complete.zsh
[ -f ~/.mvt-ios-complete.zsh ] && . ~/.mvt-ios-complete.zsh
[ -f ~/.mvt-android-complete.zsh ] && . ~/.mvt-android-complete.zsh
```
### For Fish
```bash
# Generate fish completion scripts
mkdir -p ~/.config/fish/completions
mvt-ios completion fish > ~/.config/fish/completions/mvt-ios.fish
mvt-android completion fish > ~/.config/fish/completions/mvt-android.fish
```
Fish loads completion files from `~/.config/fish/completions` automatically.
### Automatic Installation
MVT can write the completion file and update the relevant shell configuration for Bash and Zsh when you pass `--install`:
```bash
mvt-ios completion bash --install
mvt-android completion bash --install
```
Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/completions`.
For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion).
+76
View File
@@ -39,6 +39,82 @@ Selecting a single module also runs its transitive dependencies. If a dependency
is unavailable or the dependency graph contains a cycle, the command logs a
warning and does not run any modules.
## Custom modules
Module-running `check-*` commands can load custom modules from Python files that
are not installed as part of MVT. Load one file with:
```bash
mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup
```
You can also load a folder. MVT loads non-hidden top-level `*.py` files in
sorted order and skips `__init__.py`:
```bash
mvt-ios check-fs --load-module ./custom_modules ./filesystem-dump
```
Set `MVT_CUSTOM_MODULES` to load a folder for every module-running command. This
folder is loaded before any `--load-module` path:
```bash
MVT_CUSTOM_MODULES=./custom_modules mvt-android check-bugreport ./bugreport.zip
```
Custom modules are normal `MVTModule` subclasses:
```python
from mvt.common.module import MVTModule
class ExampleCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"), ("ios", "check-fs"))
slug = "example_custom_module"
def run(self):
self.results = [{"message": "custom module ran"}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
Use `supported_commands` to restrict a module to specific platform/command
pairs. Missing or empty `supported_commands` means the module is available to
all commands, which keeps older modules compatible. Supported pairs are:
```python
("ios", "check-backup")
("ios", "check-fs")
("ios", "check-iocs")
("android", "check-backup")
("android", "check-bugreport")
("android", "check-androidqf")
("android", "check-intrusion-logs")
("android", "check-iocs")
```
Custom modules can depend on existing MVT module classes. Dependencies are
resolved with the same ordering logic as built-in modules, and custom modules
are appended after built-ins before ordering:
```python
from mvt.common.module import MVTModule
from mvt.ios.modules.backup.manifest import Manifest
class DependentCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"),)
dependencies = (Manifest,)
def run(self):
manifest_results = self.get_dependency_results(Manifest)
self.results = [{"manifest_entries": len(manifest_results)}]
```
## Profiling
Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be
+5 -5
View File
@@ -17,22 +17,22 @@ classifiers = [
"Programming Language :: Python",
]
dependencies = [
"click==8.3.2",
"click==8.3.3",
"rich==14.3.3",
"tld==0.13.1",
"tld==0.13.2",
"requests==2.33.1",
"simplejson==3.20.2",
"packaging==26.0",
"packaging==26.2",
"appdirs==1.4.4",
"iOSbackup==0.9.925",
"adb-shell[usb]==0.4.4",
"libusb1==3.3.1",
"cryptography==47.0.0",
"PyYAML>=6.0.2",
"pyahocorasick==2.2.0",
"pyahocorasick==2.3.1",
"betterproto2==0.9.1",
"pydantic==2.13.3",
"pydantic-settings==2.13.1",
"pydantic-settings==2.14.0",
"NSKeyedUnArchiver==1.5.2",
"python-dateutil==2.9.0.post0",
"tzdata==2026.2",
@@ -50,13 +50,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
def parse(self, output: str) -> None:
daily = None
daily_updates: list[dict[str, Any]] = []
package_versions: dict[
str, str
] = {} # Track package versions to detect downgrades
records: list[dict[str, Any]] = []
for line in output.splitlines():
if line.startswith(" Daily from "):
if len(daily_updates) > 0:
self.results.extend(daily_updates)
records.extend(daily_updates)
daily_updates = []
timeframe = line[13:].strip()
@@ -89,35 +87,53 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
"vers": vers_nr,
}
# Check for uninstall (version 0)
if vers_nr == "0":
self.alertstore.medium(
f"Detected uninstall of package {package_name} (vers 0)",
daily["from"],
update_record,
)
# Check for downgrade
elif package_name in package_versions:
try:
current_vers = int(vers_nr)
previous_vers = int(package_versions[package_name])
if current_vers < previous_vers:
update_record["action"] = "downgrade"
update_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}",
daily["from"],
update_record,
)
except ValueError:
# If version numbers aren't integers, skip comparison
pass
# Update tracking dictionary
package_versions[package_name] = vers_nr
daily_updates.append(update_record)
if len(daily_updates) > 0:
self.results.extend(daily_updates)
records.extend(daily_updates)
self._detect_uninstalls_and_downgrades(records)
self.results.extend(records)
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["from"],
record["to"],
record["package_name"],
),
):
package_name = record["package_name"]
vers_nr = record["vers"]
if vers_nr == "0":
self.alertstore.medium(
f"Detected uninstall of package {package_name} (vers 0)",
record["from"],
record,
)
package_versions.pop(package_name, None)
continue
try:
current_vers = int(vers_nr)
except ValueError:
continue
previous_vers = package_versions.get(package_name)
if previous_vers is not None and current_vers < previous_vers:
record["action"] = "downgrade"
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["from"],
record,
)
package_versions[package_name] = current_vers
+115 -6
View File
@@ -8,6 +8,12 @@ import logging
import click
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.completion import (
SUPPORTED_SHELLS,
completion_instructions,
generate_completion_script,
install_completion_script,
)
from mvt.common.help import (
HELP_MSG_ANDROID_BACKUP_PASSWORD,
HELP_MSG_CHECK_ADB_REMOVED,
@@ -17,11 +23,13 @@ from mvt.common.help import (
HELP_MSG_CHECK_BUGREPORT,
HELP_MSG_CHECK_IOCS,
HELP_MSG_CHECK_INTRUSION_LOGS,
HELP_MSG_COMPLETION,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_HASHES,
HELP_MSG_IOC,
HELP_MSG_LIST_MODULES,
HELP_MSG_LOAD_MODULE,
HELP_MSG_MODULE,
HELP_MSG_NONINTERACTIVE,
HELP_MSG_OUTPUT,
@@ -30,6 +38,7 @@ from mvt.common.help import (
HELP_MSG_VERSION,
)
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
@@ -59,6 +68,13 @@ def _get_disable_flags(ctx):
)
def _load_custom_modules(load_module):
try:
return load_custom_modules(load_module)
except CustomModuleLoadError as exc:
raise click.ClickException(str(exc)) from exc
# ==============================================================================
# Main
# ==============================================================================
@@ -76,10 +92,11 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
if ctx.invoked_subcommand != "completion":
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ==============================================================================
@@ -90,6 +107,40 @@ def version():
return
# ==============================================================================
# Command: completion
# ==============================================================================
@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
@click.pass_context
def completion(ctx, shell, install):
program_name = "mvt-android"
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(completion_instructions(program_name))
return
root_cli = ctx.find_root().command
if install:
script_path = install_completion_script(root_cli, program_name, shell)
click.echo(f"Installed {shell} completion to {script_path}")
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads completion files automatically.")
return
click.echo(generate_completion_script(root_cli, program_name, shell))
# ==============================================================================
# Command: check-adb (removed)
# ==============================================================================
@@ -119,11 +170,28 @@ def check_adb(ctx):
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
@click.pass_context
def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_path):
def check_bugreport(
ctx,
iocs,
output,
list_modules,
module,
load_module,
verbose,
bugreport_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
# Always generate hashes as bug reports are small.
cmd = CmdAndroidCheckBugreport(
target_path=bugreport_path,
@@ -133,6 +201,7 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
hashes=True,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
@@ -164,6 +233,13 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
)
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@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)
@@ -174,12 +250,14 @@ def check_backup(
iocs,
output,
list_modules,
load_module,
non_interactive,
backup_password,
verbose,
backup_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
# Always generate hashes as backups are generally small.
cmd = CmdAndroidCheckBackup(
@@ -193,6 +271,7 @@ def check_backup(
},
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
@@ -223,6 +302,13 @@ def check_backup(
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@@ -235,6 +321,7 @@ def check_androidqf(
output,
list_modules,
module,
load_module,
hashes,
non_interactive,
backup_password,
@@ -242,6 +329,7 @@ def check_androidqf(
androidqf_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
cmd = CmdAndroidCheckAndroidQF(
target_path=androidqf_path,
@@ -255,6 +343,7 @@ def check_androidqf(
},
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
@@ -288,6 +377,13 @@ def check_androidqf(
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option(
"--timezone",
"-t",
@@ -307,11 +403,13 @@ def check_intrusion_logs(
output,
list_modules,
module,
load_module,
timezone,
verbose,
logs_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
module_options = {}
if timezone:
@@ -325,6 +423,7 @@ def check_intrusion_logs(
module_options=module_options if module_options else None,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
@@ -352,15 +451,25 @@ def check_intrusion_logs(
)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context
def check_iocs(ctx, iocs, list_modules, module, folder):
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
custom_modules = _load_custom_modules(load_module)
cmd = CmdCheckIOCS(
target_path=folder,
ioc_files=iocs,
module_name=module,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
platform="android",
)
cmd.modules = (
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
+16 -2
View File
@@ -13,10 +13,11 @@ from typing import List, Optional
from mvt.android.artifacts.getprop import GetProp
from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup, InvalidAndroidBackup
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.androidqf import ANDROIDQF_MODULES
from .modules.androidqf.base import AndroidQFModule
@@ -50,6 +51,7 @@ class CmdAndroidCheckAndroidQF(Command):
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -64,8 +66,10 @@ class CmdAndroidCheckAndroidQF(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "android"
self.name = "check-androidqf"
self.modules = ANDROIDQF_MODULES
@@ -210,6 +214,7 @@ class CmdAndroidCheckAndroidQF(Command):
module_options=self.module_options,
hashes=self.hashes,
sub_command=True,
custom_modules=self.custom_modules,
)
cmd.from_zip(bugreport)
cmd.run()
@@ -239,8 +244,16 @@ class CmdAndroidCheckAndroidQF(Command):
module_options=self.module_options,
hashes=self.hashes,
sub_command=True,
custom_modules=self.custom_modules,
)
cmd.from_ab(backup)
try:
cmd.from_ab(backup)
except InvalidAndroidBackup as exc:
self.log.warning(
"Skipping backup modules as backup.ab is malformed: %s", exc
)
return False
cmd.run()
self.timeline.extend(cmd.timeline)
@@ -311,6 +324,7 @@ class CmdAndroidCheckAndroidQF(Command):
module_options=adv_module_options,
hashes=self.hashes,
sub_command=True,
custom_modules=self.custom_modules,
)
cmd.run()
+26 -1
View File
@@ -21,12 +21,17 @@ from mvt.android.parsers.backup import (
)
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.backup import BACKUP_MODULES
log = logging.getLogger(__name__)
class InvalidAndroidBackup(Exception):
pass
class CmdAndroidCheckBackup(Command):
def __init__(
self,
@@ -41,6 +46,7 @@ class CmdAndroidCheckBackup(Command):
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -55,8 +61,10 @@ class CmdAndroidCheckBackup(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "android"
self.name = "check-backup"
self.modules = BACKUP_MODULES
@@ -68,6 +76,10 @@ class CmdAndroidCheckBackup(Command):
self.__type = "ab"
header = parse_ab_header(ab_file_bytes)
if not header["backup"]:
if self.sub_command:
raise InvalidAndroidBackup(
"Invalid backup format, file should be in .ab format"
)
log.critical("Invalid backup format, file should be in .ab format")
sys.exit(1)
@@ -83,12 +95,25 @@ class CmdAndroidCheckBackup(Command):
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)
self.__tar = tarfile.open(fileobj=dbytes)
try:
self.__tar = tarfile.open(fileobj=dbytes)
except tarfile.TarError 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)
for member in self.__tar:
self.__files.append(member.name)
+4
View File
@@ -12,6 +12,7 @@ from zipfile import ZipFile
from mvt.android.modules.bugreport.base import BugReportModule
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.bugreport import BUGREPORT_MODULES
@@ -32,6 +33,7 @@ class CmdAndroidCheckBugreport(Command):
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -46,8 +48,10 @@ class CmdAndroidCheckBugreport(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "android"
self.name = "check-bugreport"
self.modules = BUGREPORT_MODULES
@@ -9,6 +9,7 @@ from typing import Optional
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.intrusion_logs import (
INTRUSION_LOGS_MODULES,
@@ -35,6 +36,7 @@ class CmdAndroidCheckIntrusionLogs(Command):
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -49,8 +51,10 @@ class CmdAndroidCheckIntrusionLogs(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "android"
self.name = "check-intrusion-logs"
self.modules = INTRUSION_LOGS_MODULES
self._all_events: dict[str, list[dict]] = {}
+2 -2
View File
@@ -58,7 +58,7 @@ class SMS(AndroidQFModule):
def parse_backup(self, data):
header = parse_ab_header(data)
if not header["backup"]:
self.log.critical("Invalid backup format, backup.ab was not analysed")
self.log.warning("Invalid backup format, backup.ab was not analysed")
return
password = None
@@ -76,7 +76,7 @@ class SMS(AndroidQFModule):
self.log.critical("Invalid backup password")
return
except AndroidBackupParsingError:
self.log.critical(
self.log.warning(
"Impossible to parse this backup file, please use"
" Android Backup Extractor instead"
)
@@ -303,10 +303,15 @@ class SecurityEvent(IntrusionLogsModule):
def check_indicators(self) -> None:
"""Check security events against indicators of compromise."""
if not self.indicators:
return
for result in self.results:
# Heuristic alerts are intrinsic to the event, so they run even
# when no indicator set is loaded.
self._check_security_heuristics(result)
# The remaining checks match events against loaded indicators.
if not self.indicators:
continue
# Check app process start events for suspicious package names
if "app_process_start" in result:
process_info = result["app_process_start"]
@@ -390,60 +395,73 @@ class SecurityEvent(IntrusionLogsModule):
matched_indicator=ioc.ioc,
)
# Flag failed cryptographic operations as potentially suspicious
if "key_generated" in result:
if not result["key_generated"].get("success", True):
self.log.warning(
"Failed key generation detected for key_id: %s",
result["key_generated"].get("key_id", "unknown"),
)
# Flag certificate validation failures
if "cert_validation_failure" in result:
def _check_security_heuristics(self, result: dict) -> None:
"""Raise alerts for events that are intrinsically suspicious,
independent of any loaded indicators."""
# Flag failed cryptographic operations as potentially suspicious
if "key_generated" in result:
if not result["key_generated"].get("success", True):
self.log.warning(
"Certificate validation failure detected: %s",
result.get("cert_validation_failure"),
"Failed key generation detected for key_id: %s",
result["key_generated"].get("key_id", "unknown"),
)
# Flag key integrity violations
if "key_integrity_violation" in result:
# Flag certificate validation failures
if "cert_validation_failure" in result:
self.alertstore.medium(
"Certificate validation failure detected: "
f"{result.get('cert_validation_failure')}",
result.get("timestamp") or "",
result,
)
# Flag key integrity violations
if "key_integrity_violation" in result:
self.alertstore.medium(
f"Key integrity violation detected: {result.get('key_integrity_violation')}",
result.get("timestamp") or "",
result,
)
# Flag successful certificate authority installations (potential
# MITM); a missing success field is treated as installed
if "cert_authority_installed" in result:
cert_info = result["cert_authority_installed"]
if cert_info.get("success", True):
self.alertstore.medium(
f"Key integrity violation detected: {result.get('key_integrity_violation')}",
"Certificate authority installed: "
f"{cert_info.get('subject', 'unknown')}",
result.get("timestamp") or "",
result,
)
# Flag certificate authority installations (potential MITM)
if "cert_authority_installed" in result:
cert_info = result["cert_authority_installed"]
else:
self.log.warning(
"Certificate authority installed: %s (success: %s)",
"Failed certificate authority install attempt: %s",
cert_info.get("subject", "unknown"),
cert_info.get("success", "unknown"),
)
# Flag wipe failures
if "wipe_failure" in result:
# Flag wipe failures
if "wipe_failure" in result:
self.alertstore.medium(
"Device wipe failure detected",
result.get("timestamp") or "",
result,
)
# Flag crypto self test failures
if "crypto_self_test_completed" in result:
test_result = result["crypto_self_test_completed"]
if isinstance(test_result, dict):
success = test_result.get("success", True)
else:
success = test_result == 1
if not success:
self.alertstore.medium(
"Device wipe failure detected",
"Cryptographic self test failed",
result.get("timestamp") or "",
result,
)
# Flag crypto self test failures
if "crypto_self_test_completed" in result:
test_result = result["crypto_self_test_completed"]
if isinstance(test_result, dict):
success = test_result.get("success", True)
else:
success = test_result == 1
if not success:
self.alertstore.medium(
"Cryptographic self test failed",
result.get("timestamp") or "",
result,
)
def serialize(self, record: dict) -> Union[dict, list]:
"""Serialize a security event record for timeline output."""
# Determine the event sub-type
+10 -7
View File
@@ -48,13 +48,16 @@ def parse_ab_header(data):
'encryption': "none", 'version': 4}
"""
if data.startswith(b"ANDROID BACKUP"):
[_, version, is_compressed, encryption, _] = data.split(b"\n", 4)
return {
"backup": True,
"compression": (is_compressed == b"1"),
"version": int(version),
"encryption": encryption.decode("utf-8"),
}
try:
[_, version, is_compressed, encryption, _] = data.split(b"\n", 4)
return {
"backup": True,
"compression": (is_compressed == b"1"),
"version": int(version),
"encryption": encryption.decode("utf-8"),
}
except (UnicodeDecodeError, ValueError):
pass
return {"backup": False, "compression": None, "version": None, "encryption": None}
+6 -1
View File
@@ -8,6 +8,7 @@ import os
from typing import Optional
from mvt.common.command import Command
from mvt.common.module import MVTModule
from mvt.common.utils import exec_or_profile
log = logging.getLogger(__name__)
@@ -26,6 +27,8 @@ class CmdCheckIOCS(Command):
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
platform: str = "",
) -> None:
super().__init__(
target_path=target_path,
@@ -39,14 +42,16 @@ class CmdCheckIOCS(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = platform
self.name = "check-iocs"
def run(self) -> None:
assert self.target_path is not None
all_modules = []
for entry in self.modules:
for entry in self._available_modules():
if entry not in all_modules:
all_modules.append(entry)
+24 -4
View File
@@ -19,6 +19,7 @@ from .alerts import AlertLevel, AlertStore
from .config import settings
from .indicators import Indicators
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
from .module_loader import module_supports_command
from .module_types import ModuleTimeline
from .utils import (
CustomJSONEncoder,
@@ -44,9 +45,12 @@ class Command:
log: logging.Logger = logging.getLogger(__name__),
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
self.name = ""
self.platform = ""
self.modules: list[type[MVTModule]] = []
self.custom_modules = custom_modules if custom_modules else []
self.target_path = target_path
self.results_path = results_path
@@ -199,9 +203,24 @@ class Command:
def list_modules(self) -> None:
self.log.info("Following is the list of available %s modules:", self.name)
for module in self.modules:
for module in self._available_modules():
self.log.info(" - %s", module.__name__)
def _available_modules(self) -> list[type[MVTModule]]:
modules = list(self.modules)
modules.extend(
module
for module in self.custom_modules
if module_supports_command(module, self.platform, self.name)
)
deduplicated = []
for module in modules:
if module not in deduplicated:
deduplicated.append(module)
return deduplicated
def init(self) -> None:
raise NotImplementedError
@@ -262,14 +281,15 @@ class Command:
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
"""Return enabled modules in stable topological order."""
module_indexes = {module: index for index, module in enumerate(self.modules)}
modules = self._available_modules()
module_indexes = {module: index for index, module in enumerate(modules)}
if self.module_name:
selected = [
module for module in self.modules if module.__name__ == self.module_name
module for module in modules if module.__name__ == self.module_name
]
else:
selected = [module for module in self.modules if module.enabled]
selected = [module for module in modules if module.enabled]
required = set(selected)
pending = list(selected)
+94
View File
@@ -0,0 +1,94 @@
# 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 pathlib import Path
import shlex
import click
from click.shell_completion import get_completion_class
SUPPORTED_SHELLS = ("bash", "zsh", "fish")
def completion_instructions(program_name: str) -> str:
return f"""Shell completion for {program_name}
Print a completion script:
{program_name} completion bash > ~/.{program_name}-complete.bash
{program_name} completion zsh > ~/.{program_name}-complete.zsh
mkdir -p ~/.config/fish/completions
{program_name} completion fish > ~/.config/fish/completions/{program_name}.fish
Load the generated Bash script from ~/.bashrc:
[ -f ~/.{program_name}-complete.bash ] && . ~/.{program_name}-complete.bash
Load the generated Zsh script from ~/.zshrc:
[ -f ~/.{program_name}-complete.zsh ] && . ~/.{program_name}-complete.zsh
Fish loads completion files from ~/.config/fish/completions automatically.
To write these files and update Bash/Zsh shell configuration automatically:
{program_name} completion bash --install
{program_name} completion zsh --install
{program_name} completion fish --install
"""
def generate_completion_script(cli: click.Command, program_name: str, shell: str) -> str:
completion_class = get_completion_class(shell)
if completion_class is None:
raise click.ClickException(f"Unsupported shell: {shell}")
complete_var = f"_{program_name.upper().replace('-', '_')}_COMPLETE"
return completion_class(cli, {}, program_name, complete_var).source()
def install_completion_script(
cli: click.Command,
program_name: str,
shell: str,
) -> Path:
script = generate_completion_script(cli, program_name, shell)
script_path = _completion_script_path(program_name, shell)
script_path.parent.mkdir(parents=True, exist_ok=True)
script_path.write_text(script, encoding="utf-8")
if shell in ("bash", "zsh"):
_install_shell_source_line(program_name, shell, script_path)
return script_path
def _completion_script_path(program_name: str, shell: str) -> Path:
home = Path.home()
if shell == "fish":
return home / ".config" / "fish" / "completions" / f"{program_name}.fish"
return home / f".{program_name}-complete.{shell}"
def _install_shell_source_line(program_name: str, shell: str, script_path: Path) -> None:
shell_config_path = Path.home() / f".{shell}rc"
source_line = (
f"[ -f {shlex.quote(str(script_path))} ] && "
f". {shlex.quote(str(script_path))}"
)
block = (
f"# MVT shell completion for {program_name}\n"
f"{source_line}\n"
)
if shell_config_path.exists():
shell_config = shell_config_path.read_text(encoding="utf-8")
if source_line in shell_config:
return
else:
shell_config = ""
separator = "" if not shell_config or shell_config.endswith("\n") else "\n"
with shell_config_path.open("a", encoding="utf-8") as handle:
handle.write(f"{separator}{block}")
+5
View File
@@ -10,6 +10,10 @@ HELP_MSG_IOC = "Path to indicators file (can be invoked multiple time)"
HELP_MSG_FAST = "Avoid running time/resource consuming features"
HELP_MSG_LIST_MODULES = "Print list of available modules and exit"
HELP_MSG_MODULE = "Name of a single module you would like to run instead of all"
HELP_MSG_LOAD_MODULE = (
"Load custom MVT module(s) from a Python file or folder "
"(can be invoked multiple times)"
)
HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing"
HELP_MSG_HASHES = "Generate hashes of all the files analyzed"
HELP_MSG_VERBOSE = "Verbose mode"
@@ -17,6 +21,7 @@ HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided indicators"
HELP_MSG_STIX2 = "Download public STIX2 indicators"
HELP_MSG_DISABLE_UPDATE_CHECK = "Disable MVT version update check"
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check"
HELP_MSG_COMPLETION = "Generate or install shell completion"
# IOS Specific
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
+1
View File
@@ -44,6 +44,7 @@ class MVTModule:
enabled: bool = True
slug: Optional[str] = None
dependencies: Sequence[type["MVTModule"]] = ()
supported_commands: Sequence[tuple[str, str]] = ()
def __init__(
self,
+127
View File
@@ -0,0 +1,127 @@
# 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 hashlib
import importlib.util
import inspect
import os
import sys
from pathlib import Path
from types import ModuleType
from typing import Iterable, Optional
from .module import MVTModule
MVT_CUSTOM_MODULES_ENV = "MVT_CUSTOM_MODULES"
class CustomModuleLoadError(Exception):
pass
def _module_name_for_path(path: Path) -> str:
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
return f"_mvt_custom_module_{path.stem}_{digest}"
def _iter_module_files(path: Path) -> Iterable[Path]:
if path.is_file():
if path.suffix != ".py":
raise CustomModuleLoadError(f"Custom module file is not a Python file: {path}")
yield path
return
if path.is_dir():
for child in sorted(path.iterdir()):
if child.name.startswith("."):
continue
if child.name == "__init__.py":
continue
if child.is_file() and child.suffix == ".py":
yield child
return
raise CustomModuleLoadError(f"Custom module path does not exist: {path}")
def _load_python_file(path: Path) -> ModuleType:
module_name = _module_name_for_path(path)
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise CustomModuleLoadError(f"Unable to load custom module file: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception as exc:
raise CustomModuleLoadError(f"Unable to import custom module {path}: {exc}") from exc
return module
def discover_mvt_modules(module: ModuleType) -> list[type[MVTModule]]:
modules = []
for _, obj in inspect.getmembers(module, inspect.isclass):
if obj is MVTModule:
continue
if obj.__module__ != module.__name__:
continue
if not issubclass(obj, MVTModule):
continue
modules.append(obj)
return modules
def load_custom_modules_from_path(path: str) -> list[type[MVTModule]]:
custom_modules: list[type[MVTModule]] = []
seen: set[tuple[str, str]] = set()
resolved_path = Path(path).expanduser().resolve()
for module_file in _iter_module_files(resolved_path):
loaded_module = _load_python_file(module_file)
for module_class in discover_mvt_modules(loaded_module):
key = (str(module_file), module_class.__qualname__)
if key in seen:
continue
seen.add(key)
custom_modules.append(module_class)
return custom_modules
def load_custom_modules(paths: Optional[Iterable[str]] = None) -> list[type[MVTModule]]:
search_paths: list[str] = []
env_path = os.environ.get(MVT_CUSTOM_MODULES_ENV)
if env_path:
search_paths.append(env_path)
if paths:
search_paths.extend(paths)
custom_modules: list[type[MVTModule]] = []
seen: set[tuple[str, str]] = set()
for path in search_paths:
for module_class in load_custom_modules_from_path(path):
source = Path(inspect.getfile(module_class)).resolve()
key = (str(source), module_class.__qualname__)
if key in seen:
continue
seen.add(key)
custom_modules.append(module_class)
return custom_modules
def module_supports_command(
module_class: type[MVTModule],
platform: str,
command: str,
) -> bool:
supported_commands = getattr(module_class, "supported_commands", None)
if not supported_commands:
return True
return (platform, command) in {tuple(entry) for entry in supported_commands}
+106 -7
View File
@@ -11,6 +11,12 @@ import click
from rich.prompt import Prompt
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.completion import (
SUPPORTED_SHELLS,
completion_instructions,
generate_completion_script,
install_completion_script,
)
from mvt.common.logo import logo
from mvt.common.options import MutuallyExclusiveOption
from mvt.common.updates import IndicatorsUpdates
@@ -31,6 +37,7 @@ from mvt.common.help import (
HELP_MSG_OUTPUT,
HELP_MSG_FAST,
HELP_MSG_LIST_MODULES,
HELP_MSG_LOAD_MODULE,
HELP_MSG_MODULE,
HELP_MSG_VERBOSE,
HELP_MSG_CHECK_FS,
@@ -39,7 +46,9 @@ from mvt.common.help import (
HELP_MSG_CHECK_IOS_BACKUP,
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_COMPLETION,
)
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from .cmd_check_backup import CmdIOSCheckBackup
from .cmd_check_fs import CmdIOSCheckFS
from .decrypt import DecryptBackup
@@ -65,6 +74,13 @@ def _get_disable_flags(ctx):
)
def _load_custom_modules(load_module):
try:
return load_custom_modules(load_module)
except CustomModuleLoadError as exc:
raise click.ClickException(str(exc)) from exc
# ==============================================================================
# Main
# ==============================================================================
@@ -82,10 +98,11 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
if ctx.invoked_subcommand != "completion":
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ==============================================================================
@@ -96,6 +113,40 @@ def version():
return
# ==============================================================================
# Command: completion
# ==============================================================================
@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
@click.pass_context
def completion(ctx, shell, install):
program_name = "mvt-ios"
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(completion_instructions(program_name))
return
root_cli = ctx.find_root().command
if install:
script_path = install_completion_script(root_cli, program_name, shell)
click.echo(f"Installed {shell} completion to {script_path}")
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads completion files automatically.")
return
click.echo(generate_completion_script(root_cli, program_name, shell))
# ==============================================================================
# Command: decrypt-backup
# ==============================================================================
@@ -229,15 +280,32 @@ def extract_key(password, key_file, backup_path):
@click.option("--fast", "-f", is_flag=True, help=HELP_MSG_FAST)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
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)
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_backup(
ctx, iocs, output, fast, list_modules, module, hashes, verbose, backup_path
ctx,
iocs,
output,
fast,
list_modules,
module,
load_module,
hashes,
verbose,
backup_path,
):
set_verbose_logging(verbose)
module_options = {"fast_mode": fast}
custom_modules = _load_custom_modules(load_module)
cmd = CmdIOSCheckBackup(
target_path=backup_path,
@@ -248,6 +316,7 @@ def check_backup(
hashes=hashes,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
@@ -277,13 +346,32 @@ def check_backup(
@click.option("--fast", "-f", is_flag=True, help=HELP_MSG_FAST)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
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)
@click.argument("DUMP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dump_path):
def check_fs(
ctx,
iocs,
output,
fast,
list_modules,
module,
load_module,
hashes,
verbose,
dump_path,
):
set_verbose_logging(verbose)
module_options = {"fast_mode": fast}
custom_modules = _load_custom_modules(load_module)
cmd = CmdIOSCheckFS(
target_path=dump_path,
@@ -294,6 +382,7 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
hashes=hashes,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
@@ -321,15 +410,25 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context
def check_iocs(ctx, iocs, list_modules, module, folder):
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
custom_modules = _load_custom_modules(load_module)
cmd = CmdCheckIOCS(
target_path=folder,
ioc_files=iocs,
module_name=module,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
platform="ios",
)
cmd.modules = BACKUP_MODULES + FS_MODULES + MIXED_MODULES
+4
View File
@@ -8,6 +8,7 @@ from typing import Optional
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.backup import BACKUP_MODULES
from .modules.mixed import MIXED_MODULES
@@ -29,6 +30,7 @@ class CmdIOSCheckBackup(Command):
sub_command: bool = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -43,8 +45,10 @@ class CmdIOSCheckBackup(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "ios"
self.name = "check-backup"
self.modules = BACKUP_MODULES + MIXED_MODULES
+4
View File
@@ -8,6 +8,7 @@ from typing import Optional
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.fs import FS_MODULES
from .modules.mixed import MIXED_MODULES
@@ -29,6 +30,7 @@ class CmdIOSCheckFS(Command):
sub_command: bool = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -42,8 +44,10 @@ class CmdIOSCheckFS(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "ios"
self.name = "check-fs"
self.modules = FS_MODULES + MIXED_MODULES
@@ -69,3 +69,65 @@ class TestDumpsysBatteryDailyArtifact:
assert downgrade_alert.event["package_name"] == "com.example.app"
assert downgrade_alert.event["action"] == "downgrade"
assert downgrade_alert.event["previous_vers"] == "10"
def test_newest_first_update_is_not_reported_as_downgrade(self):
dba = DumpsysBatteryDailyArtifact()
dba.parse(
"""
Daily from 2026-01-10 to 2026-01-11:
Update com.example.app vers=102
Daily from 2026-01-05 to 2026-01-06:
Update com.example.app vers=101
"""
)
assert len(dba.results) == 2
assert len(dba.alertstore.alerts) == 0
assert all(result["action"] == "update" for result in dba.results)
def test_newest_first_downgrade_creates_medium_alert(self):
dba = DumpsysBatteryDailyArtifact()
dba.parse(
"""
Daily from 2026-01-10 to 2026-01-11:
Update com.example.app vers=101
Daily from 2026-01-05 to 2026-01-06:
Update com.example.app vers=102
"""
)
assert len(dba.results) == 2
assert len(dba.alertstore.alerts) == 1
downgrade_alert = dba.alertstore.alerts[0]
assert downgrade_alert.level == AlertLevel.MEDIUM
assert downgrade_alert.message == (
"Detected downgrade of package com.example.app from vers 102 to vers 101"
)
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_vers"] == "102"
def test_reinstall_after_uninstall_is_not_reported_as_downgrade(self):
dba = DumpsysBatteryDailyArtifact()
dba.parse(
"""
Daily from 2026-01-15 to 2026-01-16:
Update com.example.app vers=10
Daily from 2026-01-10 to 2026-01-11:
Update com.example.app vers=0
Daily from 2026-01-05 to 2026-01-06:
Update com.example.app vers=102
"""
)
assert len(dba.results) == 3
assert len(dba.alertstore.alerts) == 1
uninstall_alert = dba.alertstore.alerts[0]
assert uninstall_alert.level == AlertLevel.MEDIUM
assert uninstall_alert.message == (
"Detected uninstall of package com.example.app (vers 0)"
)
assert uninstall_alert.event_time == "2026-01-10"
+13 -1
View File
@@ -5,12 +5,24 @@
import hashlib
from mvt.android.parsers.backup import parse_backup_file, parse_tar_for_sms
from mvt.android.parsers.backup import (
parse_ab_header,
parse_backup_file,
parse_tar_for_sms,
)
from ..utils import get_artifact
class TestBackupParsing:
def test_parse_incomplete_header(self):
assert parse_ab_header(b"ANDROID BACKUP\n") == {
"backup": False,
"compression": None,
"version": None,
"encryption": None,
}
def test_parsing_noencryption(self):
file = get_artifact("android_backup/backup.ab")
with open(file, "rb") as f:
+81
View File
@@ -6,12 +6,14 @@
import json
import logging
import pytest
from click.testing import CliRunner
from mvt.android.cli import check_intrusion_logs
from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
from mvt.android.modules.intrusion_logs.base import IntrusionLogsModule
from mvt.android.modules.intrusion_logs.security_event import SecurityEvent
from mvt.common.alerts import AlertLevel
def _write_ndjson(path, records):
@@ -204,3 +206,82 @@ def test_check_intrusion_logs_cli_lists_modules(tmp_path):
assert "DnsEvent" in result.output
assert "ConnectEvent" in result.output
assert "SecurityEvent" in result.output
def _run_security_heuristics(results):
# No indicators loaded: heuristic alerts must still fire.
module = SecurityEvent(results=results)
module.check_indicators()
return module.alertstore.alerts
def test_cert_authority_installed_raises_medium_alert_without_indicators():
alerts = _run_security_heuristics(
[
{
"timestamp": "2024-01-01 00:00:00.000",
"cert_authority_installed": {
"subject": "CN=Unexpected Root CA",
"success": True,
},
}
]
)
assert len(alerts) == 1
assert alerts[0].level == AlertLevel.MEDIUM
assert "Certificate authority installed" in alerts[0].message
assert "Unexpected Root CA" in alerts[0].message
# Exported logs encode success as a JSON bool, raw SecurityLog as int 0/1.
@pytest.mark.parametrize("success", [False, 0])
def test_failed_cert_authority_install_does_not_alert(success, caplog):
with caplog.at_level(logging.WARNING):
alerts = _run_security_heuristics(
[
{
"timestamp": "2024-01-01 00:00:00.000",
"cert_authority_installed": {
"subject": "CN=Unexpected Root CA",
"success": success,
},
}
]
)
assert alerts == []
assert "Failed certificate authority install attempt" in caplog.text
assert "Unexpected Root CA" in caplog.text
def test_cert_validation_failure_raises_medium_alert_without_indicators():
alerts = _run_security_heuristics(
[
{
"timestamp": "2024-01-01 00:00:00.000",
"cert_validation_failure": "chain validation failed",
}
]
)
assert len(alerts) == 1
assert alerts[0].level == AlertLevel.MEDIUM
assert "Certificate validation failure" in alerts[0].message
def test_security_heuristics_fire_when_no_indicators_loaded():
# check_indicators() previously returned early with no indicators loaded,
# so none of the heuristic alerts fired on a default run.
alerts = _run_security_heuristics(
[
{"timestamp": "2024-01-01 00:00:00.000", "wipe_failure": {"reason": "x"}},
{
"timestamp": "2024-01-01 00:00:00.000",
"key_integrity_violation": {"key_id": "k1"},
},
]
)
assert len(alerts) == 2
assert all(alert.level == AlertLevel.MEDIUM for alert in alerts)
+56
View File
@@ -42,6 +42,19 @@ class IndependentModule(RecordingModule):
pass
class CustomIOSBackupModule(RecordingModule):
supported_commands = (("ios", "check-backup"),)
class CustomIOSFSModule(RecordingModule):
supported_commands = (("ios", "check-fs"),)
class CustomDependsOnBuiltin(RecordingModule):
supported_commands = (("ios", "check-backup"),)
dependencies = (FirstModule,)
class RecordingCommand(Command):
def init(self):
self.initialized = True
@@ -132,3 +145,46 @@ class TestCommand:
assert RecordingModule.run_order == []
assert not hasattr(cmd, "initialized")
assert "depends on unavailable module UnavailableModule" in caplog.text
def test_custom_modules_are_filtered_before_ordering(self):
cmd = RecordingCommand()
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [FirstModule]
cmd.custom_modules = [CustomIOSBackupModule, CustomIOSFSModule]
assert [module.__name__ for module in cmd._ordered_modules()] == [
"FirstModule",
"CustomIOSBackupModule",
]
def test_selected_custom_module_runs(self):
cmd = RecordingCommand(module_name="CustomIOSBackupModule")
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.custom_modules = [CustomIOSBackupModule]
cmd.run()
assert RecordingModule.run_order == ["CustomIOSBackupModule"]
def test_selected_unsupported_custom_module_does_not_run(self):
cmd = RecordingCommand(module_name="CustomIOSFSModule")
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.custom_modules = [CustomIOSFSModule]
cmd.run()
assert RecordingModule.run_order == []
def test_custom_module_dependencies_use_topological_order(self):
cmd = RecordingCommand(module_name="CustomDependsOnBuiltin")
cmd.platform = "ios"
cmd.name = "check-backup"
cmd.modules = [SecondModule, FirstModule]
cmd.custom_modules = [CustomDependsOnBuiltin]
cmd.run()
assert RecordingModule.run_order == ["FirstModule", "CustomDependsOnBuiltin"]
+145
View File
@@ -0,0 +1,145 @@
import pytest
from mvt.common.module import MVTModule
from mvt.common.module_loader import (
CustomModuleLoadError,
load_custom_modules,
load_custom_modules_from_path,
module_supports_command,
)
MODULE_TEMPLATE = """
from mvt.common.module import MVTModule
class {name}(MVTModule):
supported_commands = {supported_commands!r}
def run(self):
pass
def check_indicators(self):
pass
def serialize(self, result):
return None
"""
def _write_module(path, name, supported_commands=()):
path.write_text(
MODULE_TEMPLATE.format(
name=name,
supported_commands=supported_commands,
),
encoding="utf-8",
)
return path
def test_load_custom_modules_from_python_file(tmp_path):
module_path = _write_module(tmp_path / "custom.py", "FileModule")
modules = load_custom_modules_from_path(str(module_path))
assert [module.__name__ for module in modules] == ["FileModule"]
assert issubclass(modules[0], MVTModule)
def test_load_custom_modules_from_folder_in_sorted_order(tmp_path):
_write_module(tmp_path / "b_module.py", "BModule")
_write_module(tmp_path / "a_module.py", "AModule")
_write_module(tmp_path / ".hidden.py", "HiddenModule")
_write_module(tmp_path / "__init__.py", "InitModule")
nested = tmp_path / "nested"
nested.mkdir()
_write_module(nested / "nested_module.py", "NestedModule")
modules = load_custom_modules_from_path(str(tmp_path))
assert [module.__name__ for module in modules] == ["AModule", "BModule"]
def test_discovery_ignores_imported_base_and_unrelated_classes(tmp_path):
module_path = tmp_path / "custom.py"
module_path.write_text(
"""
from mvt.common.module import MVTModule
class Unrelated:
pass
class DiscoveredModule(MVTModule):
def run(self):
pass
def check_indicators(self):
pass
def serialize(self, result):
return None
""",
encoding="utf-8",
)
modules = load_custom_modules_from_path(str(module_path))
assert [module.__name__ for module in modules] == ["DiscoveredModule"]
def test_load_custom_modules_deduplicates_same_class(tmp_path):
module_path = _write_module(tmp_path / "custom.py", "DuplicateModule")
modules = load_custom_modules([str(module_path), str(module_path)])
assert [module.__name__ for module in modules] == ["DuplicateModule"]
def test_load_custom_modules_raises_for_missing_path(tmp_path):
with pytest.raises(CustomModuleLoadError, match="does not exist"):
load_custom_modules_from_path(str(tmp_path / "missing.py"))
def test_load_custom_modules_raises_for_import_error(tmp_path):
module_path = tmp_path / "broken.py"
module_path.write_text("raise RuntimeError('broken import')", encoding="utf-8")
with pytest.raises(CustomModuleLoadError, match="broken import"):
load_custom_modules_from_path(str(module_path))
def test_load_custom_modules_loads_env_folder_first(tmp_path, monkeypatch):
env_folder = tmp_path / "env"
env_folder.mkdir()
cli_folder = tmp_path / "cli"
cli_folder.mkdir()
_write_module(env_folder / "env_module.py", "EnvModule")
_write_module(cli_folder / "cli_module.py", "CliModule")
monkeypatch.setenv("MVT_CUSTOM_MODULES", str(env_folder))
modules = load_custom_modules([str(cli_folder)])
assert [module.__name__ for module in modules] == ["EnvModule", "CliModule"]
def test_module_supports_command_defaults_to_all_commands(tmp_path):
module_path = _write_module(tmp_path / "custom.py", "DefaultModule")
module = load_custom_modules_from_path(str(module_path))[0]
assert module_supports_command(module, "ios", "check-backup")
assert module_supports_command(module, "android", "check-bugreport")
def test_module_supports_command_honors_supported_commands(tmp_path):
module_path = _write_module(
tmp_path / "custom.py",
"SpecificModule",
(("ios", "check-backup"),),
)
module = load_custom_modules_from_path(str(module_path))[0]
assert module_supports_command(module, "ios", "check-backup")
assert not module_supports_command(module, "ios", "check-fs")
+17
View File
@@ -3,7 +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/
import logging
import os
import shutil
from click.testing import CliRunner
@@ -68,3 +70,18 @@ class TestCheckAndroidqfCommand:
assert result.exit_code == 0
del os.environ["MVT_ANDROID_BACKUP_PASSWORD"]
settings.__init__() # Reset settings
def test_check_malformed_backup_skips_backup_modules(self, tmp_path, caplog):
path = tmp_path / "androidqf"
shutil.copytree(os.path.join(get_artifact_folder(), "androidqf"), path)
(path / "backup.ab").write_bytes(b"")
runner = CliRunner()
with caplog.at_level(logging.WARNING):
result = runner.invoke(check_androidqf, [str(path)])
assert result.exit_code == 0
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
)
+78
View File
@@ -0,0 +1,78 @@
# 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 click.testing import CliRunner
from mvt.android.cli import cli as android_cli
from mvt.ios.cli import cli as ios_cli
class TestCompletionCommand:
def test_completion_prints_instructions_by_default(self):
runner = CliRunner()
result = runner.invoke(ios_cli, ["completion"])
assert result.exit_code == 0
assert "Shell completion for mvt-ios" in result.output
assert "mvt-ios completion bash > ~/.mvt-ios-complete.bash" in result.output
assert "Mobile Verification Toolkit" not in result.output
def test_completion_prints_bash_script(self):
runner = CliRunner()
result = runner.invoke(ios_cli, ["completion", "bash"])
assert result.exit_code == 0
assert "_MVT_IOS_COMPLETE=bash_complete" in result.output
assert "complete -o nosort" in result.output
assert "mvt-ios" in result.output
assert "Mobile Verification Toolkit" not in result.output
def test_completion_prints_fish_script(self):
runner = CliRunner()
result = runner.invoke(android_cli, ["completion", "fish"])
assert result.exit_code == 0
assert "_MVT_ANDROID_COMPLETE=fish_complete" in result.output
assert "complete --no-files --command mvt-android" in result.output
assert "Mobile Verification Toolkit" not in result.output
def test_completion_install_updates_bashrc_once(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
runner = CliRunner()
result = runner.invoke(ios_cli, ["completion", "bash", "--install"])
assert result.exit_code == 0
script_path = tmp_path / ".mvt-ios-complete.bash"
bashrc_path = tmp_path / ".bashrc"
assert script_path.exists()
assert "_MVT_IOS_COMPLETE=bash_complete" in script_path.read_text(
encoding="utf-8"
)
bashrc = bashrc_path.read_text(encoding="utf-8")
assert "[ -f" in bashrc
assert ".mvt-ios-complete.bash" in bashrc
result = runner.invoke(ios_cli, ["completion", "bash", "--install"])
assert result.exit_code == 0
assert bashrc_path.read_text(encoding="utf-8") == bashrc
def test_completion_install_fish_does_not_update_shell_rc(
self, tmp_path, monkeypatch
):
monkeypatch.setenv("HOME", str(tmp_path))
runner = CliRunner()
result = runner.invoke(android_cli, ["completion", "fish", "--install"])
assert result.exit_code == 0
script_path = (
tmp_path / ".config" / "fish" / "completions" / "mvt-android.fish"
)
assert script_path.exists()
assert "_MVT_ANDROID_COMPLETE=fish_complete" in script_path.read_text(
encoding="utf-8"
)
assert not (tmp_path / ".fishrc").exists()
+193
View File
@@ -0,0 +1,193 @@
from click.testing import CliRunner
from mvt.android.cli import check_bugreport
from mvt.android.cmd_check_androidqf import CmdAndroidCheckAndroidQF
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
from mvt.common.module import MVTModule
from mvt.ios.cli import check_backup, check_fs
CUSTOM_MODULE = """
from mvt.common.module import MVTModule
class {name}(MVTModule):
supported_commands = {supported_commands!r}
slug = "{slug}"
def run(self):
self.results = [{{"message": "custom module ran"}}]
def check_indicators(self):
pass
def serialize(self, result):
return None
"""
def _write_custom_module(path, name, supported_commands, slug=None):
path.write_text(
CUSTOM_MODULE.format(
name=name,
supported_commands=supported_commands,
slug=slug or name.lower(),
),
encoding="utf-8",
)
return path
def test_load_module_appears_only_for_supported_cli_command(tmp_path):
module_path = _write_custom_module(
tmp_path / "custom.py",
"IOSBackupOnlyModule",
(("ios", "check-backup"),),
)
backup_result = CliRunner().invoke(
check_backup,
["--list-modules", "--load-module", str(module_path), str(tmp_path)],
)
fs_result = CliRunner().invoke(
check_fs,
["--list-modules", "--load-module", str(module_path), str(tmp_path)],
)
assert backup_result.exit_code == 0
assert "IOSBackupOnlyModule" in backup_result.output
assert fs_result.exit_code == 0
assert "IOSBackupOnlyModule" not in fs_result.output
def test_module_option_runs_supported_custom_module(tmp_path):
module_path = _write_custom_module(
tmp_path / "custom.py",
"CustomRunModule",
(("ios", "check-backup"),),
slug="custom_run_module",
)
output_path = tmp_path / "out"
result = CliRunner().invoke(
check_backup,
[
"--module",
"CustomRunModule",
"--load-module",
str(module_path),
"--output",
str(output_path),
str(tmp_path),
],
)
assert result.exit_code == 0
assert (output_path / "custom_run_module.json").exists()
def test_custom_modules_load_from_environment_without_cli_flag(tmp_path, monkeypatch):
custom_modules_path = tmp_path / "custom_modules"
custom_modules_path.mkdir()
_write_custom_module(
custom_modules_path / "env_module.py",
"EnvBugreportModule",
(("android", "check-bugreport"),),
)
monkeypatch.setenv("MVT_CUSTOM_MODULES", str(custom_modules_path))
result = CliRunner().invoke(check_bugreport, ["--list-modules", str(tmp_path)])
assert result.exit_code == 0
assert "EnvBugreportModule" in result.output
class NestedBugreportModule(MVTModule):
supported_commands = (("android", "check-bugreport"),)
class NestedBackupModule(MVTModule):
supported_commands = (("android", "check-backup"),)
class NestedIntrusionLogsModule(MVTModule):
supported_commands = (("android", "check-intrusion-logs"),)
class NestedAndroidQFModule(MVTModule):
supported_commands = (("android", "check-androidqf"),)
class DummyZip:
def close(self):
pass
def test_androidqf_propagates_custom_modules_to_nested_commands(tmp_path, monkeypatch):
records = {}
custom_modules = [
NestedBugreportModule,
NestedBackupModule,
NestedIntrusionLogsModule,
NestedAndroidQFModule,
]
cmd = CmdAndroidCheckAndroidQF(
target_path=str(tmp_path),
custom_modules=custom_modules,
)
def record_available(name):
def _record(command):
records[name] = [
module.__name__
for module in command._available_modules()
if module.__name__.startswith("Nested")
]
return _record
monkeypatch.setattr(cmd, "load_bugreport", lambda: DummyZip())
monkeypatch.setattr(
CmdAndroidCheckBugreport,
"from_zip",
lambda self, bugreport: None,
)
monkeypatch.setattr(
CmdAndroidCheckBugreport,
"run",
record_available("bugreport"),
)
monkeypatch.setattr(cmd, "load_backup", lambda: b"")
monkeypatch.setattr(CmdAndroidCheckBackup, "from_ab", lambda self, backup: None)
monkeypatch.setattr(
CmdAndroidCheckBackup,
"run",
record_available("backup"),
)
intrusion_logs_path = tmp_path / "intrusion_logs"
intrusion_logs_path.mkdir()
setattr(cmd, "_CmdAndroidCheckAndroidQF__format", "dir")
setattr(
cmd,
"_CmdAndroidCheckAndroidQF__files",
["androidqf/intrusion_logs/security.txt"],
)
monkeypatch.setattr(cmd, "_read_device_timezone", lambda: None)
monkeypatch.setattr(
CmdAndroidCheckIntrusionLogs,
"run",
record_available("intrusion_logs"),
)
assert cmd.run_bugreport_cmd()
assert cmd.run_backup_cmd()
assert cmd.run_intrusion_logs_cmd()
assert records == {
"bugreport": ["NestedBugreportModule"],
"backup": ["NestedBackupModule"],
"intrusion_logs": ["NestedIntrusionLogsModule"],
}
Generated
+79 -64
View File
@@ -86,7 +86,7 @@ wheels = [
[[package]]
name = "betterproto2-compiler"
version = "0.9.0"
version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "betterproto2" },
@@ -95,9 +95,9 @@ dependencies = [
{ name = "strenum", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/62f13d5d35236b82835fc18e0e5a804435626e34897b22be5cdbf7dc368e/betterproto2_compiler-0.9.0.tar.gz", hash = "sha256:f138597a035b4f163f25255a77fc6732801e046d0d9f3b13634b07c8f06bcd66", size = 135297, upload-time = "2025-09-25T20:15:43.137Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/38/cdac9ca1cde9bf8a213bc7972985565b381d4faa2214ad097f8f614977b2/betterproto2_compiler-0.10.0.tar.gz", hash = "sha256:063179e0b6732e04f792e8317ed5d2cbfa069cac4eff42a3734f203a51e47599", size = 54682, upload-time = "2026-05-10T13:03:32.821Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/5e/0075f709826bcd3d23f9c0988a83ac8c2605342a14cef1d283cf837f3468/betterproto2_compiler-0.9.0-py3-none-any.whl", hash = "sha256:0765a726191a64df653de39fa834b278fb37c8fb4f2e5d447a522ce75547ec76", size = 65471, upload-time = "2025-09-25T20:15:41.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7e/a7f247847253983b1adbd084bbb4789ee25dd14e2a7601eb5c7aca531e19/betterproto2_compiler-0.10.0-py3-none-any.whl", hash = "sha256:075252253ca4f730b664546e2c91ab33557a98bb98141f9554ed5621eb8d25a7", size = 66884, upload-time = "2026-05-10T13:03:31.366Z" },
]
[[package]]
@@ -298,14 +298,14 @@ wheels = [
[[package]]
name = "click"
version = "8.3.2"
version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[package]]
@@ -935,21 +935,21 @@ requires-dist = [
{ name = "adb-shell", extras = ["usb"], specifier = "==0.4.4" },
{ name = "appdirs", specifier = "==1.4.4" },
{ name = "betterproto2", specifier = "==0.9.1" },
{ name = "click", specifier = "==8.3.2" },
{ name = "click", specifier = "==8.3.3" },
{ name = "cryptography", specifier = "==47.0.0" },
{ name = "iosbackup", specifier = "==0.9.925" },
{ name = "libusb1", specifier = "==3.3.1" },
{ name = "nskeyedunarchiver", specifier = "==1.5.2" },
{ name = "packaging", specifier = "==26.0" },
{ name = "pyahocorasick", specifier = "==2.2.0" },
{ name = "packaging", specifier = "==26.2" },
{ name = "pyahocorasick", specifier = "==2.3.1" },
{ name = "pydantic", specifier = "==2.13.3" },
{ name = "pydantic-settings", specifier = "==2.13.1" },
{ name = "pydantic-settings", specifier = "==2.14.0" },
{ name = "python-dateutil", specifier = "==2.9.0.post0" },
{ name = "pyyaml", specifier = ">=6.0.2" },
{ name = "requests", specifier = "==2.33.1" },
{ name = "rich", specifier = "==14.3.3" },
{ name = "simplejson", specifier = "==3.20.2" },
{ name = "tld", specifier = "==0.13.1" },
{ name = "tld", specifier = "==0.13.2" },
{ name = "tzdata", specifier = "==2026.2" },
]
@@ -1054,11 +1054,11 @@ wheels = [
[[package]]
name = "packaging"
version = "26.0"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
@@ -1099,30 +1099,45 @@ wheels = [
[[package]]
name = "pyahocorasick"
version = "2.2.0"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/4b/e3bee663803e2202be984e1291084355f064dfa3a6a632e01fe496445a5c/pyahocorasick-2.2.0.tar.gz", hash = "sha256:817f302088400a1402bf2f8631fdb21cf5a2666888e0d6a7d5a3ad556212e9da", size = 103916, upload-time = "2025-06-19T05:51:28.087Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/3c/dc9e31a0f004eabe2ef5d31456766555a02e2af29e159daa31266934af79/pyahocorasick-2.3.1.tar.gz", hash = "sha256:9d0f6bb522237ed7f111ed59c9e8baea7d1e75813587b6773babd43bda35db9f", size = 105024, upload-time = "2026-04-27T16:30:25.957Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/d4/62c7eb67e304d0e746a0a782f261011d78fc4a440f00d37ee95fd93816fb/pyahocorasick-2.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:779f1bb63644655d6001f5b1c5f864ec1284cf1b622ac24774f8444ab92f4f84", size = 58159, upload-time = "2025-06-19T05:50:52.668Z" },
{ url = "https://files.pythonhosted.org/packages/10/c6/02d4adcf2e75eb68c4e9f929b09731d9c21459af1184efe90d51ad837b8a/pyahocorasick-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6e9e082ffc2b240017357aeccaedc7aaccba530cb9e64945e23e999ef98b19c5", size = 33241, upload-time = "2025-06-19T05:50:53.752Z" },
{ url = "https://files.pythonhosted.org/packages/5f/7a/9bd41a59d6ee3abec9a494c21a2da425c3ca54b0d440b84deb37d47e3b66/pyahocorasick-2.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9b82717334794ee1bf50ab574c2b990179fc5bfedf1ff40875f18f011f5f7d5d", size = 106573, upload-time = "2025-06-19T05:50:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/11/c9/89d776685a0c2d0062b6d7d29e7d91525bc9528404fe49fff60a3b39ca84/pyahocorasick-2.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6be205779ba8e58670356a8cc5fbbbcf9255bfe24569c736d45f036fce9f2af", size = 113493, upload-time = "2025-06-19T05:50:56.919Z" },
{ url = "https://files.pythonhosted.org/packages/f7/c2/ce20c9a5b89147b70b2002f0c9b4d692677f011bb532826c4c204e72a207/pyahocorasick-2.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:43a2f3302a1c45d54fb24cd988629908b11e70da32fed0042e3558f1a6603b00", size = 34992, upload-time = "2025-06-19T05:50:58.494Z" },
{ url = "https://files.pythonhosted.org/packages/fc/3d/f4348a913b1731ca8724f093321896d3ec19ac2526bf959f6a3365873267/pyahocorasick-2.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a20d05f965ba3d5d38fd26b80d087fb59b8945d3dab3571ff9d64cef6d7edf01", size = 58130, upload-time = "2025-06-19T05:51:00.04Z" },
{ url = "https://files.pythonhosted.org/packages/b6/a4/ebea4cb5450fa77c0168fb2054916ff88eb26e1b4471d63a89b2be3f4291/pyahocorasick-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4352ade48042067eae16c9c049351cd037078fdf1885c6befe44c7fd38ec7bc9", size = 33241, upload-time = "2025-06-19T05:51:01.504Z" },
{ url = "https://files.pythonhosted.org/packages/92/e2/f233e79c6f70c0d5eaee4382f4994b8db505e9947f6c08c6bb99daacce03/pyahocorasick-2.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3463d65232e93ddbdab22be8c22ebc9246419d9be738da07af2bccf800c57107", size = 113941, upload-time = "2025-06-19T05:51:02.988Z" },
{ url = "https://files.pythonhosted.org/packages/55/35/6af44ddde1198d4a55c521fc42028046dabf5413212d74ac6c1b3caae471/pyahocorasick-2.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9850cc8fc3071c239965ba1ca2114de990493025381582176af5951a64ff11cb", size = 116381, upload-time = "2025-06-19T05:51:04.756Z" },
{ url = "https://files.pythonhosted.org/packages/ac/06/d956a977db3cfa6f58cc031ca3e728bf7fc24076b5e040927b2fad2eb5e3/pyahocorasick-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:e55347e2b884ec87c972e5f7706625f5bc4e07e703fbc1fb51a6f3bb3087d650", size = 34985, upload-time = "2025-06-19T05:51:06.384Z" },
{ url = "https://files.pythonhosted.org/packages/97/a6/b88ab854348f8449a544a435abb270ed65ed5b77ceada372ef9e998f367c/pyahocorasick-2.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:744f63790fc4d337e129c80d28f57e6ba4d22a4b7e065825c72e98f92a77e16b", size = 58174, upload-time = "2025-06-19T05:51:07.874Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e3/6aa83f4f2852d03ce28872c139580913532a85686fc49f5136e4a7efce23/pyahocorasick-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73bb94c5621565c5ad22d2f44d45edc7e568de5bd629d22a435e76d7023dae4e", size = 33285, upload-time = "2025-06-19T05:51:09.431Z" },
{ url = "https://files.pythonhosted.org/packages/4c/e5/088c0169c36581d961eb24d770a3c0a48b95d029fd12fa343f7c1a28c11f/pyahocorasick-2.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c3fb6406b3311319bef625f0269af276b75f834e5cba33b81f2e8c35a9c6c91", size = 114871, upload-time = "2025-06-19T05:51:10.513Z" },
{ url = "https://files.pythonhosted.org/packages/66/e5/d3198bba8ce7cdf4c946f71a15bf75b26e2796b805ad43041a0ea77c422d/pyahocorasick-2.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:346f92c0086589e44c279d1519187bd3421d94836875033b27b7730f11bc923e", size = 117872, upload-time = "2025-06-19T05:51:11.859Z" },
{ url = "https://files.pythonhosted.org/packages/55/15/ebd27c91dcb49487f8032e6046c11bee9037c793eb045a87d4df40c0af60/pyahocorasick-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dbc761cdf8c9a1b85f065fb2442c234b742203df8e3cd2f38fc45e4838b02d3", size = 35028, upload-time = "2025-06-19T05:51:12.936Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/d62a60af6025bc02ef2d25f29f93c591c06f4e43e51b2127f9a4a0954eb8/pyahocorasick-2.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9604d73051f96c2d5a6c267f404f3d2d02790a2680a0c0ee7069ef7660d8b", size = 58177, upload-time = "2025-06-19T05:51:13.999Z" },
{ url = "https://files.pythonhosted.org/packages/41/97/b3cf05d0a3e545ce38a10c828fb188500a31261b679fd15ef717147eadee/pyahocorasick-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57932f7e894107d5ddf011051feb081b0ff7fdd6ab94462ead0c4c716ffdbd47", size = 33285, upload-time = "2025-06-19T05:51:15.687Z" },
{ url = "https://files.pythonhosted.org/packages/d6/44/b1cd7d1b35a5f77b45b5694def4a8e6560b44cafc0dce12b7dc19a609dbe/pyahocorasick-2.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c4489c2615fc4a25824d1f12c9e775d84c2207eecedde273bbffd479d82e71", size = 114812, upload-time = "2025-06-19T05:51:17.146Z" },
{ url = "https://files.pythonhosted.org/packages/61/48/862fc0d3c92aa70a7c1721aa41460b8ac0a8d2e62aab039773c9b8d0c9d9/pyahocorasick-2.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f388b66e8973e8ac7cd7db7f90c56b2aaec4b5563b6da7bfc3e973b7ea34e1d", size = 117865, upload-time = "2025-06-19T05:51:18.619Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e3/7680654f2d5e06ed7df9c7e6387cf86ed48c670fc65d64924a9a03ccb0e7/pyahocorasick-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8eabbd6fcd65595d36dadc3fc57d536aa302833991cd6b0b872aae60c5eac3e9", size = 35021, upload-time = "2025-06-19T05:51:19.79Z" },
{ url = "https://files.pythonhosted.org/packages/df/ae/55837133a70590fd36a412f5ae09eb497603da1dd1b036eb7b3486a34d1d/pyahocorasick-2.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d0dcad4cf8f472764870ab70bd810fe04b5fb9d290c13db1f3e112e62b91e023", size = 59719, upload-time = "2026-04-27T16:31:15.565Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d6/a829b06c264cd38e5c57ace7bed48226c3ec088e2f0e7930c8a5572cc89f/pyahocorasick-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1b9bc8f48c78897fd6f073098f7007a87ce0a7e0ad38099a4aad4d760f2f3161", size = 33993, upload-time = "2026-04-27T16:31:17.003Z" },
{ url = "https://files.pythonhosted.org/packages/47/17/d9dfb1df9c1d2b749377fec553af1dd62341ffc1c124d969f5fc738b3a87/pyahocorasick-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e70206da4ecfffdd31073b26e2e9c877503ccbeb87e1fd843ca6f9f55b16077", size = 109744, upload-time = "2026-04-27T16:31:18.47Z" },
{ url = "https://files.pythonhosted.org/packages/b7/31/5d2bc0107384a9426fbfad10e287db917929ce004b67fa54cb46f1a0b188/pyahocorasick-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e48e921996044f7d161368079663608813e82dd9c22a74ba5a51abc326bb731", size = 110375, upload-time = "2026-04-27T16:31:19.889Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9f/2a438bfbc7d445cfc7d595cee367e683e34514adc028f41d39caeb895380/pyahocorasick-2.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9dee8c8aa59914435f90f6fb7ad4e02f448ac0c2533cc525414b1dd0f730a6b8", size = 113107, upload-time = "2026-04-27T16:31:21.606Z" },
{ url = "https://files.pythonhosted.org/packages/69/0f/c7a359810bef1b10c1900016028dd83f630c53c152d80a6c035a391c3237/pyahocorasick-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f015ca482c8105e28fbd6a1952726f3376534caf8bea19ea0cda34a796f7a8f8", size = 113489, upload-time = "2026-04-27T16:31:23.583Z" },
{ url = "https://files.pythonhosted.org/packages/d0/23/6dfae42e0b23607566e1aae66a603c5e1b7a343a4c7e8baa43d21f675632/pyahocorasick-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:fb6be24637846604463cd414a7537c95bdab378b0796651f78a131d5871c8e3e", size = 35166, upload-time = "2026-04-27T16:31:24.894Z" },
{ url = "https://files.pythonhosted.org/packages/7c/06/2798edbcff0d50a51f8ef527cb3f861e69f694d80043826529c33fe15aa3/pyahocorasick-2.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3a69041f5fd665ec0edcffd9562dd0f2f23c236bbc950e18ada854e29fc3dd88", size = 59714, upload-time = "2026-04-27T16:31:26.083Z" },
{ url = "https://files.pythonhosted.org/packages/58/00/4b475d2f26240253bc6412c509c1c103844a8eac326a1353d9bc798beb74/pyahocorasick-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e8f9c21fd2bd72c0454ba6df0c7dbdfd7236c5cfd161fc983476fffbde92e18f", size = 33988, upload-time = "2026-04-27T16:31:27.351Z" },
{ url = "https://files.pythonhosted.org/packages/32/9b/5eef7545f3556d8b2ca8ee943938e94a62b659ee6f6978573efd2d597e2a/pyahocorasick-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0a8bed95da02e7c874818825d65e6e31d5b38c88ecba02a6c7144524074ddade", size = 113162, upload-time = "2026-04-27T16:31:28.704Z" },
{ url = "https://files.pythonhosted.org/packages/bf/55/807c408bd7baaa137643e99b4b642abd850d83c3e80b17e17f62b5842429/pyahocorasick-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2541c437dc0f04475729076ec36aac72604b767fa347107bcd6945d61d5ba437", size = 113939, upload-time = "2026-04-27T16:31:31.935Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d4/ffe0a07979ed128ed55c9e4ac7007be4d2048c2582de68035bd84c22e585/pyahocorasick-2.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aa05c56eaeee2e0242a84f53d9927d795d26002493c69ba8a4af1d86bdca7edb", size = 116159, upload-time = "2026-04-27T16:31:33.662Z" },
{ url = "https://files.pythonhosted.org/packages/1c/97/c5b6962d93d0e7870a8e0e1d76c71cd30133a96c642190531d5fae754de0/pyahocorasick-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dfc4749cca4df4327dd2fcbbd49e5148e72840366023429729cf468f28c938a2", size = 116390, upload-time = "2026-04-27T16:31:35.554Z" },
{ url = "https://files.pythonhosted.org/packages/12/63/7072ae6d6458518c277b256a14dd1b20726192e880915b4f6d3daeb0700d/pyahocorasick-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:cb75c32f73be3f70435e49bbc5518105b54f1320a51e7da18ac989bfe93f6c1c", size = 35152, upload-time = "2026-04-27T16:31:36.828Z" },
{ url = "https://files.pythonhosted.org/packages/29/a6/2ee9301a36c9d6bcd7e745e8a98e72fddf1ff1cd3ae899f498383c3ad1c9/pyahocorasick-2.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f0df14cb10ed1e942a30c0f11d242472452e7c567acbf3ac070e5d6912b71ca9", size = 60112, upload-time = "2026-04-27T16:31:38.39Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c6/f242c7966d8207822d7ecb183101522ca03df5f302ee6520fe4412f03fae/pyahocorasick-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:873911f1d80acd82ac00aae277a9a2b335a0c0cac0a0ef1c6635b57badc6f7a6", size = 34154, upload-time = "2026-04-27T16:31:39.719Z" },
{ url = "https://files.pythonhosted.org/packages/f7/01/0a7387a6327f4ef9b7dcf3cea84dfea3e4b0e85eb37a52b612985b1f9a9a/pyahocorasick-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9a4d4f5b05ce9d8af82c40ed39cd6892613e9e8bf1b5e6ea79009c566430adb1", size = 113543, upload-time = "2026-04-27T16:31:41.311Z" },
{ url = "https://files.pythonhosted.org/packages/a1/f2/d13807476195e4ec5999a78f22db592a64da54229c9183438f3165105779/pyahocorasick-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ec1d3465f25a5063c7eaa85ecb106cbe256064669c754e0b13b2483cf613a98", size = 114873, upload-time = "2026-04-27T16:31:42.625Z" },
{ url = "https://files.pythonhosted.org/packages/af/32/d79302845be8629f9aee2a3dbeb9ad089b036f089e99589a08814e7e5910/pyahocorasick-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4e1e90eb2e755c79b9b904fd8adcca61c22b4b48811b9435f0c4b2d718895d6", size = 116455, upload-time = "2026-04-27T16:31:44.366Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c9/2e3019eb9f4404dc1fe1309535d1220740cc95275ad1b4a70f7f891cb296/pyahocorasick-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3922f66721b5b777eae758d2a0acffd98ee97dc7e6e452ba533d1c5892e15b7", size = 117863, upload-time = "2026-04-27T16:31:45.831Z" },
{ url = "https://files.pythonhosted.org/packages/3a/6e/5fa2f6fafb7a5bb82cad6e2ef3c8eed7c859ba16242766a5a425e19334b5/pyahocorasick-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:f5cc3c021be241fe9317c5991f8efba2b876e3956691322ad9e55c0d9ff7c599", size = 35258, upload-time = "2026-04-27T16:31:47.053Z" },
{ url = "https://files.pythonhosted.org/packages/31/16/4ea7db7a118778a2f56b217b8f142d1bd55e10cb6c6d59329bc58c41952a/pyahocorasick-2.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1b16eab55f961671c6eff5ead4e3fda6e85982acea86fda734b68e39e52dcd3b", size = 60118, upload-time = "2026-04-27T16:31:48.173Z" },
{ url = "https://files.pythonhosted.org/packages/ec/53/08c717e8696b3f243be89278155512a360a13b5a11bfe87a3a417f180c5e/pyahocorasick-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec6908893dffc271c1f89fe5a0f6ae872c5b7fdfb82ce032185a1fcf02339a60", size = 34160, upload-time = "2026-04-27T16:31:49.287Z" },
{ url = "https://files.pythonhosted.org/packages/5c/11/4464450c9c44719ab47082eda69424de22af51ef68c482f7e8c48a30a727/pyahocorasick-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:43e79e7f1737e8bd5290ee61bfbbc0af0a44975b8aa719ffbb00e3cd8c5c8e35", size = 113498, upload-time = "2026-04-27T16:31:50.925Z" },
{ url = "https://files.pythonhosted.org/packages/64/e0/398f558e004616411ae6914666f0aa51eb019405ef4f48358e6a9b26bc4d/pyahocorasick-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:343c93387146ddef771118cab8fc60e3be1c9c5595b647ad6c898fc940a63e20", size = 114814, upload-time = "2026-04-27T16:31:52.329Z" },
{ url = "https://files.pythonhosted.org/packages/84/dc/a7c78f3fafdee825ab2a69c7aeedc8c3bf1a82f69a710071bbeac3d8be29/pyahocorasick-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:648ee2e1dae6753cbe153d610cd8208f3da00e20456d3696de49a7606106afad", size = 116447, upload-time = "2026-04-27T16:31:54.196Z" },
{ url = "https://files.pythonhosted.org/packages/70/99/f028911b158fd9d6ea0c50a99b17b798f4cbb4d14aedf9bc07dcebfd406c/pyahocorasick-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b52bb618a6d29223470c5518daa59f319cbbca878373dcec3ca89a63759c0e5", size = 117863, upload-time = "2026-04-27T16:31:55.672Z" },
{ url = "https://files.pythonhosted.org/packages/30/75/5d5d377fab5b93462ff22496ac5a09725534ec37217626b0a5480c321e5a/pyahocorasick-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:31c743e80e92f81c390214b69f474945689f0f83db8d9bae7118a4623e5da63d", size = 35244, upload-time = "2026-04-27T16:31:56.813Z" },
{ url = "https://files.pythonhosted.org/packages/00/0b/ce8637d57f122533067e5080cbd54d4698968acd2a16921469c838ee1ae3/pyahocorasick-2.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9b87fa566bd71b46407ea8cfd86ddc6c97ba7f20eb29041ce9b5213b111e76be", size = 60047, upload-time = "2026-04-27T16:31:58.019Z" },
{ url = "https://files.pythonhosted.org/packages/63/8d/f98d8caad8bed8dc70b5b406704ca652c5bb59168984424e61732f31de50/pyahocorasick-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:523c5460afae4b9228bb9df7571ef23b90ceb3411428beb7df167d696ae054dc", size = 34114, upload-time = "2026-04-27T16:31:59.425Z" },
{ url = "https://files.pythonhosted.org/packages/60/97/b06f783364347a369c86344dbebb194535b7f41bf1df0f42dc4e64e3b655/pyahocorasick-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0e59226baf6ffb5acb6f72868ef345a4bd23d2a30ef08a9e1bf51043ea9b430d", size = 113504, upload-time = "2026-04-27T16:32:00.735Z" },
{ url = "https://files.pythonhosted.org/packages/29/b5/54b057c13eae27ceca51e68e13e1194e4c624d624b0369b571177f390a62/pyahocorasick-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c90328fb64f6d1c24bbf969194f4fe0b3aacbdddadf28ec920b34a524681a54", size = 114564, upload-time = "2026-04-27T16:32:02.184Z" },
{ url = "https://files.pythonhosted.org/packages/79/c1/a0c0ed44ebe2a0e62bebc545158707b9543fa685c384a9af90bb568444cf/pyahocorasick-2.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b10d29fb3eddf8228e41d285f2e052efddb99b6dd1ed1e0f28f00d0d0570005", size = 116371, upload-time = "2026-04-27T16:32:03.967Z" },
{ url = "https://files.pythonhosted.org/packages/c4/db/d174d6bbc6caa811ac3c3695de28785b36d83ee94aecd461f58e621068fc/pyahocorasick-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba7b98de0ff3203e2cd8c27682f6934c0d893cd97e65a45b8478e468d9919c90", size = 117877, upload-time = "2026-04-27T16:32:05.407Z" },
{ url = "https://files.pythonhosted.org/packages/c5/96/37c50ac951bb0260ec38d8d12e5b51587ef1ef4035c279088f2771544b28/pyahocorasick-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4acb11a0a2ff10519465749d22ad70789e9fe7f81dc8fe9957a8868e499e18ab", size = 35987, upload-time = "2026-04-27T16:32:07.08Z" },
]
[[package]]
@@ -1311,16 +1326,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.13.1"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
{ url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" },
]
[[package]]
@@ -1549,27 +1564,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.9.10"
version = "0.15.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/8e/fafaa6f15c332e73425d9c44ada85360501045d5ab0b81400076aff27cf6/ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7", size = 3759776, upload-time = "2025-03-07T15:27:44.363Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/b2/af7c2cc9e438cbc19fafeec4f20bfcd72165460fe75b2b6e9a0958c8c62b/ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d", size = 10049494, upload-time = "2025-03-07T15:26:51.268Z" },
{ url = "https://files.pythonhosted.org/packages/6d/12/03f6dfa1b95ddd47e6969f0225d60d9d7437c91938a310835feb27927ca0/ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d", size = 10853584, upload-time = "2025-03-07T15:26:56.104Z" },
{ url = "https://files.pythonhosted.org/packages/02/49/1c79e0906b6ff551fb0894168763f705bf980864739572b2815ecd3c9df0/ruff-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5284dcac6b9dbc2fcb71fdfc26a217b2ca4ede6ccd57476f52a587451ebe450d", size = 10155692, upload-time = "2025-03-07T15:27:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/5b/01/85e8082e41585e0e1ceb11e41c054e9e36fed45f4b210991052d8a75089f/ruff-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47678f39fa2a3da62724851107f438c8229a3470f533894b5568a39b40029c0c", size = 10369760, upload-time = "2025-03-07T15:27:04.023Z" },
{ url = "https://files.pythonhosted.org/packages/a1/90/0bc60bd4e5db051f12445046d0c85cc2c617095c0904f1aa81067dc64aea/ruff-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99713a6e2766b7a17147b309e8c915b32b07a25c9efd12ada79f217c9c778b3e", size = 9912196, upload-time = "2025-03-07T15:27:06.93Z" },
{ url = "https://files.pythonhosted.org/packages/66/ea/0b7e8c42b1ec608033c4d5a02939c82097ddcb0b3e393e4238584b7054ab/ruff-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524ee184d92f7c7304aa568e2db20f50c32d1d0caa235d8ddf10497566ea1a12", size = 11434985, upload-time = "2025-03-07T15:27:10.082Z" },
{ url = "https://files.pythonhosted.org/packages/d5/86/3171d1eff893db4f91755175a6e1163c5887be1f1e2f4f6c0c59527c2bfd/ruff-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df92aeac30af821f9acf819fc01b4afc3dfb829d2782884f8739fb52a8119a16", size = 12155842, upload-time = "2025-03-07T15:27:12.727Z" },
{ url = "https://files.pythonhosted.org/packages/89/9e/700ca289f172a38eb0bca752056d0a42637fa17b81649b9331786cb791d7/ruff-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de42e4edc296f520bb84954eb992a07a0ec5a02fecb834498415908469854a52", size = 11613804, upload-time = "2025-03-07T15:27:15.944Z" },
{ url = "https://files.pythonhosted.org/packages/f2/92/648020b3b5db180f41a931a68b1c8575cca3e63cec86fd26807422a0dbad/ruff-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d257f95b65806104b6b1ffca0ea53f4ef98454036df65b1eda3693534813ecd1", size = 13823776, upload-time = "2025-03-07T15:27:18.996Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a6/cc472161cd04d30a09d5c90698696b70c169eeba2c41030344194242db45/ruff-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60dec7201c0b10d6d11be00e8f2dbb6f40ef1828ee75ed739923799513db24c", size = 11302673, upload-time = "2025-03-07T15:27:21.655Z" },
{ url = "https://files.pythonhosted.org/packages/6c/db/d31c361c4025b1b9102b4d032c70a69adb9ee6fde093f6c3bf29f831c85c/ruff-0.9.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d838b60007da7a39c046fcdd317293d10b845001f38bcb55ba766c3875b01e43", size = 10235358, upload-time = "2025-03-07T15:27:24.72Z" },
{ url = "https://files.pythonhosted.org/packages/d1/86/d6374e24a14d4d93ebe120f45edd82ad7dcf3ef999ffc92b197d81cdc2a5/ruff-0.9.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccaf903108b899beb8e09a63ffae5869057ab649c1e9231c05ae354ebc62066c", size = 9886177, upload-time = "2025-03-07T15:27:27.282Z" },
{ url = "https://files.pythonhosted.org/packages/00/62/a61691f6eaaac1e945a1f3f59f1eea9a218513139d5b6c2b8f88b43b5b8f/ruff-0.9.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9567d135265d46e59d62dc60c0bfad10e9a6822e231f5b24032dba5a55be6b5", size = 10864747, upload-time = "2025-03-07T15:27:30.637Z" },
{ url = "https://files.pythonhosted.org/packages/ee/94/2c7065e1d92a8a8a46d46d9c3cf07b0aa7e0a1e0153d74baa5e6620b4102/ruff-0.9.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f202f0d93738c28a89f8ed9eaba01b7be339e5d8d642c994347eaa81c6d75b8", size = 11360441, upload-time = "2025-03-07T15:27:33.356Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8f/1f545ea6f9fcd7bf4368551fb91d2064d8f0577b3079bb3f0ae5779fb773/ruff-0.9.10-py3-none-win32.whl", hash = "sha256:bfb834e87c916521ce46b1788fbb8484966e5113c02df216680102e9eb960029", size = 10247401, upload-time = "2025-03-07T15:27:35.994Z" },
{ url = "https://files.pythonhosted.org/packages/4f/18/fb703603ab108e5c165f52f5b86ee2aa9be43bb781703ec87c66a5f5d604/ruff-0.9.10-py3-none-win_amd64.whl", hash = "sha256:f2160eeef3031bf4b17df74e307d4c5fb689a6f3a26a2de3f7ef4044e3c484f1", size = 11366360, upload-time = "2025-03-07T15:27:38.66Z" },
{ url = "https://files.pythonhosted.org/packages/35/85/338e603dc68e7d9994d5d84f24adbf69bae760ba5efd3e20f5ff2cec18da/ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69", size = 10436892, upload-time = "2025-03-07T15:27:41.687Z" },
{ url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" },
{ url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" },
{ url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" },
{ url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" },
{ url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" },
{ url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" },
{ url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" },
{ url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" },
{ url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" },
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
]
[[package]]
@@ -1680,11 +1695,11 @@ wheels = [
[[package]]
name = "tld"
version = "0.13.1"
version = "0.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/a1/5723b07a70c1841a80afc9ac572fdf53488306848d844cd70519391b0d26/tld-0.13.1.tar.gz", hash = "sha256:75ec00936cbcf564f67361c41713363440b6c4ef0f0c1592b5b0fbe72c17a350", size = 462000, upload-time = "2025-05-21T22:18:29.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5d/76b4383ac4e5b5e254e50c09807b3e13820bed6d6c11cd540264988d6802/tld-0.13.2.tar.gz", hash = "sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345", size = 467175, upload-time = "2026-03-06T23:50:34.498Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/70/b2f38360c3fc4bc9b5e8ef429e1fde63749144ac583c2dbdf7e21e27a9ad/tld-0.13.1-py2.py3-none-any.whl", hash = "sha256:a2d35109433ac83486ddf87e3c4539ab2c5c2478230e5d9c060a18af4b03aa7c", size = 274718, upload-time = "2025-05-21T22:18:25.811Z" },
{ url = "https://files.pythonhosted.org/packages/9e/90/39a85a4b63c84213e78b3c17d22e1bf45328acf8ebb33ef93be30d0a3911/tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c", size = 296743, upload-time = "2026-03-06T23:50:32.465Z" },
]
[[package]]
@@ -1761,14 +1776,14 @@ wheels = [
[[package]]
name = "types-requests"
version = "2.33.0.20260503"
version = "2.33.0.20260508"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/b8/57e94268c0d82ac3eaa2fc35aa8ca7bbc2542f726b67dcf90b0b00a3b14d/types_requests-2.33.0.20260503.tar.gz", hash = "sha256:9721b2d9dbee7131f2fb39f20f0ebb1999c18cef4b512c9a7932f3722de7c5f4", size = 23931, upload-time = "2026-05-03T05:20:08.882Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/6b/eb226bdd61a982c9a03e02c657fb4ab001733506e6423906ac142331f2e3/types_requests-2.33.0.20260508.tar.gz", hash = "sha256:81b2ae5f0d20967714a6aa5ef9284c05570d7cb06b7de8f2a77b918b63ddd411", size = 23991, upload-time = "2026-05-08T04:50:56.818Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/82/959113a6351f3ca046cd0a8cd2cee071d7ea47473560557a01eeae9a6fe2/types_requests-2.33.0.20260503-py3-none-any.whl", hash = "sha256:02aaa7e3577a13471715bb1bddb693cc985ea514f754b503bf033e6a09a3e528", size = 20736, upload-time = "2026-05-03T05:20:07.858Z" },
{ url = "https://files.pythonhosted.org/packages/cb/96/080db0afdf2c5cc5fe512b41354e8d114fe8f65e9510c56ff8dfd40216ce/types_requests-2.33.0.20260508-py3-none-any.whl", hash = "sha256:fa01459cca184229713df03709db46a905325906d27e042cd4fd7ea3d15d3400", size = 20722, upload-time = "2026-05-08T04:50:55.548Z" },
]
[[package]]