mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-22 15:18:26 +02:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c57179a62c | ||
|
|
2689176c0e | ||
|
|
638937e838 | ||
|
|
f95f7b1283 | ||
|
|
b9f13b8146 | ||
|
|
58558fdfb7 | ||
|
|
7c7e332e7b | ||
|
|
a232c988f9 | ||
|
|
2f4123222f | ||
|
|
81a7bc977b | ||
|
|
3b20bd944b | ||
|
|
6bbb5957af | ||
|
|
6a6c1758c3 | ||
|
|
174ce08812 |
No files matched your search
@@ -1,5 +1,8 @@
|
||||
name: Mypy
|
||||
on: workflow_dispatch
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mypy_py3:
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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]]
|
||||
|
||||
Reference in new issue
Block a user