diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9d866e3..53dcece 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,9 @@ version: 2 updates: - - package-ecosystem: "pip" # See documentation for possible values + - package-ecosystem: "uv" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/workflows/add-issue-to-project.yml b/.github/workflows/add-issue-to-project.yml index c4e6d84..825eeee 100644 --- a/.github/workflows/add-issue-to-project.yml +++ b/.github/workflows/add-issue-to-project.yml @@ -11,7 +11,7 @@ jobs: name: Add issue to project runs-on: ubuntu-latest steps: - - uses: actions/add-to-project@v0.5.0 + - uses: actions/add-to-project@v2 with: # You can target a project in a different organization # to the issue diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 67d5b90..54402fc 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -1,5 +1,8 @@ name: Mypy -on: workflow_dispatch +on: + pull_request: + branches: [main] + workflow_dispatch: jobs: mypy_py3: @@ -8,15 +11,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: - python-version: 3.9 - cache: 'pip' + python-version: "3.10" + - name: Install uv + uses: astral-sh/setup-uv@v10.0.0 + with: + enable-cache: true - name: Install Dependencies run: | - pip install mypy + uv sync --locked --group dev - name: mypy run: | make mypy diff --git a/.github/workflows/publish-release-docker.yml b/.github/workflows/publish-release-docker.yml index dde12b7..eddca22 100644 --- a/.github/workflows/publish-release-docker.yml +++ b/.github/workflows/publish-release-docker.yml @@ -36,10 +36,10 @@ jobs: tag-suffix: "-android" steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. - name: Log in to the Container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -47,7 +47,7 @@ jobs: # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} flavor: | @@ -61,13 +61,13 @@ jobs: type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }} # This step sets up some additional capabilities to generate the provenance and sbom attestations - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. - name: Build and push Docker image id: push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: file: ${{ matrix.platform.dockerfile }} context: . @@ -76,4 +76,3 @@ jobs: labels: ${{ steps.meta.outputs.labels }} provenance: mode=max sbom: true - diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index a05e0ef..08fc2cd 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -12,15 +12,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v7 with: - python-version: 3.9 - cache: 'pip' + python-version: "3.10" + - name: Install uv + uses: astral-sh/setup-uv@v10.0.0 + with: + enable-cache: true - name: Install Dependencies run: | - pip install ruff + uv sync --locked --group dev - name: ruff run: | make ruff diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0243d5e..c98ab22 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,15 +15,18 @@ jobs: python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 + - name: Install uv + uses: astral-sh/setup-uv@v10.0.0 + with: + enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install Python dependencies run: | - make install - make test-requirements + uv sync --locked --group dev --python ${{ matrix.python-version }} - name: Test with pytest run: | set -o pipefail diff --git a/.github/workflows/update-ios-data.yml b/.github/workflows/update-ios-data.yml index 5df9cfa..443918c 100644 --- a/.github/workflows/update-ios-data.yml +++ b/.github/workflows/update-ios-data.yml @@ -11,10 +11,12 @@ jobs: update-ios-version: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + - name: Install script dependencies + run: python -m pip install "packaging==26.3" - name: Run script to fetch latest iOS releases from Apple RSS feed. - run: python3 .github/workflows/scripts/update-ios-releases.py + run: python .github/workflows/scripts/update-ios-releases.py - name: Create Pull Request uses: peter-evans/create-pull-request@v8 with: diff --git a/.gitignore b/.gitignore index 766bd03..838330d 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,9 @@ ipython_config.py # pyenv .python-version +# uv project Python version +!.python-version + # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies @@ -138,4 +141,4 @@ dmypy.json .idea # Sublime Text project files -*.sublime* \ No newline at end of file +*.sublime* diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 8fc0ba1..73af48f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,14 +6,16 @@ version: 2 build: - os: "ubuntu-22.04" + os: "ubuntu-24.04" tools: python: "3.11" mkdocs: configuration: mkdocs.yml -# Optionally set the version of Python and requirements required to build your docs python: - install: - - requirements: docs/requirements.txt + install: + - method: uv + command: sync + groups: + - docs diff --git a/Dockerfile b/Dockerfile index 0db3853..da06064 100644 --- a/Dockerfile +++ b/Dockerfile @@ -104,6 +104,7 @@ RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \ # Create main image FROM ubuntu:24.04 AS main +COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/ LABEL org.opencontainers.image.url="https://mvt.re" LABEL org.opencontainers.image.documentation="https://docs.mvt.re" @@ -133,12 +134,8 @@ COPY --from=build-usbmuxd /build / # Install mvt using the locally checked out source COPY . mvt/ -RUN apt-get update \ - && apt-get install -y git python3-pip \ - && PIP_NO_CACHE_DIR=1 pip3 install --break-system-packages ./mvt \ - && apt-get remove -y python3-pip git && apt-get autoremove -y \ - && rm -rf /var/lib/apt/lists/* \ - && rm -rf mvt +RUN uv pip install --system --break-system-packages --no-cache ./mvt \ + && rm -rf mvt # Installing ABE ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \ diff --git a/Dockerfile.android b/Dockerfile.android index 1c82999..2c060db 100644 --- a/Dockerfile.android +++ b/Dockerfile.android @@ -1,5 +1,6 @@ # Create main image FROM python:3.10.14-alpine3.20 AS main +COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/ LABEL org.opencontainers.image.url="https://mvt.re" LABEL org.opencontainers.image.documentation="https://docs.mvt.re" @@ -20,7 +21,7 @@ RUN apk add --no-cache \ # Install mvt COPY ./ mvt RUN apk add --no-cache --virtual .build-deps gcc musl-dev \ - && PIP_NO_CACHE_DIR=1 pip3 install ./mvt \ + && uv pip install --system --no-cache ./mvt \ && apk del .build-deps gcc musl-dev && rm -rf ./mvt # Installing ABE diff --git a/Dockerfile.ios b/Dockerfile.ios index 78c1967..fccbe45 100644 --- a/Dockerfile.ios +++ b/Dockerfile.ios @@ -105,6 +105,7 @@ RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \ # Main image # ---------- FROM python:3.10.14-alpine3.20 AS main +COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/ LABEL org.opencontainers.image.url="https://mvt.re" LABEL org.opencontainers.image.documentation="https://docs.mvt.re" @@ -131,7 +132,7 @@ COPY --from=build-usbmuxd /build / # Install mvt using the locally checked out source COPY ./ mvt RUN apk add --no-cache --virtual .build-deps git gcc musl-dev \ - && PIP_NO_CACHE_DIR=1 pip3 install ./mvt \ + && uv pip install --system --no-cache ./mvt \ && apk del .build-deps git gcc musl-dev && rm -rf ./mvt ENTRYPOINT [ "/usr/local/bin/mvt-ios" ] diff --git a/Makefile b/Makefile index e310dde..528c697 100644 --- a/Makefile +++ b/Makefile @@ -1,39 +1,39 @@ PWD = $(shell pwd) +UV ?= uv check: ruff mypy ruff: - ruff check . + $(UV) run ruff check . mypy: - mypy + $(UV) run mypy test: - python3 -m pytest + $(UV) run pytest test-ci: - python3 -m pytest -v + $(UV) run pytest -v install: - python3 -m pip install --upgrade -e . + $(UV) sync test-requirements: - python3 -m pip install --upgrade --group dev + $(UV) sync --group dev generate-proto-parsers: # Generate python parsers for protobuf files PROTO_FILES=$$(find src/mvt/android/parsers/proto/ -iname "*.proto"); \ - protoc -Isrc/mvt/android/parsers/proto/ --python_betterproto2_out=src/mvt/android/parsers/proto/ $$PROTO_FILES + $(UV) run protoc -Isrc/mvt/android/parsers/proto/ --python_betterproto2_out=src/mvt/android/parsers/proto/ $$PROTO_FILES clean: rm -rf $(PWD)/build $(PWD)/dist $(PWD)/src/mvt.egg-info dist: - python3 -m pip install --upgrade build - python3 -m build + $(UV) build upload: - python3 -m twine upload dist/* + $(UV) tool run twine upload dist/* test-upload: - python3 -m twine upload --repository testpypi dist/* + $(UV) tool run twine upload --repository testpypi dist/* diff --git a/README.md b/README.md index f331433..65fcdee 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ # Mobile Verification Toolkit -> [!IMPORTANT] -> Soon we will merge the v3 pull request which will result in breaking changes. If you rely on mvt output in other script make sure to the the branch before we merge. More details: https://github.com/mvt-project/mvt/issues/757 +> [!IMPORTANT] +> We recently merged the "v3" branch. This introduced breaking changes. If you relied on mvt output in other scripts They might have broken. More details: https://github.com/mvt-project/mvt/issues/757 [![](https://img.shields.io/pypi/v/mvt)](https://pypi.org/project/mvt/) [![Documentation Status](https://readthedocs.org/projects/mvt/badge/?version=latest)](https://docs.mvt.re/en/latest/?badge=latest) @@ -41,6 +41,18 @@ MVT can be installed from sources or from [PyPI](https://pypi.org/project/mvt/) pip3 install mvt ``` +You can also install MVT from PyPI with [uv](https://docs.astral.sh/uv/). First, install uv: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +Then install MVT as a command-line tool: + +```bash +uv tool install mvt +``` + For alternative installation options and known issues, please refer to the [documentation](https://docs.mvt.re/en/latest/install/) as well as [GitHub Issues](https://github.com/mvt-project/mvt/issues). @@ -48,6 +60,33 @@ 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. + +Users can also add top-level commands to `mvt-ios` and `mvt-android` from +installed Python packages or local files and folders. See the +[custom CLI command documentation](https://docs.mvt.re/en/latest/custom_commands/) +for the plugin entry points and `--load-command` interface. + ## License diff --git a/docs/android/adb.md b/docs/android/adb.md index fd3d3ef..29a0e28 100644 --- a/docs/android/adb.md +++ b/docs/android/adb.md @@ -1,56 +1,29 @@ -# Check over ADB +# ADB Analysis Removed -In order to check an Android device over the [Android Debug Bridge (adb)](https://developer.android.com/studio/command-line/adb) you will first need to install [Android SDK Platform Tools](https://developer.android.com/studio/releases/platform-tools). If you have installed [Android Studio](https://developer.android.com/studio/) you should already have access to `adb` and other utilities. +The ability to analyze Android devices directly over the [Android Debug Bridge (adb)](https://developer.android.com/studio/command-line/adb) has been removed from MVT. -While many Linux distributions already package Android Platform Tools (for example `android-platform-tools-base` on Debian), it is preferable to install the most recent version from the official website. Packaged versions might be outdated and incompatible with most recent Android handsets. - -Next you will need to enable debugging on the Android device you are testing. [Please follow the official instructions on how to do so.](https://developer.android.com/studio/command-line/adb) - -## Connecting over USB - -The easiest way to check the device is over a USB transport. You will need to have USB debugging enabled and the device plugged into your computer. If everything is configured appropriately you should see your device when launching the command `adb devices`. - -Now you can try launching MVT with: +Use [AndroidQF](https://github.com/mvt-project/androidqf) to collect forensic artifacts from an Android device, then analyze the collected output with: ```bash -mvt-android check-adb --output /path/to/results +mvt-android check-androidqf /path/to/androidqf-output ``` -!!! warning - The `check-adb` command is deprecated and will be removed in a future release. - Whenever possible, prefer acquiring device data using the AndroidQF project (https://github.com/mvt-project/androidqf/) and then analyze those acquisitions with MVT. - - Running `mvt-android check-adb` will also emit a runtime deprecation warning advising you to migrate to AndroidQF. - -If you have previously started an adb daemon MVT will alert you and require you to kill it with `adb kill-server` and relaunch the command. - -!!! warning - MVT relies on the Python library [adb-shell](https://pypi.org/project/adb-shell/) to connect to an Android device, which relies on libusb for the USB transport. Because of known driver issues, Windows users [are recommended](https://github.com/JeffLIrion/adb_shell/issues/118) to install appropriate drivers using [Zadig](https://zadig.akeo.ie/). Alternatively, an easier option might be to use the TCP transport and connect over Wi-Fi as describe next. - -## Connecting over Wi-FI - -When connecting to the device over USB is not possible or not working properly, an alternative option is to connect over the network. In order to do so, first launch an adb daemon at a fixed port number: +For a standalone Android bug report, use: ```bash -adb tcpip 5555 +mvt-android check-bugreport /path/to/bugreport.zip ``` -Then you can specify the IP address of the phone with the adb port number to MVT like so: +## Reasons for Removal -```bash -mvt-android check-adb --serial 192.168.1.20:5555 --output /path/to/results -``` +1. **Inconsistent Data Collection Across Devices** + Android devices vary significantly in system architecture, security policy, and diagnostic log availability. This made it difficult to collect reliable forensic data across devices. -Where `192.168.1.20` is the correct IP address of your device. +2. **Incomplete Forensic Data Acquisition** + Direct ADB analysis did not retrieve a complete forensic snapshot. Critical artifacts such as full bug reports could be missing. -!!! warning - The `check-adb` workflow shown above is deprecated. If you can acquire an AndroidQF acquisition from the device (recommended), use the AndroidQF project to create that acquisition: https://github.com/mvt-project/androidqf/ - - AndroidQF acquisitions provide a more stable, reproducible analysis surface and are the preferred workflow going forward. +3. **Duplicated Analysis Paths** + Similar artifacts were parsed through separate ADB, AndroidQF, and bugreport modules, which made behavior harder to keep consistent. -## MVT modules requiring root privileges - -!!! warning - Deprecated: many `mvt-android check-adb` workflows are deprecated and will be removed in a future release. Whenever possible, prefer acquiring an AndroidQF acquisition using the AndroidQF project (https://github.com/mvt-project/androidqf/). - -Of the currently available `mvt-android check-adb` modules a handful require root privileges to function correctly. This is because certain files, such as browser history and SMS messages databases are not accessible with user privileges through adb. These modules are to be considered OPTIONALLY available in case the device was already jailbroken. **Do NOT jailbreak your own device unless you are sure of what you are doing!** Jailbreaking your phone exposes it to considerable security risks! +4. **Workflow Consistency** + MVT now focuses on analyzing acquired artifacts rather than interacting with live devices directly. diff --git a/docs/android/backup.md b/docs/android/backup.md index 81344aa..fae0d17 100644 --- a/docs/android/backup.md +++ b/docs/android/backup.md @@ -57,3 +57,9 @@ If the backup is encrypted, ABE will prompt you to enter the password. Alternatively, [ab-decrypt](https://github.com/joernheissler/ab-decrypt) can be used for that purpose. You can then extract SMSs with MVT by passing the folder path as parameter instead of the `.ab` file: `mvt-android check-backup --output /path/to/results/ /path/to/backup/` (the path to backup given should be the folder containing the `apps` folder). + +When an output folder is specified, URLs extracted from SMS and MMS messages +are also written to `urls.json`. Each entry contains the URL, its expanded +destination when MVT resolved a shortened URL during indicator checking, the +message timestamp, and the `sms` source. The same file is created by +`check-androidqf` when its nested Android backup contains messages with URLs. diff --git a/docs/android/download_apks.md b/docs/android/download_apks.md deleted file mode 100644 index b7ea886..0000000 --- a/docs/android/download_apks.md +++ /dev/null @@ -1,28 +0,0 @@ -# Downloading APKs from an Android phone - -MVT allows you to attempt to download all available installed packages (APKs) from a device in order to further inspect them and potentially identify any which might be malicious in nature. - -You can do so by launching the following command: - -```bash -mvt-android download-apks --output /path/to/folder -``` - -It might take several minutes to complete. - -!!! info - MVT will likely warn you it was unable to download certain installed packages. There is no reason to be alarmed: this is typically expected behavior when MVT attempts to download a system package it has no privileges to access. - -Optionally, you can decide to enable lookups of the SHA256 hash of all the extracted APKs on [VirusTotal](https://www.virustotal.com). While these lookups do not provide any conclusive assessment on all of the extracted APKs, they might highlight any known malicious ones: - -```bash -MVT_VT_API_KEY= mvt-android download-apks --output /path/to/folder --virustotal -``` - -Please note that in order to use VirusTotal lookups you are required to provide your own API key through the `MVT_VT_API_KEY` environment variable. You should also note that VirusTotal enforces strict API usage. Be mindful that MVT might consume your hourly search quota. - -In case you have a previous extraction of APKs you want to later check against VirusTotal, you can do so with the following arguments: - -```bash -MVT_VT_API_KEY= mvt-android download-apks --from-file /path/to/folder/apks.json --virustotal -``` diff --git a/docs/android/intrusion_logs.md b/docs/android/intrusion_logs.md new file mode 100644 index 0000000..da8fc83 --- /dev/null +++ b/docs/android/intrusion_logs.md @@ -0,0 +1,82 @@ +# Check Android Intrusion Logs + +Recent versions of Android can produce structured *Intrusion Logs* — newline-delimited JSON records derived from the platform's [SecurityLog API](https://developer.android.com/reference/android/app/admin/SecurityLog). Intrusion Logging is offered as a new option under Android's **Advanced Protection Mode**, which users can opt into on their device; no MDM or device-policy configuration is required. When enabled, these logs provide a high-fidelity record of process starts, DNS queries, outbound network connections, ADB activity, keyguard events, and other security-relevant operations. The initial Intrusion Logging feature was released for Android 16 in May 2026. The feature and supported events is likely to be expanded over time. + +For background on how this data source was introduced and why it is forensically valuable, see the Amnesty International Security Lab announcement: [Android Intrusion Logging as a new source of data for consensual forensic analysis](https://securitylab.amnesty.org/latest/2026/05/android-intrusion-logging-as-a-new-source-of-data-for-consensual-forensic-analysis/). + +## Recommended workflow: collect with AndroidQF + +[AndroidQF](https://github.com/mvt-project/androidqf) is the recommended way to acquire data from an Android device for analysis with MVT. During acquisition AndroidQF will prompt the user to also collect intrusion logs from the device, and writes them into an `intrusion-logs/` subdirectory of the acquisition output. + +When you analyse such an acquisition with `mvt-android check-androidqf`, MVT automatically detects the `intrusion-logs/` directory and runs the same intrusion-log checks described below — there is no need to invoke a separate command: + +```bash +mvt-android check-androidqf --output /path/to/results/ /path/to/androidqf-output/ +``` + +The device timezone is read from the AndroidQF acquisition (`getprop.txt`) and applied to event timestamps automatically. + +## Standalone command: `check-intrusion-logs` + +The `mvt-android check-intrusion-logs` command runs the intrusion-log analysis directly against a set of log files. Prefer the AndroidQF workflow above; use the standalone command when the intrusion logs were collected outside of an AndroidQF acquisition, or when re-analysing only a set of intrusion logs. + +## Expected input + +`check-intrusion-logs` accepts either: + +- a **directory** containing one or more `.txt` files (recursively), or +- a **`.zip` archive** containing such `.txt` files (nested `.zip` archives are also walked). + +Each `.txt` file is expected to contain newline-delimited JSON, with one JSON object per line. Each object wraps a single event under a top-level key indicating its type, for example: + +```json +{"dns_event": {"event_time": 1746979200000, "hostname": "example.com", "ip_addresses": ["93.184.216.34"], "package_name": "com.example.app"}} +{"connect_event": {"event_time": 1746979201000, "ip_address": "93.184.216.34", "port": 443, "package_name": "com.example.app"}} +{"security_event": {"event_time": 1746979202000, "tag": 210005, "data": ["..."]}} +``` + +Identical events that appear across multiple overlapping log files (e.g. daily rotations) are de-duplicated on a first-seen basis. + +## Running the analysis + +```bash +mvt-android check-intrusion-logs --output /path/to/results/ /path/to/intrusion-logs/ +``` + +A `.zip` archive can be passed directly in place of the directory: + +```bash +mvt-android check-intrusion-logs --output /path/to/results/ /path/to/intrusion-logs.zip +``` + +### Options + +| Option | Description | +| --- | --- | +| `-i, --iocs PATH` | Path to a STIX2 indicator file. May be passed multiple times. | +| `-o, --output PATH` | Directory where JSON results and the timeline CSV will be written. | +| `-l, --list-modules` | List the available intrusion-log modules and exit. | +| `-m, --module NAME` | Run a single module (e.g. `DnsEvent`) instead of all of them. | +| `-t, --timezone TZ` | IANA timezone name for the device (e.g. `Europe/Paris`). When set, event timestamps are converted to the device's local time instead of UTC. | +| `-v, --verbose` | Verbose logging. | + +## Modules + +The command runs the following modules over the parsed events: + +- **`DnsEvent`** — DNS resolution events. Hostnames and resolved IP addresses are checked against domain indicators, and the requesting `package_name` is checked against app-identifier indicators. +- **`ConnectEvent`** — Outbound network connection events. Destination IPs (with localhost addresses skipped) are checked against domain indicators, and `package_name` is checked against app-identifier indicators. +- **`SecurityEvent`** — Security log events keyed by Android `SecurityLog` tag IDs (e.g. `app_process_start`, `adb_shell_cmd`, `keyguard_dismissed`, `os_startup`, `cert_*` events). These are surfaced in the timeline to help reconstruct device activity around suspected events. + +All three modules share a single pre-parsing pass over the input, so adding more modules in the future does not multiply I/O cost. Additional modules will be added in the future to support new event types which are generated by the Intrusion Logging feature. + +## Interpreting results + +A successful IOC match raises a `CRITICAL` alert that includes the matched indicator, the offending event, and the event timestamp. Alerts are summarised at the end of the run and persisted alongside the per-module JSON results. + +When `--timezone` is provided, timestamps in the timeline and JSON output reflect the device's local wall-clock time. Otherwise timestamps are in UTC, consistent with the rest of MVT. + +## Limitations + +- This page assumes the intrusion logs have already been collected from the device. The recommended collection path is via AndroidQF (see above); intrusion logging itself must have been enabled on the device beforehand by opting into Android's Advanced Protection mode and also enabling the optional Intrusion Logging feature (see the [Amnesty blog post](https://securitylab.amnesty.org/latest/2026/05/android-intrusion-logging-as-a-new-source-of-data-for-consensual-forensic-analysis/) for details). +- As with all IOC-based analysis, public indicators alone are not sufficient to conclude that a device is uncompromised. See the [Indicators of Compromise](../iocs.md) page for context. diff --git a/docs/android/methodology.md b/docs/android/methodology.md index e8062c6..797ada8 100644 --- a/docs/android/methodology.md +++ b/docs/android/methodology.md @@ -1,23 +1,75 @@ # Methodology for Android forensic -Unfortunately Android devices provide much less observability than their iOS cousins. Android stores very little diagnostic information useful to triage potential compromises, and because of this `mvt-android` capabilities are limited as well. +Unfortunately Android devices provide fewer complete forensically useful datasources than their iOS cousins. Unlike iOS, the Android backup feature only provides a limited about of relevant data. + +Android diagnostic logs such as *bugreport files* can be inconsistent in format and structure across different Android versions and device vendors. The limited diagnostic information available makes it difficult to triage potential compromises, and because of this `mvt-android` capabilities are limited as well. However, not all is lost. -## Check installed Apps +## Check Android devices with AndroidQF and MVT -Because malware attacks over Android typically take the form of malicious or backdoored apps, the very first thing you might want to do is to extract and verify all installed Android packages and triage quickly if there are any which stand out as malicious or which might be atypical. +The [AndroidQF](https://github.com/mvt-project/androidqf) tool can be used to collect a wide range of forensic artifacts from an Android device including an Android backup, a bugreport file, and a range of system logs. MVT natively supports analyzing the generated AndroidQF output for signs of device compromise. -While it is out of the scope of this documentation to dwell into details on how to analyze Android apps, MVT does allow to easily and automatically extract information about installed apps, download copies of them, and quickly look them up on services such as [VirusTotal](https://www.virustotal.com). +### Why Use AndroidQF? -!!! info "Using VirusTotal" - Please note that in order to use VirusTotal lookups you are required to provide your own API key through the `MVT_VT_API_KEY` environment variable. You should also note that VirusTotal enforces strict API usage. Be mindful that MVT might consume your hourly search quota. +- **Complete and raw data extraction** + AndroidQF collects full forensic artifacts using an on-device forensic collection agent, ensuring that no crucial data is overlooked. The data collection does not depended on the shell environment or utilities available on the device. -## Check the device over Android Debug Bridge +- **Consistent and standardized output** + By collecting a predefined and complete set of forensic files, AndroidQF ensures consistency in data acquisition across different Android devices. -Some additional diagnostic information can be extracted from the phone using the [Android Debug Bridge (adb)](https://developer.android.com/studio/command-line/adb). `mvt-android` allows to automatically extract information including [dumpsys](https://developer.android.com/studio/command-line/dumpsys) results, details on installed packages (without download), running processes, presence of root binaries and packages, and more. +- **Future-proof analysis** + Since the full forensic artifacts are preserved, analysts can extract new evidence or apply updated analysis techniques without requiring access to the original device. +- **Cross-platform tool without dependencies** + AndroidQF is a standalone Go binary which can be used to remotely collect data from an Android device without the device owner needing to install MVT or a Python environment. + +### Workflow for Android Forensic Analysis with AndroidQF + +With AndroidQF the analysis process is split into a separate data collection and data analysis stages. + +1. **Extract Data Using AndroidQF** + Deploy the AndroidQF forensic collector to acquire all relevant forensic artifacts from the Android device. + +2. **Analyze Extracted Data with MVT** + Use the `mvt-android check-androidqf` command to perform forensic analysis on the extracted artifacts. + +By separating artifact collection from forensic analysis, this approach ensures a more reliable and scalable methodology for Android forensic investigations. + +For more information, refer to the [AndroidQF project documentation](https://github.com/mvt-project/androidqf). + +### VirusTotal package lookups + +AndroidQF records APK file hashes in `packages.json`. MVT can optionally look up non-system APK hashes on VirusTotal while checking an AndroidQF acquisition: + +```bash +MVT_VT_API_KEY= mvt-android check-androidqf --virustotal /path/to/androidqf-output +``` + +The `--virustotal` option is disabled by default because it sends APK hashes to VirusTotal and requires network access. It uses the `VT_API_KEY` MVT configuration value, which can also be provided through the `MVT_VT_API_KEY` environment variable. + +To avoid exhausting free VirusTotal API quotas, MVT waits 16 seconds between package hash requests by default. Use `--delay` to change the delay, or `--delay 0` to disable throttling: + +```bash +mvt-android check-androidqf --virustotal --delay 30 /path/to/androidqf-output +``` + +## Android Intrusion Logs + +On devices where the user has opted into Android's [**Advanced Protection Mode**](https://support.google.com/android/answer/16339980) and turned on the optional Intrusion Logging featrue, Android can create and archive structured *Intrusion Logs* in an encrypted format. These logs record DNS queries, outbound network connections, process starts, ADB activity and other security-relevant events, and are a high-fidelity complement to the rest of an AndroidQF acquisition. The logs are generated on-device and encrypted before being stored in the Google account associated with the device. The encryption key is protected by the user device PIN. The intrusion log data is not accessible to Google. + +AndroidQF will prompt the user to download, decrypt and collect device intrusion logs as part of an acquisition. When they are present, `mvt-android check-androidqf` will automatically run the intrusion-log checks alongside the other AndroidQF modules — no extra command is required. This is the recommended workflow for Android forensic analysis with MVT. + +For cases where intrusion logs were collected outside of an AndroidQF acquisition, the standalone `mvt-android check-intrusion-logs` command can analyse them directly. See [Check Android Intrusion Logs](intrusion_logs.md) for details, and the [feature announcment from Amnesty International's Security Lab](https://securitylab.amnesty.org/latest/2026/05/android-intrusion-logging-as-a-new-source-of-data-for-consensual-forensic-analysis/) for background on the data source. + +## Android Debug Bridge analysis removed + +The ability to analyze Android devices directly over ADB has been removed from MVT. Direct extraction of data from ADB was error-prone and frequently resulted in inconsistent data collection between ADB and AndroidQF acquisitions. Use AndroidQF for device acquisition and `mvt-android check-androidqf` for analysis. ## Check an Android Backup (SMS messages) -Although Android backups are becoming deprecated, it is still possible to generate one. Unfortunately, because apps these days typically favor backup over the cloud, the amount of data available is limited. Currently, `mvt-android check-backup` only supports checking SMS messages containing links. +Although Android backups are becoming deprecated, it is still possible to generate one. Unfortunately, because apps these days typically favor backup over the cloud, the amount of data available is limited. + +The `mvt-android check-androidqf` command will automatically check an Android backup and SMS messages if an SMS backup is included in the AndroidQF extraction. + +The `mvt-android check-backup` command can also be used directly with an Android backup file. diff --git a/docs/command_completion.md b/docs/command_completion.md index 1cd4eb7..7204df5 100644 --- a/docs/command_completion.md +++ b/docs/command_completion.md @@ -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). - diff --git a/docs/custom_commands.md b/docs/custom_commands.md new file mode 100644 index 0000000..ab89fd9 --- /dev/null +++ b/docs/custom_commands.md @@ -0,0 +1,107 @@ +# Custom CLI Commands + +MVT can load additional top-level commands into `mvt-ios` and `mvt-android`. +Custom commands are different from [custom forensic modules](development.md#custom-modules): +commands add new CLI operations, while modules add analysis steps to existing +`check-*` commands. + +!!! warning + + Custom commands run as trusted Python code inside the MVT process. Install + or load commands only from sources you trust. MVT does not sandbox + third-party commands, and the MVT maintainers do not maintain them. + +## Install a Command Package + +Python packages can register a Click command or group for either MVT CLI. A +minimal package can expose this command from `my_mvt_plugin.py`: + +```python +import click + + +@click.command() +@click.argument("path", type=click.Path(exists=True)) +def summarize(path): + """Summarize an acquisition.""" + click.echo(f"Summarizing {path}") +``` + +Register the object in the package's `pyproject.toml`. The entry-point name is +the command users invoke: + +```toml +[project.entry-points."mvt.ios.cli_plugins"] +summarize = "my_mvt_plugin:summarize" + +[project.entry-points."mvt.android.cli_plugins"] +summarize = "my_mvt_plugin:summarize" +``` + +Use only the iOS or Android group if the command is platform-specific. After +installing the package in the same environment as MVT, it appears directly in +the appropriate CLI: + +```bash +mvt-ios summarize ./ios-backup +mvt-android summarize ./androidqf-output +``` + +For a `pipx` installation of MVT, inject the plugin into MVT's environment: + +```bash +pipx inject mvt my-mvt-plugin +``` + +When MVT is installed in an active virtual environment, install the plugin with +`pip` in that environment. + +## Load a Command File + +For local commands that are not packaged, create a Python file that exports one +Click command or group named `cli`: + +```python +import click + + +@click.command("case-summary") +@click.argument("path", type=click.Path(exists=True)) +def cli(path): + """Summarize a case directory.""" + click.echo(f"Summarizing {path}") +``` + +Pass the file before the custom command name: + +```bash +mvt-ios --load-command ./case_summary.py case-summary ./ios-backup +``` + +`--load-command` can be repeated and also accepts a folder. MVT loads +non-hidden top-level `*.py` files in sorted order and skips `__init__.py`. +Every loaded file must export one `cli` object. + +To load a file or folder on every invocation, set the platform-specific +environment variable: + +```bash +export MVT_IOS_CUSTOM_COMMANDS=./ios_commands +export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands +``` + +## Naming and Errors + +Built-in MVT commands cannot be replaced. External command names must also be +unique; when installed packages or environment paths collide, MVT keeps the +first command and logs a warning. A collision from an explicit +`--load-command` is a usage error. + +A package entry point or environment command that cannot be imported appears +as a marked broken command without preventing other MVT commands from working. +Invoke that command to see its package or file source and the underlying error. +An invalid command supplied explicitly with `--load-command` fails immediately +with a usage error. + +Installed command packages use the entry-point name as the CLI command name. +The entry point must resolve to a `click.Command` or `click.Group`. diff --git a/docs/development.md b/docs/development.md index fa99484..ffe1265 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,9 +2,17 @@ The Mobile Verification Toolkit team welcomes contributions of new forensic modules or other contributions which help improve the software. +## Local environment + +MVT uses `uv` for dependency management. To install the project and development dependencies from the locked environment, run: + +```bash +make install +``` + ## Testing -MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `flake8`, `ruff` and `black`. All can +MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `ruff` and `mypy`. All can be run automatically with: ```bash @@ -13,6 +21,102 @@ make check Run these tests before making new commits or opening pull requests. +## Module dependencies + +Modules can require other modules to run first by declaring their classes in +`dependencies`. The command runner uses a stable topological ordering, so the +existing module list order is preserved wherever dependency constraints allow. + +```python +class DependentModule(MVTModule): + dependencies = (PrerequisiteModule,) + + def run(self): + prerequisite_results = self.get_dependency_results(PrerequisiteModule) +``` + +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 declare the platform/command pairs a module +supports. Empty `supported_commands` means the module will not run and MVT logs +a warning. This explicit declaration is required for every command. Supported +pairs are: + +```python +("ios", "check-backup") +("ios", "check-fs") +("ios", "check-iocs") +("ios", "check-sysdiagnose") +("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 @@ -24,4 +128,4 @@ MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_ MVT_PROFILE=1 dev/mvt-ios check-backup test_backup ``` -Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT. \ No newline at end of file +Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT. diff --git a/docs/docker.md b/docs/docker.md index be8631c..d8f4ff8 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -31,21 +31,4 @@ Test if the image was created successfully: docker run -it mvt ``` -If a prompt is spawned successfully, you can close it with `exit`. - - -## Docker usage with Android devices - -If you wish to use MVT to test an Android device you will need to enable the container's access to the host's USB devices. You can do so by enabling the `--privileged` flag and mounting the USB bus device as a volume: - -```bash -docker run -it --privileged -v /dev/bus/usb:/dev/bus/usb mvt -``` - -**Please note:** the `--privileged` parameter is generally regarded as a security risk. If you want to learn more about this check out [this explainer on container escapes](https://blog.trailofbits.com/2019/07/19/understanding-docker-container-escapes/) as it gives access to the whole system. - -Recent versions of Docker provide a `--device` parameter allowing to specify a precise USB device without enabling `--privileged`: - -```bash -docker run -it --device=/dev/ mvt -``` +If a prompt is spawned successfully, you can close it with `exit`. \ No newline at end of file diff --git a/docs/iocs.md b/docs/iocs.md index 5bdb8a6..c7586de 100644 --- a/docs/iocs.md +++ b/docs/iocs.md @@ -34,6 +34,27 @@ It is also possible to load STIX2 files automatically from the environment varia export MVT_STIX2="/home/user/IOC1.stix2:/home/user/IOC2.stix2" ``` +## Network Access + +When checking URL indicators, MVT follows recognized shortened URLs with an +HTTP `HEAD` request. URL checks are deduplicated and run concurrently, with at +most 20 requests in progress at a time. Redirects within an individual URL +chain are still followed sequentially. The following environment variables +control these requests: + +- `MVT_NETWORK_ACCESS_ALLOWED` enables or disables network requests. It defaults + to `true`. Set it to `false` to prevent MVT from attempting to resolve + shortened URLs. +- `MVT_NETWORK_TIMEOUT` sets the request timeout in seconds. It defaults to + `15`. + +For example, to run IOC checks without resolving shortened URLs: + +```bash +MVT_NETWORK_ACCESS_ALLOWED=false mvt-ios check-iocs \ + --iocs ~/iocs/malware.stix2 /path/to/iphone/output/ +``` + ## STIX2 Support So far MVT implements only a subset of [STIX2 specifications](https://docs.oasis-open.org/cti/stix/v2.1/csprd01/stix-v2.1-csprd01.html): @@ -54,5 +75,3 @@ You can automaticallly download the latest public indicator files with the comma Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs. - - diff --git a/docs/ios/records.md b/docs/ios/records.md index 17be192..016c861 100644 --- a/docs/ios/records.md +++ b/docs/ios/records.md @@ -312,6 +312,15 @@ If indicators are provided through the command-line, they are checked against th --- +### `urls.json` + +This JSON file collects URLs extracted from SMS, iMessage, and WhatsApp +messages. Each entry contains the original URL, its expanded destination when +MVT resolved a shortened URL during indicator checking, the message timestamp, +and its `sms` or `whatsapp` source. + +--- + ### `sms_attachments.json` !!! info "Availability" diff --git a/docs/ios/sysdiagnose.md b/docs/ios/sysdiagnose.md new file mode 100644 index 0000000..6488bc2 --- /dev/null +++ b/docs/ios/sysdiagnose.md @@ -0,0 +1,50 @@ +# Check an iOS Sysdiagnose + +`mvt-ios check-sysdiagnose` prepares an iOS sysdiagnose archive for analysis by +custom MVT modules. MVT does not include built-in sysdiagnose modules. You must +load at least one custom module that explicitly supports this command. + +The command accepts either an extracted sysdiagnose directory or the original +gzip-compressed tar archive. + +```bash +mvt-ios check-sysdiagnose \ + --load-module ./sysdiagnose_modules.py \ + --output ./results \ + ./sysdiagnose_2024.01.02_03-04-05+0200.tar.gz +``` + +Use `--hashes` to include hashes for analyzed files in `info.json`, and +`--list-modules` to display the eligible custom modules without running them. + +## Writing a custom module + +Extend `SysdiagnoseExtraction` to access the archive contents consistently for +both directory and tar inputs. Each module must declare the command explicitly +in `supported_commands`. + +```python +from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction + + +class ExampleSysdiagnoseModule(SysdiagnoseExtraction): + supported_commands = (("ios", "check-sysdiagnose"),) + slug = "example_sysdiagnose" + + def run(self): + paths = self._get_files_by_pattern("*/example.log") + if paths: + content = self._get_file_content(paths[0]).decode("utf-8", "replace") + self.results = [{"content": content}] + + def check_indicators(self): + pass + + def serialize(self, result): + return None +``` + +The base class provides `from_sysdiagnose_folder()` and +`from_sysdiagnose_tar()` setup hooks, as well as protected file lookup, file +reading, and timezone extraction helpers. IPS crash-report metadata is exposed +on `ips_files`. diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 843517a..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -mkdocs==1.6.1 -mkdocs-autorefs==1.4.3 -mkdocs-material==9.6.20 -mkdocs-material-extensions==1.3.1 -mkdocstrings==1.0.0 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 7918ec6..66f3471 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,6 +3,8 @@ repo_url: https://github.com/mvt-project/mvt edit_uri: edit/main/docs/ copyright: Copyright © 2021-2023 MVT Project Developers site_description: Mobile Verification Toolkit Documentation +not_in_nav: | + android/adb.md markdown_extensions: - attr_list - admonition @@ -28,6 +30,8 @@ nav: - Welcome: "index.md" - Introduction: "introduction.md" - Installation: "install.md" + - Command Completion: "command_completion.md" + - Custom CLI Commands: "custom_commands.md" - Using Docker: "docker.md" - MVT for iOS: - iOS Forensic Methodology: "ios/methodology.md" @@ -39,12 +43,12 @@ nav: - Check a Filesystem Dump: - Dumping the filesystem: "ios/filesystem/dump.md" - Check a Filesystem Dump with mvt-ios: "ios/filesystem/check.md" + - Check a Sysdiagnose: "ios/sysdiagnose.md" - Records extracted by mvt-ios: "ios/records.md" - MVT for Android: - Android Forensic Methodology: "android/methodology.md" - - Check over ADB: "android/adb.md" - Check an Android Backup (SMS messages): "android/backup.md" - - Download APKs: "android/download_apks.md" + - Check Android Intrusion Logs: "android/intrusion_logs.md" - Indicators of Compromise: "iocs.md" - Development: "development.md" - License: "license.md" diff --git a/pyproject.toml b/pyproject.toml index eddfca1..5548e40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,26 +17,26 @@ classifiers = [ "Programming Language :: Python", ] dependencies = [ - "click==8.3.2", - "rich==14.3.3", - "tld==0.13.1", - "requests==2.33.1", - "simplejson==3.20.2", - "packaging==26.0", + "click==8.4.2", + "rich==15.0.0", + "tld==0.13.2", + "requests==2.34.2", + "simplejson==4.1.1", + "packaging==26.3", "appdirs==1.4.4", "iphone_backup_decrypt==0.9.0", - "pycryptodome>=3.18", + "pycryptodome>=3.20.0", "adb-shell[usb]==0.4.4", - "libusb1==3.3.1", - "cryptography==47.0.0", + "libusb1==3.4.0", + "cryptography==50.0.0", "PyYAML>=6.0.2", - "pyahocorasick==2.2.0", - "betterproto2==0.9.1", - "pydantic==2.12.5", - "pydantic-settings==2.13.1", + "pyahocorasick==2.3.1", + "betterproto2==0.10.0", + "pydantic==2.13.4", + "pydantic-settings==2.15.0", "NSKeyedUnArchiver==1.5.2", "python-dateutil==2.9.0.post0", - "tzdata==2026.1", + "tzdata==2026.3", ] requires-python = ">= 3.10" @@ -59,6 +59,16 @@ dev = [ "ruff>=0.1.6", "mypy>=1.7.1", "betterproto2-compiler", + "types-PyYAML>=6.0.12.20250516", + "types-python-dateutil>=2.9.0.20250822", + "types-requests>=2.32.4.20250913", +] +docs = [ + "mkdocs==1.6.1", + "mkdocs-autorefs==1.4.4", + "mkdocs-material==9.7.7", + "mkdocs-material-extensions==1.3.1", + "mkdocstrings==1.0.6", ] [build-system] @@ -72,8 +82,7 @@ omit = ["tests/*"] directory = "htmlcov" [tool.mypy] -install_types = true -non_interactive = true +install_types = false ignore_missing_imports = true packages = "src" diff --git a/src/mvt/android/artifacts/artifact.py b/src/mvt/android/artifacts/artifact.py index a5df7b7..94dddde 100644 --- a/src/mvt/android/artifacts/artifact.py +++ b/src/mvt/android/artifacts/artifact.py @@ -20,23 +20,39 @@ class AndroidArtifact(Artifact): :param binary: whether the dumpsys should be pared as binary or not (bool) :return: section extracted (string or bytes) """ - lines = [] in_section = False - delimiter = "------------------------------------------------------------------------------" + delimiter_str = "------------------------------------------------------------------------------" + delimiter_bytes = b"------------------------------------------------------------------------------" + if binary: - delimiter = delimiter.encode("utf-8") + lines_bytes = [] + for line in dumpsys.splitlines(): # type: ignore[union-attr] + if line.strip() == separator: # type: ignore[arg-type] + in_section = True + continue - for line in dumpsys.splitlines(): - if line.strip() == separator: - in_section = True - continue + if not in_section: + continue - if not in_section: - continue + if line.strip().startswith(delimiter_bytes): # type: ignore[arg-type] + break - if line.strip().startswith(delimiter): - break + lines_bytes.append(line) # type: ignore[arg-type] - lines.append(line) + return b"\n".join(lines_bytes) # type: ignore[return-value,arg-type] + else: + lines_str = [] + for line in dumpsys.splitlines(): # type: ignore[union-attr] + if line.strip() == separator: # type: ignore[arg-type] + in_section = True + continue - return b"\n".join(lines) if binary else "\n".join(lines) + if not in_section: + continue + + if line.strip().startswith(delimiter_str): # type: ignore[arg-type] + break + + lines_str.append(line) # type: ignore[arg-type] + + return "\n".join(lines_str) # type: ignore[return-value,arg-type] diff --git a/src/mvt/android/artifacts/dumpsys_accessibility.py b/src/mvt/android/artifacts/dumpsys_accessibility.py index fca84df..3d8fa6c 100644 --- a/src/mvt/android/artifacts/dumpsys_accessibility.py +++ b/src/mvt/android/artifacts/dumpsys_accessibility.py @@ -10,15 +10,20 @@ from .artifact import AndroidArtifact class DumpsysAccessibilityArtifact(AndroidArtifact): def check_indicators(self) -> None: - if not self.indicators: - return - for result in self.results: - ioc = self.indicators.check_app_id(result["package_name"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) - continue + if self.indicators: + ioc_match = self.indicators.check_app_id(result["package_name"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) + continue + + self.alertstore.medium( + f'Found accessibility service: "{result["service"]}"', + "", + result, + ) def parse(self, content: str) -> None: """ diff --git a/src/mvt/android/artifacts/dumpsys_adb.py b/src/mvt/android/artifacts/dumpsys_adb.py index 2bc9abe..e5438c9 100644 --- a/src/mvt/android/artifacts/dumpsys_adb.py +++ b/src/mvt/android/artifacts/dumpsys_adb.py @@ -13,6 +13,13 @@ from .artifact import AndroidArtifact class DumpsysADBArtifact(AndroidArtifact): multiline_fields = ["user_keys", "keystore"] + @staticmethod + def is_structural_line(key: str, vals: list) -> bool: + if key == "}": + return True + # XML keystore continuations also split on "=", but never into an identifier. + return len(vals) == 2 and key.isidentifier() + def indented_dump_parser(self, dump_data): """ Parse the indented dumpsys output, generated by DualDumpOutputStream in Android. @@ -41,10 +48,18 @@ class DumpsysADBArtifact(AndroidArtifact): if key == "": # If the line is empty, it's the terminator for the multiline value in_multiline = False - stack.pop() - else: + if isinstance(stack[-1], list): + stack.pop() + continue + + if not self.is_structural_line(key, vals): current_dict.append(line.lstrip()) - continue + continue + + in_multiline = False + if isinstance(stack[-1], list): + stack.pop() + current_dict = stack[-1] if key == "}": stack.pop() @@ -84,7 +99,7 @@ class DumpsysADBArtifact(AndroidArtifact): return keystore @staticmethod - def calculate_key_info(user_key: bytes) -> str: + def calculate_key_info(user_key: bytes) -> dict: if b" " in user_key: key_base64, user = user_key.split(b" ", 1) else: @@ -131,10 +146,17 @@ class DumpsysADBArtifact(AndroidArtifact): ) return - # TODO: Parse AdbDebuggingManager line in output. - start_of_json = content.find(b"\n{") + 2 - end_of_json = content.rfind(b"}\n") - 2 - json_content = content[start_of_json:end_of_json].rstrip() + start_of_json = content.find(b"\n{") + if start_of_json == -1: + self.log.error("Unable to find ADB manager state in dumpsys output") + return + + end_of_json = content.rfind(b"}\n") + if end_of_json == -1 or end_of_json <= start_of_json: + self.log.error("Unable to find complete ADB manager state in dumpsys output") + return + + json_content = content[start_of_json + 2 : end_of_json - 2].rstrip() parsed = self.indented_dump_parser(json_content) if parsed.get("debugging_manager") is None: diff --git a/src/mvt/android/artifacts/dumpsys_appops.py b/src/mvt/android/artifacts/dumpsys_appops.py index 8323c8a..492eac7 100644 --- a/src/mvt/android/artifacts/dumpsys_appops.py +++ b/src/mvt/android/artifacts/dumpsys_appops.py @@ -4,13 +4,13 @@ # https://license.mvt.re/1.1/ from datetime import datetime -from typing import Any, Dict, List, Union +from typing import Any +from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult from mvt.common.utils import convert_datetime_to_iso from .artifact import AndroidArtifact - RISKY_PERMISSIONS = ["REQUEST_INSTALL_PACKAGES"] RISKY_PACKAGES = ["com.android.shell"] @@ -20,9 +20,9 @@ class DumpsysAppopsArtifact(AndroidArtifact): Parser for dumpsys app ops info """ - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult: records = [] - for perm in record["permissions"]: + for perm in result["permissions"]: if "entries" not in perm: continue @@ -33,7 +33,7 @@ class DumpsysAppopsArtifact(AndroidArtifact): "timestamp": entry["timestamp"], "module": self.__class__.__name__, "event": entry["access"], - "data": f"{record['package_name']} access to " + "data": f"{result['package_name']} access to " f"{perm['name']}: {entry['access']}", } ) @@ -43,51 +43,51 @@ class DumpsysAppopsArtifact(AndroidArtifact): def check_indicators(self) -> None: for result in self.results: if self.indicators: - ioc = self.indicators.check_app_id(result.get("package_name")) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_app_id(result.get("package_name")) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue - detected_permissions = [] + # We use a placeholder entry to create a basic alert even without permission entries. + placeholder_entry = {"access": "Unknown", "timestamp": ""} + for perm in result["permissions"]: if ( perm["name"] in RISKY_PERMISSIONS # and perm["access"] == "allow" ): - detected_permissions.append(perm) - for entry in sorted(perm["entries"], key=lambda x: x["timestamp"]): - self.log.warning( - "Package '%s' had risky permission '%s' set to '%s' at %s", - result["package_name"], - perm["name"], - entry["access"], + for entry in sorted( + perm["entries"] or [placeholder_entry], + key=lambda x: x["timestamp"], + ): + cleaned_result = result.copy() + cleaned_result["permissions"] = [perm] + self.alertstore.medium( + f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}", entry["timestamp"], + cleaned_result, ) elif result["package_name"] in RISKY_PACKAGES: - detected_permissions.append(perm) - for entry in sorted(perm["entries"], key=lambda x: x["timestamp"]): - self.log.warning( - "Risky package '%s' had '%s' permission set to '%s' at %s", - result["package_name"], - perm["name"], - entry["access"], + for entry in sorted( + perm["entries"] or [placeholder_entry], + key=lambda x: x["timestamp"], + ): + cleaned_result = result.copy() + cleaned_result["permissions"] = [perm] + self.alertstore.medium( + f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}", entry["timestamp"], + cleaned_result, ) - if detected_permissions: - # We clean the result to only include the risky permission, otherwise the timeline - # will be polluted with all the other irrelevant permissions - cleaned_result = result.copy() - cleaned_result["permissions"] = detected_permissions - self.detected.append(cleaned_result) - def parse(self, output: str) -> None: - self.results: List[Dict[str, Any]] = [] - perm = {} - package = {} - entry = {} + # self.results: List[Dict[str, Any]] = [] + perm: dict[str, Any] = {} + package: dict[str, Any] = {} + entry: dict[str, Any] = {} uid = None in_packages = False diff --git a/src/mvt/android/artifacts/dumpsys_battery_daily.py b/src/mvt/android/artifacts/dumpsys_battery_daily.py index c7bcc6a..c0f2c5e 100644 --- a/src/mvt/android/artifacts/dumpsys_battery_daily.py +++ b/src/mvt/android/artifacts/dumpsys_battery_daily.py @@ -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/ -from typing import Union +from typing import Any + +from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult from .artifact import AndroidArtifact @@ -13,7 +15,7 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): Parser for dumpsys dattery daily updates. """ - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: action = record.get("action", "update") package_name = record["package_name"] vers = record["vers"] @@ -38,20 +40,21 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): return for result in self.results: - ioc = self.indicators.check_app_id(result["package_name"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_app_id(result["package_name"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue def parse(self, output: str) -> None: daily = None - daily_updates = [] - package_versions = {} # Track package versions to detect downgrades + daily_updates: list[dict[str, Any]] = [] + 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() @@ -76,7 +79,7 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): break if not already_seen: - update_record = { + update_record: dict[str, Any] = { "action": "update", "from": daily["from"], "to": daily["to"], @@ -84,36 +87,53 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): "vers": vers_nr, } - # Check for uninstall (version 0) - if vers_nr == "0": - self.log.warning( - "Detected uninstall of package %s (vers 0) on %s", - package_name, - daily["from"], - ) - # 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.log.warning( - "Detected downgrade of package %s from vers %d to vers %d on %s", - package_name, - previous_vers, - current_vers, - daily["from"], - ) - 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 diff --git a/src/mvt/android/artifacts/dumpsys_battery_history.py b/src/mvt/android/artifacts/dumpsys_battery_history.py index 35e41ec..77d9d3c 100644 --- a/src/mvt/android/artifacts/dumpsys_battery_history.py +++ b/src/mvt/android/artifacts/dumpsys_battery_history.py @@ -16,10 +16,11 @@ class DumpsysBatteryHistoryArtifact(AndroidArtifact): return for result in self.results: - ioc = self.indicators.check_app_id(result["package_name"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_app_id(result["package_name"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue def parse(self, data: str) -> None: @@ -30,21 +31,38 @@ class DumpsysBatteryHistoryArtifact(AndroidArtifact): if line.strip() == "": break - time_elapsed = line.strip().split(" ", 1)[0] + time_parts = line.strip().split() + time_elapsed = time_parts[0] + if ( + len(time_parts) > 1 + and len(time_parts[0]) == 5 + and time_parts[0][2] == "-" + and ":" in time_parts[1] + ): + time_elapsed = " ".join(time_parts[:2]) event = "" if line.find("+job") > 0: event = "start_job" - uid = line[line.find("+job") + 5 : line.find(":")] - service = line[line.find(":") + 1 :].strip('"') + payload = line.split("+job=", 1)[1] + uid, separator, service = payload.partition(":") + if not separator: + continue + service = service.strip().strip('"') package_name = service.split("/")[0] elif line.find("-job") > 0: event = "end_job" - uid = line[line.find("-job") + 5 : line.find(":")] - service = line[line.find(":") + 1 :].strip('"') + payload = line.split("-job=", 1)[1] + uid, separator, service = payload.partition(":") + if not separator: + continue + service = service.strip().strip('"') package_name = service.split("/")[0] elif line.find("+running +wake_lock=") > 0: - uid = line[line.find("+running +wake_lock=") + 21 : line.find(":")] + payload = line.split("+running +wake_lock=", 1)[1] + uid, separator, _ = payload.partition(":") + if not separator: + continue event = "wake" service = ( line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip() diff --git a/src/mvt/android/artifacts/dumpsys_dbinfo.py b/src/mvt/android/artifacts/dumpsys_dbinfo.py index 1064e49..050582d 100644 --- a/src/mvt/android/artifacts/dumpsys_dbinfo.py +++ b/src/mvt/android/artifacts/dumpsys_dbinfo.py @@ -20,18 +20,18 @@ class DumpsysDBInfoArtifact(AndroidArtifact): for result in self.results: path = result.get("path", "") for part in path.split("/"): - ioc = self.indicators.check_app_id(part) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_app_id(part) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue def parse(self, output: str) -> None: rxp = re.compile( - r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\].*\[Pid:\((\d+)\)\](\w+).*sql\=\"(.+?)\"" - ) # pylint: disable=line-too-long - rxp_no_pid = re.compile( - r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\][ ]{1}(\w+).*sql\=\"(.+?)\"" + r".*\[((?:[0-9]{4}-)?[0-9]{2}-[0-9]{2} " + r"[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\]\s*" + r"(?:\[Pid:\((\d+)\)\])?([\w-]+).*?sql=\"(.+?)\"" ) # pylint: disable=line-too-long pool = None @@ -55,29 +55,16 @@ class DumpsysDBInfoArtifact(AndroidArtifact): pool = None continue - matches = rxp.findall(line) - if not matches: - matches = rxp_no_pid.findall(line) - if not matches: - continue + match = rxp.match(line) + if not match: + continue - match = matches[0] - self.results.append( - { - "isodate": match[0], - "action": match[1], - "sql": match[2], - "path": pool, - } - ) - else: - match = matches[0] - self.results.append( - { - "isodate": match[0], - "pid": match[1], - "action": match[2], - "sql": match[3], - "path": pool, - } - ) + result = { + "isodate": match.group(1), + "action": match.group(3), + "sql": match.group(4), + "path": pool, + } + if match.group(2): + result["pid"] = match.group(2) + self.results.append(result) diff --git a/src/mvt/android/artifacts/dumpsys_package_activities.py b/src/mvt/android/artifacts/dumpsys_package_activities.py index d8d284f..31c6bbd 100644 --- a/src/mvt/android/artifacts/dumpsys_package_activities.py +++ b/src/mvt/android/artifacts/dumpsys_package_activities.py @@ -12,10 +12,11 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact): return for activity in self.results: - ioc = self.indicators.check_app_id(activity["package_name"]) - if ioc: - activity["matched_indicator"] = ioc - self.detected.append(activity) + ioc_match = self.indicators.check_app_id(activity["package_name"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", activity, matched_indicator=ioc_match.ioc + ) continue def parse(self, content: str): diff --git a/src/mvt/android/artifacts/dumpsys_packages.py b/src/mvt/android/artifacts/dumpsys_packages.py index 2204180..bef7744 100644 --- a/src/mvt/android/artifacts/dumpsys_packages.py +++ b/src/mvt/android/artifacts/dumpsys_packages.py @@ -4,35 +4,40 @@ # https://license.mvt.re/1.1/ import re -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Optional from mvt.android.utils import ROOT_PACKAGES +from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult from .artifact import AndroidArtifact class DumpsysPackagesArtifact(AndroidArtifact): def check_indicators(self) -> None: + alerted_root_packages = set() for result in self.results: if result["package_name"] in ROOT_PACKAGES: - self.log.warning( - 'Found an installed package related to rooting/jailbreaking: "%s"', - result["package_name"], + if result["package_name"] in alerted_root_packages: + continue + alerted_root_packages.add(result["package_name"]) + self.alertstore.medium( + f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"', + "", + result, ) - self.detected.append(result) continue if not self.indicators: continue - ioc = self.indicators.check_app_id(result.get("package_name", "")) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_app_id(result.get("package_name", "")) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: records = [] - timestamps = [ {"event": "package_install", "timestamp": record["timestamp"]}, { @@ -59,21 +64,30 @@ class DumpsysPackagesArtifact(AndroidArtifact): """ Parse one entry of a dumpsys package information """ - details = { + details: Dict[str, Any] = { "uid": "", "version_name": "", "version_code": "", "timestamp": "", "first_install_time": "", "last_update_time": "", - "permissions": [], - "requested_permissions": [], + "installer": "", + "system": False, + "permissions": list(), + "requested_permissions": list(), } in_install_permissions = False in_runtime_permissions = False in_declared_permissions = False in_requested_permissions = True + current_user: Optional[int] = None + first_install_times: Dict[Optional[int], str] = {} + runtime_permissions: Dict[Optional[int], List[Dict[str, Any]]] = {} for line in output.splitlines(): + user_match = re.match(r"User (\d+):", line.strip()) + if user_match: + current_user = int(user_match.group(1)) + if in_install_permissions: if line.startswith(" " * 4) and not line.startswith(" " * 6): in_install_permissions = False @@ -97,7 +111,7 @@ class DumpsysPackagesArtifact(AndroidArtifact): if "granted=" in lineinfo[1]: granted = "granted=true" in lineinfo[1] - details["permissions"].append( + runtime_permissions.setdefault(current_user, []).append( {"name": permission, "granted": granted, "type": "runtime"} ) if in_declared_permissions: @@ -121,8 +135,12 @@ class DumpsysPackagesArtifact(AndroidArtifact): details["version_code"] = line.split("=", 1)[1].strip() elif line.strip().startswith("timeStamp="): details["timestamp"] = line.split("=")[1].strip() + elif line.strip().startswith("installerPackageName="): + details["installer"] = line.split("=", 1)[1].strip() + elif line.strip().startswith("pkgFlags="): + details["system"] = "SYSTEM" in line.split("=", 1)[1].split() elif line.strip().startswith("firstInstallTime="): - details["first_install_time"] = line.split("=")[1].strip() + first_install_times[current_user] = line.split("=", 1)[1].strip() elif line.strip().startswith("lastUpdateTime="): details["last_update_time"] = line.split("=")[1].strip() elif line.strip() == "install permissions:": @@ -134,6 +152,19 @@ class DumpsysPackagesArtifact(AndroidArtifact): elif line.strip() == "requested permissions:": in_requested_permissions = True + if 0 in first_install_times: + details["first_install_time"] = first_install_times[0] + elif None in first_install_times: + details["first_install_time"] = first_install_times[None] + elif first_install_times: + details["first_install_time"] = next(iter(first_install_times.values())) + + if 0 in runtime_permissions: + details["permissions"].extend(runtime_permissions[0]) + elif None in runtime_permissions: + details["permissions"].extend(runtime_permissions[None]) + elif runtime_permissions: + details["permissions"].extend(next(iter(runtime_permissions.values()))) return details def parse_dumpsys_packages(self, output: str) -> List[Dict[str, Any]]: @@ -145,7 +176,7 @@ class DumpsysPackagesArtifact(AndroidArtifact): results = [] package_name = None package = {} - lines = [] + lines: list[str] = [] for line in output.splitlines(): if line.startswith(" Package ["): if len(lines) > 0: @@ -186,7 +217,7 @@ class DumpsysPackagesArtifact(AndroidArtifact): package = [] in_package_list = False - for line in content.split("\n"): + for line in content.splitlines(): if line.startswith("Packages:"): in_package_list = True continue diff --git a/src/mvt/android/artifacts/dumpsys_platform_compat.py b/src/mvt/android/artifacts/dumpsys_platform_compat.py index e1037f0..c01fbfd 100644 --- a/src/mvt/android/artifacts/dumpsys_platform_compat.py +++ b/src/mvt/android/artifacts/dumpsys_platform_compat.py @@ -16,10 +16,11 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact): return for result in self.results: - ioc = self.indicators.check_app_id(result["package_name"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_app_id(result["package_name"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue def parse(self, data: str) -> None: diff --git a/src/mvt/android/artifacts/dumpsys_receivers.py b/src/mvt/android/artifacts/dumpsys_receivers.py index 6ef1c08..b437930 100644 --- a/src/mvt/android/artifacts/dumpsys_receivers.py +++ b/src/mvt/android/artifacts/dumpsys_receivers.py @@ -50,14 +50,18 @@ class DumpsysReceiversArtifact(AndroidArtifact): if not self.indicators: continue - ioc = self.indicators.check_app_id(receiver["package_name"]) - if ioc: - receiver["matched_indicator"] = ioc - self.detected.append({intent: receiver}) + ioc_match = self.indicators.check_app_id(receiver["package_name"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, + "", + {intent: receiver}, + matched_indicator=ioc_match.ioc, + ) continue def parse(self, output: str) -> None: - self.results = {} + self.results: dict[str, list[dict[str, str]]] = {} in_receiver_resolver_table = False in_non_data_actions = False @@ -92,6 +96,18 @@ class DumpsysReceiversArtifact(AndroidArtifact): self.results[intent] = [] continue + parts = line.strip().split(" ") + if len(parts) < 2: + # A single-token line here is not a receiver. Real dumpstate + # output can print an action header mis-indented (observed with + # 15 leading spaces instead of 6), which used to raise + # IndexError and abort the whole module. Treat a trailing-colon + # token as the next action, skip anything else. + if parts[0].endswith(":"): + intent = parts[0][:-1] + self.results.setdefault(intent, []) + continue + # If we are not in an intent block yet, skip. if not intent: continue @@ -105,7 +121,7 @@ class DumpsysReceiversArtifact(AndroidArtifact): # If we got this far, we are processing receivers for the # activities we are interested in. - receiver = line.strip().split(" ")[1] + receiver = parts[1] package_name = receiver.split("/")[0] self.results[intent].append( diff --git a/src/mvt/android/artifacts/file_timestamps.py b/src/mvt/android/artifacts/file_timestamps.py index aa2dc25..98b8789 100644 --- a/src/mvt/android/artifacts/file_timestamps.py +++ b/src/mvt/android/artifacts/file_timestamps.py @@ -2,13 +2,13 @@ # 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 typing import Union from .artifact import AndroidArtifact +from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult class FileTimestampsArtifact(AndroidArtifact): - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: records = [] for ts in set( diff --git a/src/mvt/android/artifacts/getprop.py b/src/mvt/android/artifacts/getprop.py index 6c7030f..debcfc4 100644 --- a/src/mvt/android/artifacts/getprop.py +++ b/src/mvt/android/artifacts/getprop.py @@ -39,10 +39,10 @@ class GetProp(AndroidArtifact): if not matches or len(matches[0]) != 2: continue - entry = {"name": matches[0][0], "value": matches[0][1]} - self.results.append(entry) + prop_entry = {"name": matches[0][0], "value": matches[0][1]} + self.results.append(prop_entry) - def get_device_timezone(self) -> str: + def get_device_timezone(self) -> str | None: """ Get the device timezone from the getprop results @@ -59,13 +59,18 @@ class GetProp(AndroidArtifact): self.log.info("%s: %s", entry["name"], entry["value"]) if entry["name"] == "ro.build.version.security_patch": - warn_android_patch_level(entry["value"], self.log) + warning_message = warn_android_patch_level(entry["value"], self.log) + if isinstance(warning_message, str): + self.alertstore.medium(warning_message, "", entry) if not self.indicators: return for result in self.results: - ioc = self.indicators.check_android_property_name(result.get("name", "")) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_android_property_name( + result.get("name", "") + ) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) diff --git a/src/mvt/android/artifacts/mounts.py b/src/mvt/android/artifacts/mounts.py index 6e7b0b6..a35d889 100644 --- a/src/mvt/android/artifacts/mounts.py +++ b/src/mvt/android/artifacts/mounts.py @@ -133,13 +133,16 @@ class Mounts(AndroidArtifact): if mount["is_system_partition"] and mount["is_read_write"]: system_rw_mounts.append(mount) if mount_point == "/system": - self.log.warning( - "Root detected /system partition is mounted as read-write (rw). " + self.alertstore.high( + "Root detected /system partition is mounted as read-write (rw)", + "", + mount, ) else: - self.log.warning( - "System partition %s is mounted as read-write (rw). This may indicate system modifications.", - mount_point, + self.alertstore.high( + f"System partition {mount_point} is mounted as read-write (rw). This may indicate system modifications.", + "", + mount, ) # Check for other suspicious mount options @@ -151,10 +154,10 @@ class Mounts(AndroidArtifact): ): continue suspicious_mounts.append(mount) - self.log.warning( - "Suspicious mount options found for %s: %s", - mount_point, - ", ".join(suspicious_opts), + self.alertstore.medium( + f"Suspicious mount options found for {mount_point}: {', '.join(suspicious_opts)}", + "", + mount, ) # Log interesting mount information @@ -176,11 +179,19 @@ class Mounts(AndroidArtifact): # Check if any mount points match indicators ioc = self.indicators.check_file_path(mount.get("mount_point", "")) if ioc: - mount["matched_indicator"] = ioc - self.detected.append(mount) + self.alertstore.critical( + ioc.message, + "", + mount, + matched_indicator=ioc.ioc, + ) # Check device paths for indicators ioc = self.indicators.check_file_path(mount.get("device", "")) if ioc: - mount["matched_indicator"] = ioc - self.detected.append(mount) + self.alertstore.critical( + ioc.message, + "", + mount, + matched_indicator=ioc.ioc, + ) diff --git a/src/mvt/android/artifacts/processes.py b/src/mvt/android/artifacts/processes.py index 273ac10..5471eb2 100644 --- a/src/mvt/android/artifacts/processes.py +++ b/src/mvt/android/artifacts/processes.py @@ -8,7 +8,7 @@ from .artifact import AndroidArtifact class Processes(AndroidArtifact): def parse(self, entry: str) -> None: - for line in entry.split("\n")[1:]: + for line in entry.splitlines()[1:]: proc = line.split() # Skip empty lines @@ -58,13 +58,15 @@ class Processes(AndroidArtifact): if result["proc_name"] == "gatekeeperd": continue - ioc = self.indicators.check_app_id(proc_name) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_app_id(proc_name) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue - ioc = self.indicators.check_process(proc_name) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_process(proc_name) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) diff --git a/src/mvt/android/artifacts/settings.py b/src/mvt/android/artifacts/settings.py index 4649666..17c3bf2 100644 --- a/src/mvt/android/artifacts/settings.py +++ b/src/mvt/android/artifacts/settings.py @@ -67,11 +67,14 @@ class Settings(AndroidArtifact): # Check if one of the dangerous settings is using an unsafe # value (different than the one specified). if danger["key"] == key and danger["safe_value"] != value: - self.log.warning( - 'Found suspicious "%s" setting "%s = %s" (%s)', - namespace, - key, - value, - danger["description"], + self.alertstore.medium( + f'Found suspicious "{namespace}" setting "{key} = {value}" ({danger["description"]})', + "", + { + "namespace": namespace, + "key": key, + "value": value, + "description": danger["description"], + }, ) break diff --git a/src/mvt/android/artifacts/tombstone_crashes.py b/src/mvt/android/artifacts/tombstone_crashes.py index 4c8d14b..bcfb389 100644 --- a/src/mvt/android/artifacts/tombstone_crashes.py +++ b/src/mvt/android/artifacts/tombstone_crashes.py @@ -4,16 +4,17 @@ # https://license.mvt.re/1.1/ import datetime -from typing import List, Optional, Union +from typing import List, Optional import pydantic import betterproto2 from dateutil import parser -from mvt.common.utils import convert_datetime_to_iso from mvt.android.parsers.proto.tombstone import Tombstone -from .artifact import AndroidArtifact +from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult +from mvt.common.utils import convert_datetime_to_iso +from .artifact import AndroidArtifact TOMBSTONE_DELIMITER = "*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***" @@ -33,6 +34,7 @@ TOMBSTONE_TEXT_KEY_MAPPINGS = { "signal": "signal_info", "code": "code", "Cause": "cause", + "Abort message": "abort_message", } @@ -66,6 +68,8 @@ class TombstoneCrashResult(pydantic.BaseModel): uid: int signal_info: SignalInfo cause: Optional[str] = None + causes: Optional[List[dict]] = None + abort_message: Optional[str] = None extra: Optional[str] = None @@ -76,7 +80,7 @@ class TombstoneCrashArtifact(AndroidArtifact): This parser can parse both text and protobuf tombstone crash files. """ - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["timestamp"], "module": self.__class__.__name__, @@ -92,18 +96,20 @@ class TombstoneCrashArtifact(AndroidArtifact): return for result in self.results: - ioc = self.indicators.check_process(result["process_name"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_process(result["process_name"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue if result.get("command_line", []): - command_name = result.get("command_line")[0].split("/")[-1] - ioc = self.indicators.check_process(command_name) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + command_name = result["command_line"][0].split("/")[-1] + ioc_match = self.indicators.check_process(command_name) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue SUSPICIOUS_UIDS = [ @@ -112,11 +118,14 @@ class TombstoneCrashArtifact(AndroidArtifact): 2000, # shell ] if result["uid"] in SUSPICIOUS_UIDS: - self.log.warning( - f"Potentially suspicious crash in process '{result['process_name']}' " - f"running as UID '{result['uid']}' in tombstone '{result['file_name']}' at {result['timestamp']}" + self.alertstore.medium( + ( + f"Potentially suspicious crash in process '{result['process_name']}' " + f"running as UID '{result['uid']}' in tombstone '{result['file_name']}' at {result['timestamp']}" + ), + "", + result, ) - self.detected.append(result) def parse_protobuf( self, file_name: str, file_timestamp: datetime.datetime, data: bytes @@ -193,13 +202,18 @@ class TombstoneCrashArtifact(AndroidArtifact): # eg. "Process uptime: 40s" tombstone[destination_key] = int(value_clean.rstrip("s")) elif destination_key == "command_line": - # XXX: Check if command line should be a single string in a list, or a list of strings. + # Wrap in list for consistency with protobuf format (repeated string). tombstone[destination_key] = [value_clean] else: tombstone[destination_key] = value_clean return True def _load_pid_line(self, line: str, tombstone: dict) -> bool: + # The first pid line identifies the crashing thread. Full text tombstones + # contain additional pid lines for the other threads in the process. + if "pid" in tombstone: + return True + try: parts = line.split(" >>> ") if " >>> " in line else line.split(">>>") process_info = parts[0] @@ -255,7 +269,7 @@ class TombstoneCrashArtifact(AndroidArtifact): @staticmethod def _parse_timestamp_string(timestamp: str) -> str: timestamp_parsed = parser.parse(timestamp) - # HACK: Swap the local timestamp to UTC, so keep the original time and avoid timezone conversion. + # Preserve the source wall-clock time while returning the project-wide ISO format. local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc) return convert_datetime_to_iso(local_timestamp) diff --git a/src/mvt/android/cli.py b/src/mvt/android/cli.py index b30d2e5..ca7e3dc 100644 --- a/src/mvt/android/cli.py +++ b/src/mvt/android/cli.py @@ -4,47 +4,59 @@ # https://license.mvt.re/1.1/ import logging +from zipfile import BadZipFile import click +from mvt.common.cli_plugins import ( + ANDROID_CLI_PLUGIN_GROUP, + MVT_ANDROID_CUSTOM_COMMANDS_ENV, + load_cli_commands_option, + register_cli_plugins, +) 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_APK_OUTPUT, - HELP_MSG_APKS_FROM_FILE, - HELP_MSG_CHECK_ADB, + HELP_MSG_CHECK_ADB_REMOVED, + HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION, HELP_MSG_CHECK_ANDROID_BACKUP, HELP_MSG_CHECK_ANDROIDQF, HELP_MSG_CHECK_BUGREPORT, HELP_MSG_CHECK_IOCS, + HELP_MSG_CHECK_INTRUSION_LOGS, + HELP_MSG_DELAY_CHECKS, + HELP_MSG_COMPLETION, HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, HELP_MSG_DISABLE_UPDATE_CHECK, - HELP_MSG_DOWNLOAD_ALL_APKS, - HELP_MSG_DOWNLOAD_APKS, - HELP_MSG_FAST, HELP_MSG_HASHES, HELP_MSG_IOC, HELP_MSG_LIST_MODULES, + HELP_MSG_LOAD_MODULE, HELP_MSG_MODULE, HELP_MSG_NONINTERACTIVE, HELP_MSG_OUTPUT, - HELP_MSG_SERIAL, HELP_MSG_STIX2, HELP_MSG_VERBOSE, HELP_MSG_VERSION, HELP_MSG_VIRUS_TOTAL, ) from mvt.common.logo import logo +from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules from mvt.common.updates import IndicatorsUpdates from mvt.common.utils import init_logging, set_verbose_logging -from .cmd_check_adb import CmdAndroidCheckADB from .cmd_check_androidqf import CmdAndroidCheckAndroidQF from .cmd_check_backup import CmdAndroidCheckBackup from .cmd_check_bugreport import CmdAndroidCheckBugreport -from .cmd_download_apks import DownloadAPKs -from .modules.adb import ADB_MODULES -from .modules.adb.packages import Packages +from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs +from .modules.intrusion_logs import INTRUSION_LOGS_MODULES +from .modules.androidqf import ANDROIDQF_MODULES from .modules.backup import BACKUP_MODULES from .modules.backup.helpers import cli_load_android_backup_password from .modules.bugreport import BUGREPORT_MODULES @@ -65,10 +77,18 @@ 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 # ============================================================================== @click.group(invoke_without_command=False) +@load_cli_commands_option @click.option( "--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK ) @@ -82,10 +102,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, + ) # ============================================================================== @@ -97,124 +118,49 @@ def version(): # ============================================================================== -# Command: download-apks +# Command: completion # ============================================================================== -@cli.command( - "download-apks", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_DOWNLOAD_APKS -) -@click.option("--serial", "-s", type=str, help=HELP_MSG_SERIAL) -@click.option("--all-apks", "-a", is_flag=True, help=HELP_MSG_DOWNLOAD_ALL_APKS) -@click.option("--virustotal", "-V", is_flag=True, help=HELP_MSG_VIRUS_TOTAL) -@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_APK_OUTPUT) +@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION) +@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS)) @click.option( - "--from-file", "-f", type=click.Path(exists=True), help=HELP_MSG_APKS_FROM_FILE + "--install", + is_flag=True, + help="Write completion files and update shell configuration.", ) -@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.pass_context -def download_apks(ctx, all_apks, virustotal, output, from_file, serial, verbose): - set_verbose_logging(verbose) - try: - if from_file: - download = DownloadAPKs.from_json(from_file) - else: - # TODO: Do we actually want to be able to run without storing any - # file? - if not output: - log.critical("You need to specify an output folder with --output!") - ctx.exit(1) +def completion(ctx, shell, install): + program_name = "mvt-android" - download = DownloadAPKs(results_path=output, all_apks=all_apks) - if serial: - download.serial = serial - download.run() - - packages_to_lookup = [] - if all_apks: - packages_to_lookup = download.packages - else: - for package in download.packages: - if not package.get("system", False): - packages_to_lookup.append(package) - - if len(packages_to_lookup) == 0: - return - - if virustotal: - m = Packages() - m.check_virustotal(packages_to_lookup) - except KeyboardInterrupt: - print("") - ctx.exit(1) - - -# ============================================================================== -# Command: check-adb -# ============================================================================== -@cli.command("check-adb", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_CHECK_ADB) -@click.option("--serial", "-s", type=str, help=HELP_MSG_SERIAL) -@click.option( - "--iocs", - "-i", - type=click.Path(exists=True), - multiple=True, - default=[], - help=HELP_MSG_IOC, -) -@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT) -@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("--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) -@click.pass_context -def check_adb( - ctx, - serial, - iocs, - output, - fast, - list_modules, - module, - non_interactive, - backup_password, - verbose, -): - set_verbose_logging(verbose) - module_options = { - "fast_mode": fast, - "interactive": not non_interactive, - "backup_password": cli_load_android_backup_password(log, backup_password), - } - - cmd = CmdAndroidCheckADB( - results_path=output, - ioc_files=iocs, - module_name=module, - serial=serial, - module_options=module_options, - disable_version_check=_get_disable_flags(ctx)[0], - disable_indicator_check=_get_disable_flags(ctx)[1], - ) - - if list_modules: - cmd.list_modules() + if shell is None: + if install: + raise click.UsageError("A shell is required when using --install.") + click.echo(completion_instructions(program_name)) return - log.warning( - "DEPRECATION: The 'check-adb' command is deprecated and may be removed in a future release. " - "Prefer acquiring device data using the AndroidQF project (https://github.com/mvt-project/androidqf/) and analyzing that acquisition with MVT." - ) + root_cli = ctx.find_root().command - log.info("Checking Android device over debug bridge") + 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 - cmd.run() + click.echo(generate_completion_script(root_cli, program_name, shell)) - if cmd.detected_count > 0: - log.warning( - "The analysis of the Android device produced %d detections!", - cmd.detected_count, - ) + +# ============================================================================== +# Command: check-adb (removed) +# ============================================================================== +@cli.command( + "check-adb", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_CHECK_ADB_REMOVED +) +@click.pass_context +def check_adb(ctx): + log.error(HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION) + ctx.exit(1) # ============================================================================== @@ -234,11 +180,28 @@ def check_adb( @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, @@ -248,6 +211,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: @@ -256,13 +220,12 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_ log.info("Checking Android bug report at path: %s", bugreport_path) - cmd.run() - - if cmd.detected_count > 0: - log.warning( - "The analysis of the Android bug report produced %d detections!", - cmd.detected_count, - ) + try: + cmd.run() + except BadZipFile as exc: + raise click.ClickException(f"Invalid bugreport archive: {exc}") from exc + cmd.show_alerts_brief() + cmd.show_support_message() # ============================================================================== @@ -283,6 +246,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) @@ -293,12 +263,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( @@ -312,6 +284,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: @@ -321,12 +294,8 @@ def check_backup( log.info("Checking Android backup at path: %s", backup_path) cmd.run() - - if cmd.detected_count > 0: - log.warning( - "The analysis of the Android backup produced %d detections!", - cmd.detected_count, - ) + cmd.show_alerts_brief() + cmd.show_support_message() # ============================================================================== @@ -346,7 +315,18 @@ 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("--virustotal", "-V", is_flag=True, help=HELP_MSG_VIRUS_TOTAL) +@click.option( + "--delay", "-d", type=click.IntRange(min=0), default=16, help=HELP_MSG_DELAY_CHECKS +) @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) @@ -358,13 +338,17 @@ def check_androidqf( output, list_modules, module, + load_module, hashes, + virustotal, + delay, non_interactive, backup_password, verbose, androidqf_path, ): set_verbose_logging(verbose) + custom_modules = _load_custom_modules(load_module) cmd = CmdAndroidCheckAndroidQF( target_path=androidqf_path, @@ -375,9 +359,12 @@ def check_androidqf( module_options={ "interactive": not non_interactive, "backup_password": cli_load_android_backup_password(log, backup_password), + "virustotal": virustotal, + "virustotal_delay": delay, }, disable_version_check=_get_disable_flags(ctx)[0], disable_indicator_check=_get_disable_flags(ctx)[1], + custom_modules=custom_modules, ) if list_modules: @@ -387,12 +374,88 @@ def check_androidqf( log.info("Checking AndroidQF acquisition at path: %s", androidqf_path) cmd.run() + cmd.show_alerts_brief() + cmd.show_disable_adb_warning() + cmd.show_support_message() - if cmd.detected_count > 0: - log.warning( - "The analysis of the AndroidQF acquisition produced %d detections!", - cmd.detected_count, - ) + +# ============================================================================== +# Command: check-intrusion-logs +# ============================================================================== +@cli.command( + "check-intrusion-logs", + context_settings=CONTEXT_SETTINGS, + help=HELP_MSG_CHECK_INTRUSION_LOGS, +) +@click.option( + "--iocs", + "-i", + type=click.Path(exists=True), + multiple=True, + default=[], + help=HELP_MSG_IOC, +) +@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", + default=None, + help=( + "IANA timezone name for the device, for example 'Europe/Paris'. " + "When provided, event timestamps are expressed in the device's local " + "time instead of UTC." + ), +) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) +@click.argument("LOGS_PATH", type=click.Path(exists=True)) +@click.pass_context +def check_intrusion_logs( + ctx, + iocs, + 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: + module_options["device_timezone"] = timezone + + cmd = CmdAndroidCheckIntrusionLogs( + target_path=logs_path, + results_path=output, + ioc_files=iocs, + module_name=module, + 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: + cmd.list_modules() + return + + log.info("Checking intrusion logs at path: %s", logs_path) + + cmd.run() + cmd.show_alerts_brief() + cmd.show_support_message() # ============================================================================== @@ -409,23 +472,37 @@ def check_androidqf( ) @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 ) - cmd.modules = BACKUP_MODULES + ADB_MODULES + BUGREPORT_MODULES if list_modules: cmd.list_modules() return cmd.run() + cmd.show_alerts_brief() + cmd.show_support_message() # ============================================================================== @@ -435,3 +512,10 @@ def check_iocs(ctx, iocs, list_modules, module, folder): def download_indicators(): ioc_updates = IndicatorsUpdates() ioc_updates.update() + + +register_cli_plugins( + cli, + entry_point_group=ANDROID_CLI_PLUGIN_GROUP, + environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV, +) diff --git a/src/mvt/android/cmd_check_adb.py b/src/mvt/android/cmd_check_adb.py deleted file mode 100644 index 3388792..0000000 --- a/src/mvt/android/cmd_check_adb.py +++ /dev/null @@ -1,48 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -from typing import Optional - -from mvt.common.command import Command -from mvt.common.indicators import Indicators - -from .modules.adb import ADB_MODULES - -log = logging.getLogger(__name__) - - -class CmdAndroidCheckADB(Command): - def __init__( - self, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - ioc_files: Optional[list] = None, - iocs: Optional[Indicators] = None, - module_name: Optional[str] = None, - serial: Optional[str] = None, - module_options: Optional[dict] = None, - hashes: Optional[bool] = False, - sub_command: Optional[bool] = False, - disable_version_check: bool = False, - disable_indicator_check: bool = False, - ) -> None: - super().__init__( - target_path=target_path, - results_path=results_path, - ioc_files=ioc_files, - iocs=iocs, - module_name=module_name, - serial=serial, - module_options=module_options, - hashes=hashes, - sub_command=sub_command, - log=log, - disable_version_check=disable_version_check, - disable_indicator_check=disable_indicator_check, - ) - - self.name = "check-adb" - self.modules = ADB_MODULES diff --git a/src/mvt/android/cmd_check_androidqf.py b/src/mvt/android/cmd_check_androidqf.py index 564e26b..ffa3e13 100644 --- a/src/mvt/android/cmd_check_androidqf.py +++ b/src/mvt/android/cmd_check_androidqf.py @@ -3,16 +3,22 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import json import logging import os +import shutil +import tempfile import zipfile from pathlib import Path from typing import List, Optional -from mvt.android.cmd_check_backup import CmdAndroidCheckBackup +from mvt.android.artifacts.getprop import GetProp +from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs +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 @@ -46,6 +52,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, @@ -60,8 +67,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 @@ -70,6 +79,9 @@ class CmdAndroidCheckAndroidQF(Command): self.__files: List[str] = [] def init(self): + if not self.target_path: + raise NoAndroidQFTargetPath + if os.path.isdir(self.target_path): self.__format = "dir" parent_path = Path(self.target_path).absolute().parent.as_posix() @@ -83,6 +95,47 @@ class CmdAndroidCheckAndroidQF(Command): self.__zip = zipfile.ZipFile(self.target_path) self.__files = self.__zip.namelist() + self._load_acquisition_context() + + def _load_acquisition_context(self) -> None: + """Pass AndroidQF acquisition metadata to nested commands and modules.""" + context = {} + metadata_files = [ + file_path + for file_path in self.__files + if file_path.replace("\\", "/").rsplit("/", 1)[-1] == "acquisition.json" + ] + for file_path in metadata_files: + try: + metadata = json.loads(self._get_file_content(file_path)) + if isinstance(metadata, dict): + context["started"] = metadata.get("started") + context["adb_host_public_key"] = metadata.get("adb_host_public_key") + break + except (json.JSONDecodeError, OSError, TypeError, UnicodeDecodeError): + self.log.warning( + 'Unable to read AndroidQF acquisition metadata "%s"', file_path + ) + + if not context.get("adb_host_public_key"): + key_files = [ + file_path + for file_path in self.__files + if file_path.replace("\\", "/").rsplit("/", 1)[-1] == "adb_host_key.pub" + ] + for file_path in key_files: + try: + context["adb_host_public_key"] = self._get_file_content( + file_path + ).decode("utf-8") + break + except (OSError, UnicodeDecodeError): + self.log.warning( + 'Unable to read AndroidQF ADB host key "%s"', file_path + ) + + self.module_options["androidqf_acquisition"] = context + def module_init(self, module: AndroidQFModule) -> None: # type: ignore[override] if self.__format == "zip" and self.__zip: module.from_zip(self.__zip, self.__files) @@ -136,7 +189,57 @@ class CmdAndroidCheckAndroidQF(Command): raise NoAndroidQFBackup + def _read_device_timezone(self) -> Optional[str]: + getprop_files = [ + f for f in self.__files if f.replace("\\", "/").endswith("getprop.txt") + ] + if not getprop_files: + self.log.warning( + "Could not find getprop.txt; intrusion log timestamps will use UTC." + ) + return None + + try: + content = self._get_file_content(getprop_files[0]).decode( + "utf-8", errors="ignore" + ) + except Exception as exc: + self.log.warning("Could not read getprop.txt: %s", exc) + return None + + props = GetProp() + props.parse(content) + timezone = props.get_device_timezone() + if timezone: + self.log.info( + "Device timezone identified from getprop.txt: %s", + timezone, + ) + else: + self.log.warning( + "persist.sys.timezone not found in getprop.txt; " + "intrusion log timestamps will use UTC." + ) + + return timezone + + def _get_file_content(self, file_path: str) -> bytes: + if self.__format == "zip" and self.__zip: + handle = self.__zip.open(file_path) + try: + return handle.read() + finally: + handle.close() + + if self.__format == "dir" and self.target_path: + parent_path = Path(self.target_path).absolute().parent.as_posix() + with open(os.path.join(parent_path, file_path), "rb") as handle: + return handle.read() + + raise FileNotFoundError(file_path) + def run_bugreport_cmd(self) -> bool: + bugreport = None try: bugreport = self.load_bugreport() except NoAndroidQFBugReport: @@ -153,13 +256,19 @@ 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() - self.detected_count += cmd.detected_count self.timeline.extend(cmd.timeline) - self.timeline_detected.extend(cmd.timeline_detected) + self.url_results.extend(cmd.url_results) + self.alertstore.extend(cmd.alertstore.alerts) + finally: + if bugreport: + bugreport.close() + + return True def run_backup_cmd(self) -> bool: try: @@ -169,26 +278,120 @@ class CmdAndroidCheckAndroidQF(Command): "Skipping backup modules as no backup.ab found in AndroidQF data." ) return False - else: - cmd = CmdAndroidCheckBackup( - target_path=None, + + cmd = CmdAndroidCheckBackup( + target_path=None, + results_path=self.results_path, + ioc_files=self.ioc_files, + iocs=self.iocs, + module_options=self.module_options, + hashes=self.hashes, + sub_command=True, + custom_modules=self.custom_modules, + ) + 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) + self.url_results.extend(cmd.url_results) + self.alertstore.extend(cmd.alertstore.alerts) + return True + + def run_intrusion_logs_cmd(self) -> bool: + intrusion_log_files = [ + f + for f in self.__files + if "/intrusion_logs/" in f.replace("\\", "/") + or f.replace("\\", "/").startswith("intrusion_logs/") + ] + + if not intrusion_log_files: + self.log.info( + "No intrusion_logs folder found in AndroidQF data, " + "skipping intrusion logs analysis." + ) + return False + + self.log.info( + "Found intrusion_logs folder in AndroidQF data, running intrusion logs analysis." + ) + + intrusion_logs_path = None + temp_dir = None + + try: + if self.__format == "dir" and self.target_path: + intrusion_logs_path = os.path.join( + os.path.abspath(self.target_path), "intrusion_logs" + ) + if not os.path.isdir(intrusion_logs_path): + self.log.warning( + "intrusion_logs directory not found at %s", + intrusion_logs_path, + ) + return False + + elif self.__format == "zip" and self.__zip: + temp_dir = tempfile.mkdtemp(prefix="mvt_intrusion_logs_") + temp_root = Path(temp_dir).resolve() + for entry in intrusion_log_files: + normalized = entry.replace("\\", "/") + idx = normalized.find("intrusion_logs/") + relative = normalized[idx + len("intrusion_logs/") :] + if not relative or relative.endswith("/"): + continue + + target = (temp_root / relative).resolve() + if not target.is_relative_to(temp_root): + self.log.warning( + "Skipping unsafe intrusion log archive entry: %s", entry + ) + continue + + target.parent.mkdir(parents=True, exist_ok=True) + with self.__zip.open(entry) as src, target.open("wb") as dst: + dst.write(src.read()) + + intrusion_logs_path = temp_dir + else: + return False + + adv_module_options = dict(self.module_options or {}) + if device_timezone := self._read_device_timezone(): + adv_module_options["device_timezone"] = device_timezone + + cmd = CmdAndroidCheckIntrusionLogs( + target_path=intrusion_logs_path, results_path=self.results_path, ioc_files=self.ioc_files, iocs=self.iocs, - module_options=self.module_options, + module_options=adv_module_options, hashes=self.hashes, sub_command=True, + custom_modules=self.custom_modules, ) - cmd.from_ab(backup) cmd.run() - self.detected_count += cmd.detected_count self.timeline.extend(cmd.timeline) - self.timeline_detected.extend(cmd.timeline_detected) + self.url_results.extend(cmd.url_results) + self.alertstore.extend(cmd.alertstore.alerts) + return True + + finally: + if temp_dir: + shutil.rmtree(temp_dir, ignore_errors=True) def finish(self) -> None: """ - Run the bugreport and backup modules if the respective files are found in the AndroidQF data. + Run nested modules if their respective files are found in AndroidQF data. """ self.run_bugreport_cmd() self.run_backup_cmd() + self.run_intrusion_logs_cmd() diff --git a/src/mvt/android/cmd_check_backup.py b/src/mvt/android/cmd_check_backup.py index 8bdb05e..b75bb34 100644 --- a/src/mvt/android/cmd_check_backup.py +++ b/src/mvt/android/cmd_check_backup.py @@ -11,7 +11,7 @@ import tarfile from pathlib import Path from typing import List, Optional -from mvt.android.modules.backup.base import BackupExtraction +from mvt.android.modules.backup.base import BackupModule from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password from mvt.android.parsers.backup import ( AndroidBackupParsingError, @@ -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,19 +61,25 @@ 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 - self.backup_type: str = "" - self.backup_archive: Optional[tarfile.TarFile] = None - self.backup_files: List[str] = [] + self.__type: str = "" + self.__tar: Optional[tarfile.TarFile] = None + self.__files: List[str] = [] def from_ab(self, ab_file_bytes: bytes) -> None: - self.backup_type = "ab" + 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,32 +95,51 @@ 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.backup_archive = tarfile.open(fileobj=dbytes) - for member in self.backup_archive: - self.backup_files.append(member.name) + 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) def init(self) -> None: - if not self.target_path: + if not self.target_path: # type: ignore[has-type] return - if os.path.isfile(self.target_path): - self.backup_type = "ab" - with open(self.target_path, "rb") as handle: + # Type guard: we know it's not None here after the check above + assert self.target_path is not None # type: ignore[has-type] + # Use a different local variable name to avoid any scoping issues + backup_path: str = self.target_path # type: ignore[has-type] + + if os.path.isfile(backup_path): + self.__type = "ab" + with open(backup_path, "rb") as handle: ab_file_bytes = handle.read() self.from_ab(ab_file_bytes) - elif os.path.isdir(self.target_path): - self.backup_type = "folder" - self.target_path = Path(self.target_path).absolute().as_posix() - for root, subdirs, subfiles in os.walk(os.path.abspath(self.target_path)): + elif os.path.isdir(backup_path): + self.__type = "folder" + backup_path = Path(backup_path).absolute().as_posix() + self.target_path = backup_path + for root, subdirs, subfiles in os.walk(os.path.abspath(backup_path)): for fname in subfiles: - self.backup_files.append( - os.path.relpath(os.path.join(root, fname), self.target_path) + self.__files.append( + os.path.relpath(os.path.join(root, fname), backup_path) ) else: log.critical( @@ -117,8 +148,12 @@ class CmdAndroidCheckBackup(Command): ) sys.exit(1) - def module_init(self, module: BackupExtraction) -> None: # type: ignore[override] - if self.backup_type == "folder": - module.from_dir(self.target_path, self.backup_files) + def module_init(self, module: BackupModule) -> None: # type: ignore[override] + if self.__type == "folder": + module.from_dir(self.target_path, self.__files) else: - module.from_ab(self.target_path, self.backup_archive, self.backup_files) + module.from_ab(self.target_path, self.__tar, self.__files) + + def finish(self) -> None: + if self.__tar: + self.__tar.close() diff --git a/src/mvt/android/cmd_check_bugreport.py b/src/mvt/android/cmd_check_bugreport.py index 7cc827f..1c03c6d 100644 --- a/src/mvt/android/cmd_check_bugreport.py +++ b/src/mvt/android/cmd_check_bugreport.py @@ -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 @@ -96,6 +100,8 @@ class CmdAndroidCheckBugreport(Command): if self.__format == "zip": module.from_zip(self.__zip, self.__files) else: + if not self.target_path: + raise ValueError("target_path is not set") module.from_dir(self.target_path, self.__files) def finish(self) -> None: diff --git a/src/mvt/android/cmd_check_intrusion_logs.py b/src/mvt/android/cmd_check_intrusion_logs.py new file mode 100644 index 0000000..95f2089 --- /dev/null +++ b/src/mvt/android/cmd_check_intrusion_logs.py @@ -0,0 +1,117 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import logging +import os +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, + KNOWN_INTRUSION_LOG_EVENT_TYPES, +) +from .modules.intrusion_logs.base import IntrusionLogsModule + +log = logging.getLogger(__name__) + + +class CmdAndroidCheckIntrusionLogs(Command): + """Command to check Android Intrusion Logging files.""" + + def __init__( + self, + target_path: Optional[str] = None, + results_path: Optional[str] = None, + ioc_files: Optional[list] = None, + iocs: Optional[Indicators] = None, + module_name: Optional[str] = None, + serial: Optional[str] = None, + module_options: Optional[dict] = None, + hashes: Optional[bool] = False, + 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, + results_path=results_path, + ioc_files=ioc_files, + iocs=iocs, + module_name=module_name, + serial=serial, + module_options=module_options, + hashes=hashes, + sub_command=sub_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]] = {} + + def init(self) -> None: + if not self.target_path: + raise ValueError("No target path specified") + + if not os.path.isdir(self.target_path) and not ( + os.path.isfile(self.target_path) + and self.target_path.lower().endswith(".zip") + ): + raise ValueError( + f"Target path must be a directory or a .zip file: {self.target_path}" + ) + + self.log.info("Checking intrusion logs at path: %s", self.target_path) + self._all_events = self._pre_load_events() + + def module_init(self, module: IntrusionLogsModule) -> None: # type: ignore[override] + module.il_events_by_type = self._all_events + + def finish(self) -> None: + return + + def _pre_load_events(self) -> dict[str, list[dict]]: + """Load and parse all advanced-log files once for reuse by all modules.""" + self.log.info("Pre-loading intrusion log files from: %s", self.target_path) + + loader = IntrusionLogsModule( + target_path=self.target_path, + log=self.log, + ) + + try: + all_events = loader.load_all_events(self.target_path) + except Exception as exc: + self.log.error("Failed to pre-load events: %s", exc) + return {} + + total_events = sum(len(events) for events in all_events.values()) + self.log.info( + "Pre-loaded %d events across %d type(s); modules will reuse this data", + total_events, + len(all_events), + ) + + unknown_event_types = sorted( + event_type + for event_type in all_events + if event_type not in KNOWN_INTRUSION_LOG_EVENT_TYPES + ) + if unknown_event_types: + self.log.warning( + "Found unknown intrusion logging event type(s): %s. " + "Please open an issue on GitHub so MVT can add support for them.", + ", ".join(unknown_event_types), + ) + + return all_events diff --git a/src/mvt/android/cmd_download_apks.py b/src/mvt/android/cmd_download_apks.py deleted file mode 100644 index deacb05..0000000 --- a/src/mvt/android/cmd_download_apks.py +++ /dev/null @@ -1,184 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import json -import logging -import os -from typing import Callable, Optional, Union - -from rich.progress import track - -from mvt.common.module import InsufficientPrivileges - -from .modules.adb.base import AndroidExtraction -from .modules.adb.packages import Packages - -log = logging.getLogger(__name__) - - -class DownloadAPKs(AndroidExtraction): - """DownloadAPKs is the main class operating the download of APKs - from the device. - """ - - def __init__( - self, - results_path: Optional[str] = None, - all_apks: bool = False, - packages: Optional[list] = None, - ) -> None: - """Initialize module. - :param results_path: Path to the folder where data should be stored - :param all_apks: Boolean indicating whether to download all packages - or filter known-goods - :param packages: Provided list of packages, typically for JSON checks - """ - super().__init__(results_path=results_path, log=log) - - self.packages = packages - self.all_apks = all_apks - self.results_path_apks = None - - @classmethod - def from_json(cls, json_path: str) -> Callable: - """Initialize this class from an existing apks.json file. - - :param json_path: Path to the apks.json file to parse. - - """ - with open(json_path, "r", encoding="utf-8") as handle: - packages = json.load(handle) - return cls(packages=packages) - - def pull_package_file( - self, package_name: str, remote_path: str - ) -> Union[str, None]: - """Pull files related to specific package from the device. - - :param package_name: Name of the package to download - :param remote_path: Path to the file to download - :returns: Path to the local copy - - """ - log.info("Downloading %s ...", remote_path) - - file_name = "" - if "==/" in remote_path: - file_name = "_" + remote_path.split("==/")[1].replace(".apk", "") - - local_path = os.path.join( - self.results_path_apks, f"{package_name}{file_name}.apk" - ) - name_counter = 0 - while True: - if not os.path.exists(local_path): - break - - name_counter += 1 - local_path = os.path.join( - self.results_path_apks, f"{package_name}{file_name}_{name_counter}.apk" - ) - - try: - self._adb_download(remote_path, local_path) - except InsufficientPrivileges: - log.error( - "Unable to pull package file from %s: insufficient privileges, " - "it might be a system app", - remote_path, - ) - self._adb_reconnect() - return None - except Exception as exc: - log.exception("Failed to pull package file from %s: %s", remote_path, exc) - self._adb_reconnect() - return None - - return local_path - - def get_packages(self) -> None: - """Use the Packages adb module to retrieve the list of packages. - We reuse the same extraction logic to then download the APKs. - """ - self.log.info("Retrieving list of installed packages...") - - m = Packages() - m.log = self.log - m.serial = self.serial - m.run() - - self.packages = m.results - - def pull_packages(self) -> None: - """Download all files of all selected packages from the device.""" - log.info( - "Starting extraction of installed APKs at folder %s", self.results_path - ) - - # If the user provided the flag --all-apks we select all packages. - packages_selection = [] - if self.all_apks: - log.info("Selected all %d available packages", len(self.packages)) - packages_selection = self.packages - else: - # Otherwise we loop through the packages and get only those that - # are not marked as system. - for package in self.packages: - if not package.get("system", False): - packages_selection.append(package) - - log.info( - 'Selected only %d packages which are not marked as "system"', - len(packages_selection), - ) - - if len(packages_selection) == 0: - log.info("No packages were selected for download") - return - - log.info("Downloading packages from device. This might take some time ...") - - self.results_path_apks = os.path.join(self.results_path, "apks") - if not os.path.exists(self.results_path_apks): - os.makedirs(self.results_path_apks, exist_ok=True) - - for i in track( - range(len(packages_selection)), - description=f"Downloading {len(packages_selection)} packages...", - ): - package = packages_selection[i] - - log.info( - "[%d/%d] Package: %s", - i, - len(packages_selection), - package["package_name"], - ) - - # Sometimes the package path contains multiple lines for multiple - # apks. We loop through each line and download each file. - for package_file in package["files"]: - device_path = package_file["path"] - local_path = self.pull_package_file( - package["package_name"], device_path - ) - if not local_path: - continue - - package_file["local_path"] = local_path - - log.info("Download of selected packages completed") - - def save_json(self) -> None: - json_path = os.path.join(self.results_path, "apks.json") - with open(json_path, "w", encoding="utf-8") as handle: - json.dump(self.packages, handle, indent=4) - - def run(self) -> None: - self.get_packages() - self._adb_connect() - self.pull_packages() - self.save_json() - self._adb_disconnect() diff --git a/src/mvt/android/modules/adb/__init__.py b/src/mvt/android/modules/adb/__init__.py deleted file mode 100644 index b2b4368..0000000 --- a/src/mvt/android/modules/adb/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -from .chrome_history import ChromeHistory -from .dumpsys_full import DumpsysFull -from .files import Files -from .getprop import Getprop -from .logcat import Logcat -from .packages import Packages -from .processes import Processes -from .root_binaries import RootBinaries -from .selinux_status import SELinuxStatus -from .settings import Settings -from .sms import SMS -from .whatsapp import Whatsapp - -ADB_MODULES = [ - ChromeHistory, - SMS, - Whatsapp, - Processes, - Getprop, - Settings, - SELinuxStatus, - DumpsysFull, - Packages, - Logcat, - RootBinaries, - Files, -] diff --git a/src/mvt/android/modules/adb/base.py b/src/mvt/android/modules/adb/base.py deleted file mode 100644 index 72df794..0000000 --- a/src/mvt/android/modules/adb/base.py +++ /dev/null @@ -1,355 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import base64 -import logging -import os -import random -import string -import sys -import tempfile -import time -from typing import Callable, Optional - -from adb_shell.adb_device import AdbDeviceTcp, AdbDeviceUsb -from adb_shell.auth.keygen import keygen, write_public_keyfile -from adb_shell.auth.sign_pythonrsa import PythonRSASigner -from adb_shell.exceptions import ( - AdbCommandFailureException, - DeviceAuthError, - UsbDeviceNotFoundError, - UsbReadFailedError, -) -from usb1 import USBErrorAccess, USBErrorBusy - -from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password -from mvt.android.parsers.backup import ( - InvalidBackupPassword, - parse_ab_header, - parse_backup_file, -) -from mvt.common.module import InsufficientPrivileges, MVTModule - -ADB_KEY_PATH = os.path.expanduser("~/.android/adbkey") -ADB_PUB_KEY_PATH = os.path.expanduser("~/.android/adbkey.pub") - - -class AndroidExtraction(MVTModule): - """This class provides a base for all Android extraction modules.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - self.device = None - self.serial = None - - @staticmethod - def _adb_check_keys() -> None: - """Make sure Android adb keys exist.""" - if not os.path.isdir(os.path.dirname(ADB_KEY_PATH)): - os.makedirs(os.path.dirname(ADB_KEY_PATH)) - - if not os.path.exists(ADB_KEY_PATH): - keygen(ADB_KEY_PATH) - - if not os.path.exists(ADB_PUB_KEY_PATH): - write_public_keyfile(ADB_KEY_PATH, ADB_PUB_KEY_PATH) - - def _adb_connect(self) -> None: - """Connect to the device over adb.""" - self._adb_check_keys() - - with open(ADB_KEY_PATH, "rb") as handle: - priv_key = handle.read() - - with open(ADB_PUB_KEY_PATH, "rb") as handle: - pub_key = handle.read() - - signer = PythonRSASigner(pub_key, priv_key) - - # If no serial was specified or if the serial does not seem to be - # a HOST:PORT definition, we use the USB transport. - if not self.serial or ":" not in self.serial: - try: - self.device = AdbDeviceUsb(serial=self.serial) - except UsbDeviceNotFoundError: - self.log.critical( - "No device found. Make sure it is connected and unlocked." - ) - sys.exit(-1) - # Otherwise we try to use the TCP transport. - else: - addr = self.serial.split(":") - if len(addr) < 2: - raise ValueError( - "TCP serial number must follow the format: `address:port`" - ) - - self.device = AdbDeviceTcp( - addr[0], int(addr[1]), default_transport_timeout_s=30.0 - ) - - while True: - try: - self.device.connect(rsa_keys=[signer], auth_timeout_s=5) - except (USBErrorBusy, USBErrorAccess): - self.log.critical( - "Device is busy, maybe run `adb kill-server` and try again." - ) - sys.exit(-1) - except DeviceAuthError: - self.log.error( - "You need to authorize this computer on the Android device. " - "Retrying in 5 seconds..." - ) - time.sleep(5) - except UsbReadFailedError: - self.log.error( - "Unable to connect to the device over USB. " - "Try to unplug, plug the device and start again." - ) - sys.exit(-1) - except OSError as exc: - if exc.errno == 113 and self.serial: - self.log.critical( - "Unable to connect to the device %s: " - "did you specify the correct IP address?", - self.serial, - ) - sys.exit(-1) - else: - break - - def _adb_disconnect(self) -> None: - """Close adb connection to the device.""" - self.device.close() - - def _adb_reconnect(self) -> None: - """Reconnect to device using adb.""" - self.log.info("Reconnecting ...") - self._adb_disconnect() - self._adb_connect() - - def _adb_command(self, command: str, decode: bool = True) -> str: - """Execute an adb shell command. - - :param command: Shell command to execute - :returns: Output of command - - """ - return self.device.shell(command, read_timeout_s=200.0, decode=decode) - - def _adb_check_if_root(self) -> bool: - """Check if we have a `su` binary on the Android device. - - - :returns: Boolean indicating whether a `su` binary is present or not - - """ - result = self._adb_command("command -v su && su -c true") - return bool(result) and "Permission denied" not in result - - def _adb_root_or_die(self) -> None: - """Check if we have a `su` binary, otherwise raise an Exception.""" - if not self._adb_check_if_root(): - raise InsufficientPrivileges( - "This module is optionally available " - "in case the device is already rooted." - " Do NOT root your own device!" - ) - - def _adb_command_as_root(self, command): - """Execute an adb shell command. - - :param command: Shell command to execute as root - :returns: Output of command - - """ - return self._adb_command(f"su -c {command}") - - def _adb_check_file_exists(self, file: str) -> bool: - """Verify that a file exists. - - :param file: Path of the file - :returns: Boolean indicating whether the file exists or not - - """ - - # TODO: Need to support checking files without root privileges as well. - - # Check if we have root, if not raise an Exception. - self._adb_root_or_die() - - return bool(self._adb_command_as_root(f"[ ! -f {file} ] || echo 1")) - - def _adb_download( - self, - remote_path: str, - local_path: str, - progress_callback: Optional[Callable] = None, - retry_root: Optional[bool] = True, - ) -> None: - """Download a file form the device. - - :param remote_path: Path to download from the device - :param local_path: Path to where to locally store the copy of the file - :param progress_callback: Callback for download progress bar - (Default value = None) - :param retry_root: Default value = True) - - """ - try: - self.device.pull(remote_path, local_path, progress_callback) - except AdbCommandFailureException as exc: - if retry_root: - self._adb_download_root(remote_path, local_path, progress_callback) - else: - raise Exception( - f"Unable to download file {remote_path}: {exc}" - ) from exc - - def _adb_download_root( - self, - remote_path: str, - local_path: str, - progress_callback: Optional[Callable] = None, - ) -> None: - try: - # Check if we have root, if not raise an Exception. - self._adb_root_or_die() - - # We generate a random temporary filename. - allowed_chars = ( - string.ascii_uppercase + string.ascii_lowercase + string.digits - ) - tmp_filename = "tmp_" + "".join(random.choices(allowed_chars, k=10)) - - # We create a temporary local file. - new_remote_path = f"/sdcard/{tmp_filename}" - - # We copy the file from the data folder to /sdcard/. - cp_output = self._adb_command_as_root(f"cp {remote_path} {new_remote_path}") - if ( - cp_output.startswith("cp: ") - and "No such file or directory" in cp_output - ): - raise Exception(f"Unable to process file {remote_path}: File not found") - if cp_output.startswith("cp: ") and "Permission denied" in cp_output: - raise Exception( - f"Unable to process file {remote_path}: Permission denied" - ) - - # We download from /sdcard/ to the local temporary file. - # If it doesn't work now, don't try again (retry_root=False) - self._adb_download( - new_remote_path, local_path, progress_callback, retry_root=False - ) - - # Delete the copy on /sdcard/. - self._adb_command(f"rm -rf {new_remote_path}") - - except AdbCommandFailureException as exc: - raise Exception(f"Unable to download file {remote_path}: {exc}") from exc - - def _adb_process_file(self, remote_path: str, process_routine: Callable) -> None: - """Download a local copy of a file which is only accessible as root. - This is a wrapper around process_routine. - - :param remote_path: Path of the file on the device to process - :param process_routine: Function to be called on the local copy of the - downloaded file - - """ - # Connect to the device over adb. - # Check if we have root, if not raise an Exception. - self._adb_root_or_die() - - # We create a temporary local file. - tmp = tempfile.NamedTemporaryFile() - local_path = tmp.name - local_name = os.path.basename(tmp.name) - new_remote_path = f"/sdcard/Download/{local_name}" - - # We copy the file from the data folder to /sdcard/. - cp_output = self._adb_command_as_root(f"cp {remote_path} {new_remote_path}") - if cp_output.startswith("cp: ") and "No such file or directory" in cp_output: - raise Exception(f"Unable to process file {remote_path}: File not found") - if cp_output.startswith("cp: ") and "Permission denied" in cp_output: - raise Exception(f"Unable to process file {remote_path}: Permission denied") - - # We download from /sdcard/ to the local temporary file. - self._adb_download(new_remote_path, local_path) - - # Launch the provided process routine! - process_routine(local_path) - - # Delete the local copy. - tmp.close() - # Delete the copy on /sdcard/. - self._adb_command(f"rm -f {new_remote_path}") - - def _generate_backup(self, package_name: str) -> bytes: - self.log.info( - "Please check phone and accept Android backup prompt. " - "You may need to set a backup password. \a" - ) - - if self.module_options.get("backup_password", None): - self.log.warning( - "Backup password already set from command line or environment " - "variable. You should use the same password if enabling encryption!" - ) - - # TODO: Base64 encoding as temporary fix to avoid byte-mangling over - # the shell transport... - cmd = f"/system/bin/bu backup -nocompress '{package_name}' | base64" - backup_output_b64 = self._adb_command(cmd) - backup_output = base64.b64decode(backup_output_b64) - header = parse_ab_header(backup_output) - - if not header["backup"]: - self.log.error( - "Extracting SMS via Android backup failed. No valid backup data found." - ) - return None - - if header["encryption"] == "none": - return parse_backup_file(backup_output, password=None) - - for _ in range(0, 3): - backup_password = prompt_or_load_android_backup_password( - self.log, self.module_options - ) - if not backup_password: - # Fail as no backup password loaded for this encrypted backup - self.log.critical("No backup password provided.") - try: - decrypted_backup_tar = parse_backup_file(backup_output, backup_password) - return decrypted_backup_tar - except InvalidBackupPassword: - self.log.error("You provided the wrong password! Please try again...") - - self.log.error("All attempts to decrypt backup with password failed!") - - return None - - def run(self) -> None: - """Run the main procedure.""" - raise NotImplementedError diff --git a/src/mvt/android/modules/adb/chrome_history.py b/src/mvt/android/modules/adb/chrome_history.py deleted file mode 100644 index 54be2a0..0000000 --- a/src/mvt/android/modules/adb/chrome_history.py +++ /dev/null @@ -1,110 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -import os -import sqlite3 -from typing import Optional, Union - -from mvt.common.utils import convert_chrometime_to_datetime, convert_datetime_to_iso - -from .base import AndroidExtraction - -CHROME_HISTORY_PATH = "data/data/com.android.chrome/app_chrome/Default/History" - - -class ChromeHistory(AndroidExtraction): - """This module extracts records from Android's Chrome browsing history.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - self.results = [] - - def serialize(self, record: dict) -> Union[dict, list]: - return { - "timestamp": record["isodate"], - "module": self.__class__.__name__, - "event": "visit", - "data": f"{record['id']} - {record['url']} (visit ID: {record['visit_id']}, " - f"redirect source: {record['redirect_source']})", - } - - def check_indicators(self) -> None: - if not self.indicators: - return - - for result in self.results: - if self.indicators.check_url(result["url"]): - self.detected.append(result) - continue - - def _parse_db(self, db_path: str) -> None: - """Parse a Chrome History database file. - - :param db_path: Path to the History database to process. - - """ - assert isinstance(self.results, list) # assert results type for mypy - conn = sqlite3.connect(db_path) - cur = conn.cursor() - cur.execute( - """ - SELECT - urls.id, - urls.url, - visits.id, - visits.visit_time, - visits.from_visit - FROM urls - JOIN visits ON visits.url = urls.id - ORDER BY visits.visit_time; - """ - ) - - for item in cur: - self.results.append( - { - "id": item[0], - "url": item[1], - "visit_id": item[2], - "timestamp": item[3], - "isodate": convert_datetime_to_iso( - convert_chrometime_to_datetime(item[3]) - ), - "redirect_source": item[4], - } - ) - - cur.close() - conn.close() - - self.log.info("Extracted a total of %d history items", len(self.results)) - - def run(self) -> None: - self._adb_connect() - - try: - self._adb_process_file( - os.path.join("/", CHROME_HISTORY_PATH), self._parse_db - ) - except Exception as exc: - self.log.error(exc) - - self._adb_disconnect() diff --git a/src/mvt/android/modules/adb/dumpsys_full.py b/src/mvt/android/modules/adb/dumpsys_full.py deleted file mode 100644 index 6103357..0000000 --- a/src/mvt/android/modules/adb/dumpsys_full.py +++ /dev/null @@ -1,45 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -import os -from typing import Optional - -from .base import AndroidExtraction - - -class DumpsysFull(AndroidExtraction): - """This module extracts stats on battery consumption by processes.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - def run(self) -> None: - self._adb_connect() - - output = self._adb_command("dumpsys") - if self.results_path: - output_path = os.path.join(self.results_path, "dumpsys.txt") - with open(output_path, "w", encoding="utf-8") as handle: - handle.write(output) - - self.log.info("Full dumpsys output stored at %s", output_path) - - self._adb_disconnect() diff --git a/src/mvt/android/modules/adb/files.py b/src/mvt/android/modules/adb/files.py deleted file mode 100644 index a8a11a2..0000000 --- a/src/mvt/android/modules/adb/files.py +++ /dev/null @@ -1,155 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -import os -import stat -from typing import Optional, Union - -from mvt.common.utils import convert_unix_to_iso - -from .base import AndroidExtraction - -ANDROID_TMP_FOLDERS = [ - "/tmp/", - "/data/local/tmp/", -] -ANDROID_MEDIA_FOLDERS = [ - "/data/media/0", - "/sdcard/", -] - - -class Files(AndroidExtraction): - """This module extracts the list of files on the device.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - self.full_find = False - - def serialize(self, record: dict) -> Union[dict, list, None]: - if "modified_time" in record: - return { - "timestamp": record["modified_time"], - "module": self.__class__.__name__, - "event": "file_modified", - "data": record["path"], - } - - return None - - def check_indicators(self) -> None: - for result in self.results: - if result.get("is_suid"): - self.log.warning( - 'Found an SUID file in a non-standard directory "%s".', - result["path"], - ) - - if self.indicators and self.indicators.check_file_path(result["path"]): - self.log.warning( - 'Found a known suspicous file at path: "%s"', result["path"] - ) - self.detected.append(result) - - def backup_file(self, file_path: str) -> None: - if not self.results_path: - return - - local_file_name = file_path.replace("/", "_").replace(" ", "-") - local_files_folder = os.path.join(self.results_path, "files") - if not os.path.exists(local_files_folder): - os.mkdir(local_files_folder) - - local_file_path = os.path.join(local_files_folder, local_file_name) - - try: - self._adb_download(remote_path=file_path, local_path=local_file_path) - except Exception: - pass - else: - self.log.info( - "Downloaded file %s to local copy at %s", file_path, local_file_path - ) - - def find_files(self, folder: str) -> None: - assert isinstance(self.results, list) - if self.full_find: - cmd = f"find '{folder}' -type f -printf '%T@ %m %s %u %g %p\n' 2> /dev/null" - output = self._adb_command(cmd) - - for file_line in output.splitlines(): - file_info = file_line.rstrip().split(" ", 5) - if len(file_line) < 6: - self.log.info("Skipping invalid file info - %s", file_line.rstrip()) - continue - [unix_timestamp, mode, size, owner, group, full_path] = file_info - mod_time = convert_unix_to_iso(unix_timestamp) - - self.results.append( - { - "path": full_path, - "modified_time": mod_time, - "mode": mode, - "is_suid": (int(mode, 8) & stat.S_ISUID) == 2048, - "is_sgid": (int(mode, 8) & stat.S_ISGID) == 1024, - "size": size, - "owner": owner, - "group": group, - } - ) - else: - output = self._adb_command(f"find '{folder}' -type f 2> /dev/null") - for file_line in output.splitlines(): - self.results.append({"path": file_line.rstrip()}) - - def run(self) -> None: - self._adb_connect() - - cmd = "find '/' -maxdepth 1 -printf '%T@ %m %s %u %g %p\n' 2> /dev/null" - output = self._adb_command(cmd) - if output or output.strip().splitlines(): - self.full_find = True - - for tmp_folder in ANDROID_TMP_FOLDERS: - self.find_files(tmp_folder) - - for entry in self.results: - self.log.info("Found file in tmp folder at path %s", entry.get("path")) - self.backup_file(entry.get("path")) - - for media_folder in ANDROID_MEDIA_FOLDERS: - self.find_files(media_folder) - - self.log.info( - "Found %s files in primary Android tmp and media folders", len(self.results) - ) - - if self.module_options.get("fast_mode", None): - self.log.info( - "The `fast_mode` option was enabled: skipping full file listing" - ) - else: - self.log.info("Processing full file listing. This may take a while...") - self.find_files("/") - - self.log.info("Found %s total files", len(self.results)) - - self._adb_disconnect() diff --git a/src/mvt/android/modules/adb/getprop.py b/src/mvt/android/modules/adb/getprop.py deleted file mode 100644 index 71bface..0000000 --- a/src/mvt/android/modules/adb/getprop.py +++ /dev/null @@ -1,43 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -from typing import Optional - -from mvt.android.artifacts.getprop import GetProp as GetPropArtifact - -from .base import AndroidExtraction - - -class Getprop(GetPropArtifact, AndroidExtraction): - """This module extracts device properties from getprop command.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - self.results = {} if not results else results - - def run(self) -> None: - self._adb_connect() - output = self._adb_command("getprop") - self._adb_disconnect() - - self.parse(output) - self.log.info("Extracted %d Android system properties", len(self.results)) diff --git a/src/mvt/android/modules/adb/logcat.py b/src/mvt/android/modules/adb/logcat.py deleted file mode 100644 index bdc8c48..0000000 --- a/src/mvt/android/modules/adb/logcat.py +++ /dev/null @@ -1,57 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -import os -from typing import Optional - -from .base import AndroidExtraction - - -class Logcat(AndroidExtraction): - """This module extracts details on installed packages.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - def run(self) -> None: - self._adb_connect() - - # Get the current logcat. - output = self._adb_command('logcat -d -b all "*:V"') - # Get the locat prior to last reboot. - last_output = self._adb_command('logcat -L -b all "*:V"') - - if self.results_path: - logcat_path = os.path.join(self.results_path, "logcat.txt") - with open(logcat_path, "w", encoding="utf-8") as handle: - handle.write(output) - - self.log.info("Current logcat logs stored at %s", logcat_path) - - logcat_last_path = os.path.join(self.results_path, "logcat_last.txt") - with open(logcat_last_path, "w", encoding="utf-8") as handle: - handle.write(last_output) - - self.log.info( - "Logcat logs prior to last reboot stored at %s", logcat_last_path - ) - - self._adb_disconnect() diff --git a/src/mvt/android/modules/adb/packages.py b/src/mvt/android/modules/adb/packages.py deleted file mode 100644 index 421ac88..0000000 --- a/src/mvt/android/modules/adb/packages.py +++ /dev/null @@ -1,317 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -from typing import Optional, Union - -from rich.console import Console -from rich.progress import track -from rich.table import Table -from rich.text import Text - -from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact -from mvt.android.utils import ( - DANGEROUS_PERMISSIONS, - DANGEROUS_PERMISSIONS_THRESHOLD, - ROOT_PACKAGES, - SECURITY_PACKAGES, - SYSTEM_UPDATE_PACKAGES, -) -from mvt.common.virustotal import VTNoKey, VTQuotaExceeded, virustotal_lookup - -from .base import AndroidExtraction - - -class Packages(AndroidExtraction): - """This module extracts the list of installed packages.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - self._user_needed = False - - def serialize(self, record: dict) -> Union[dict, list]: - records = [] - - timestamps = [ - {"event": "package_install", "timestamp": record["timestamp"]}, - { - "event": "package_first_install", - "timestamp": record["first_install_time"], - }, - {"event": "package_last_update", "timestamp": record["last_update_time"]}, - ] - - for timestamp in timestamps: - records.append( - { - "timestamp": timestamp["timestamp"], - "module": self.__class__.__name__, - "event": timestamp["event"], - "data": f"{record['package_name']} (system: {record['system']}," - f" third party: {record['third_party']})", - } - ) - - return records - - def check_indicators(self) -> None: - for result in self.results: - if result["package_name"] in ROOT_PACKAGES: - self.log.warning( - 'Found an installed package related to rooting/jailbreaking: "%s"', - result["package_name"], - ) - self.detected.append(result) - continue - - if result["package_name"] in SECURITY_PACKAGES and result["disabled"]: - self.log.warning( - 'Found a security package disabled: "%s"', result["package_name"] - ) - - if result["package_name"] in SYSTEM_UPDATE_PACKAGES and result["disabled"]: - self.log.warning( - 'System OTA update package "%s" disabled on the phone', - result["package_name"], - ) - - if not self.indicators: - continue - - ioc = self.indicators.check_app_id(result.get("package_name")) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) - continue - - for package_file in result.get("files", []): - ioc = self.indicators.check_file_hash(package_file["sha256"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) - - def check_virustotal(self, packages: list) -> None: - hashes = [] - for package in packages: - for file in package.get("files", []): - if file["sha256"] not in hashes: - hashes.append(file["sha256"]) - - total_hashes = len(hashes) - detections = {} - - progress_desc = f"Looking up {total_hashes} files..." - for i in track(range(total_hashes), description=progress_desc): - try: - results = virustotal_lookup(hashes[i]) - except VTNoKey: - return - except VTQuotaExceeded as exc: - print("Unable to continue: %s", exc) - break - - if not results: - continue - - positives = results["attributes"]["last_analysis_stats"]["malicious"] - total = len(results["attributes"]["last_analysis_results"]) - - detections[hashes[i]] = f"{positives}/{total}" - - table = Table(title="VirusTotal Packages Detections") - table.add_column("Package name") - table.add_column("File path") - table.add_column("Detections") - - for package in packages: - for file in package.get("files", []): - if "package_name" in package: - row = [package["package_name"], file["path"]] - elif "name" in package: - row = [package["name"], file["path"]] - else: - self.log.error( - f"Package {package} has no name or package_name. packages.json or apks.json is malformed" - ) - continue - if file["sha256"] in detections: - detection = detections[file["sha256"]] - positives = detection.split("/")[0] - if int(positives) > 0: - row.append(Text(detection, "red bold")) - else: - row.append(detection) - else: - row.append("not found") - - table.add_row(*row) - - console = Console() - console.print(table) - - @staticmethod - def parse_package_for_details(output: str) -> dict: - lines = [] - in_packages = False - for line in output.splitlines(): - if in_packages: - if line.strip() == "": - break - lines.append(line) - if line.strip() == "Packages:": - in_packages = True - - return DumpsysPackagesArtifact.parse_dumpsys_package_for_details( - "\n".join(lines) - ) - - def _get_files_for_package(self, package_name: str) -> list: - command = f"pm path {package_name}" - if self._user_needed: - command += " --user 0" - output = self._adb_command(command) - output = output.strip().replace("package:", "") - if not output: - return [] - - package_files = [] - for file_path in output.splitlines(): - file_path = file_path.strip() - - md5 = self._adb_command(f"md5sum {file_path}").split(" ", maxsplit=1)[0] - sha1 = self._adb_command(f"sha1sum {file_path}").split(" ", maxsplit=1)[0] - sha256 = self._adb_command(f"sha256sum {file_path}").split(" ", maxsplit=1)[ - 0 - ] - sha512 = self._adb_command(f"sha512sum {file_path}").split(" ", maxsplit=1)[ - 0 - ] - - package_files.append( - { - "path": file_path, - "md5": md5, - "sha1": sha1, - "sha256": sha256, - "sha512": sha512, - } - ) - - return package_files - - def run(self) -> None: - self._adb_connect() - - packages = self._adb_command("pm list packages -u -i -f") - if "java.lang.SecurityException" in packages or packages.strip() == "": - self._user_needed = True - packages = self._adb_command("pm list packages -u -i -f --user 0") - - for line in packages.splitlines(): - line = line.strip() - if not line.startswith("package:"): - continue - - fields = line.split() - file_name, package_name = fields[0].split(":")[1].rsplit("=", 1) - - try: - installer = fields[1].split("=")[1].strip() - except IndexError: - installer = None - else: - if installer == "null": - installer = None - - package_files = self._get_files_for_package(package_name) - new_package = { - "package_name": package_name, - "file_name": file_name, - "installer": installer, - "disabled": False, - "system": False, - "third_party": False, - "files": package_files, - } - - dumpsys_package = self._adb_command(f"dumpsys package {package_name}") - package_details = self.parse_package_for_details(dumpsys_package) - new_package.update(package_details) - - self.results.append(new_package) - - cmds = [ - {"field": "disabled", "arg": "-d"}, - {"field": "system", "arg": "-s"}, - {"field": "third_party", "arg": "-3"}, - ] - for cmd in cmds: - command = f"pm list packages {cmd['arg']}" - if self._user_needed: - command += " --user 0" - output = self._adb_command(command) - for line in output.splitlines(): - line = line.strip() - if not line.startswith("package:"): - continue - - package_name = line.split(":", 1)[1] - for i, result in enumerate(self.results): - if result["package_name"] == package_name: - self.results[i][cmd["field"]] = True - - for result in self.results: - if not result["third_party"]: - continue - - dangerous_permissions_count = 0 - for perm in result["requested_permissions"]: - if perm in DANGEROUS_PERMISSIONS: - dangerous_permissions_count += 1 - - if dangerous_permissions_count >= DANGEROUS_PERMISSIONS_THRESHOLD: - self.log.info( - 'Third-party package "%s" requested %d ' - "potentially dangerous permissions", - result["package_name"], - dangerous_permissions_count, - ) - - packages_to_lookup = [] - for result in self.results: - if result["system"]: - continue - - packages_to_lookup.append(result) - self.log.info( - 'Found non-system package with name "%s" installed by "%s" on %s', - result["package_name"], - result["installer"], - result["timestamp"], - ) - - if not self.module_options.get("fast_mode", None): - self.check_virustotal(packages_to_lookup) - - self.log.info( - "Extracted at total of %d installed package names", len(self.results) - ) - - self._adb_disconnect() diff --git a/src/mvt/android/modules/adb/processes.py b/src/mvt/android/modules/adb/processes.py deleted file mode 100644 index 1a9f29f..0000000 --- a/src/mvt/android/modules/adb/processes.py +++ /dev/null @@ -1,42 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -from typing import Optional - -from mvt.android.artifacts.processes import Processes as ProcessesArtifact - -from .base import AndroidExtraction - - -class Processes(ProcessesArtifact, AndroidExtraction): - """This module extracts details on running processes.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - def run(self) -> None: - self._adb_connect() - - output = self._adb_command("ps -A") - self.parse(output) - self._adb_disconnect() - - self.log.info("Extracted records on a total of %d processes", len(self.results)) diff --git a/src/mvt/android/modules/adb/root_binaries.py b/src/mvt/android/modules/adb/root_binaries.py deleted file mode 100644 index 6d8350c..0000000 --- a/src/mvt/android/modules/adb/root_binaries.py +++ /dev/null @@ -1,70 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -from typing import Optional - -from .base import AndroidExtraction - - -class RootBinaries(AndroidExtraction): - """This module extracts the list of installed packages.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - def check_indicators(self) -> None: - for root_binary in self.results: - self.detected.append(root_binary) - self.log.warning('Found root binary "%s"', root_binary) - - def run(self) -> None: - root_binaries = [ - "su", - "busybox", - "supersu", - "Superuser.apk", - "KingoUser.apk", - "SuperSu.apk", - "magisk", - "magiskhide", - "magiskinit", - "magiskpolicy", - ] - - self._adb_connect() - - for root_binary in root_binaries: - root_binary = root_binary.strip() - if not root_binary: - continue - - output = self._adb_command(f"which -a {root_binary}") - output = output.strip() - - if not output: - continue - - if "which: not found" in output: - continue - - self.results.append(root_binary) - - self._adb_disconnect() diff --git a/src/mvt/android/modules/adb/selinux_status.py b/src/mvt/android/modules/adb/selinux_status.py deleted file mode 100644 index a46e362..0000000 --- a/src/mvt/android/modules/adb/selinux_status.py +++ /dev/null @@ -1,48 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -from typing import Optional - -from .base import AndroidExtraction - - -class SELinuxStatus(AndroidExtraction): - """This module checks if SELinux is being enforced.""" - - slug = "selinux_status" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - self.results = {} if not results else results - - def run(self) -> None: - self._adb_connect() - output = self._adb_command("getenforce") - self._adb_disconnect() - - status = output.lower().strip() - self.results["status"] = status - - if status == "enforcing": - self.log.info("SELinux is being regularly enforced") - else: - self.log.warning('SELinux status is "%s"!', status) diff --git a/src/mvt/android/modules/adb/settings.py b/src/mvt/android/modules/adb/settings.py deleted file mode 100644 index 416ef7f..0000000 --- a/src/mvt/android/modules/adb/settings.py +++ /dev/null @@ -1,58 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -from typing import Optional - -from mvt.android.artifacts.settings import Settings as SettingsArtifact - -from .base import AndroidExtraction - - -class Settings(SettingsArtifact, AndroidExtraction): - """This module extracts Android system settings.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - self.results = {} if not results else results - - def run(self) -> None: - self._adb_connect() - - for namespace in ["system", "secure", "global"]: - out = self._adb_command(f"cmd settings list {namespace}") - if not out: - continue - - self.results[namespace] = {} - - for line in out.splitlines(): - line = line.strip() - if line == "": - continue - - fields = line.split("=", 1) - try: - self.results[namespace][fields[0]] = fields[1] - except IndexError: - continue - - self._adb_disconnect() diff --git a/src/mvt/android/modules/adb/sms.py b/src/mvt/android/modules/adb/sms.py deleted file mode 100644 index 673e56a..0000000 --- a/src/mvt/android/modules/adb/sms.py +++ /dev/null @@ -1,179 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -import os -import sqlite3 -from typing import Optional, Union - -from mvt.android.parsers.backup import AndroidBackupParsingError, parse_tar_for_sms -from mvt.common.module import InsufficientPrivileges -from mvt.common.utils import check_for_links, convert_unix_to_iso - -from .base import AndroidExtraction - -SMS_BUGLE_PATH = "data/data/com.google.android.apps.messaging/databases/bugle_db" -SMS_BUGLE_QUERY = """ -SELECT - ppl.normalized_destination AS address, - p.timestamp AS timestamp, -CASE WHEN m.sender_id IN -(SELECT _id FROM participants WHERE contact_id=-1) -THEN 2 ELSE 1 END incoming, p.text AS body -FROM messages m, conversations c, parts p, - participants ppl, conversation_participants cp -WHERE (m.conversation_id = c._id) - AND (m._id = p.message_id) - AND (cp.conversation_id = c._id) - AND (cp.participant_id = ppl._id); -""" - -SMS_MMSSMS_PATH = "data/data/com.android.providers.telephony/databases/mmssms.db" -SMS_MMSMS_QUERY = """ -SELECT - address AS address, - date_sent AS timestamp, - type as incoming, - body AS body -FROM sms; -""" - - -class SMS(AndroidExtraction): - """This module extracts all SMS messages.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - self.sms_db_type = 0 - - def serialize(self, record: dict) -> Union[dict, list]: - body = record["body"].replace("\n", "\\n") - return { - "timestamp": record["isodate"], - "module": self.__class__.__name__, - "event": f"sms_{record['direction']}", - "data": f'{record.get("address", "unknown source")}: "{body}"', - } - - def check_indicators(self) -> None: - if not self.indicators: - return - - for message in self.results: - if "body" not in message: - continue - - message_links = message.get("links", []) - if message_links == []: - message_links = check_for_links(message["body"]) - - if self.indicators.check_urls(message_links): - self.detected.append(message) - continue - - def _parse_db(self, db_path: str) -> None: - """Parse an Android bugle_db SMS database file. - - :param db_path: Path to the Android SMS database file to process - - """ - conn = sqlite3.connect(db_path) - cur = conn.cursor() - - if self.sms_db_type == 1: - cur.execute(SMS_BUGLE_QUERY) - elif self.sms_db_type == 2: - cur.execute(SMS_MMSMS_QUERY) - - names = [description[0] for description in cur.description] - - for item in cur: - message = {} - for index, value in enumerate(item): - message[names[index]] = value - - message["direction"] = "received" if message["incoming"] == 1 else "sent" - message["isodate"] = convert_unix_to_iso(message["timestamp"]) - - # Extract links in the message body - body = message.get("body", None) - if body: - links = check_for_links(message["body"]) - message["links"] = links - - self.results.append(message) - - cur.close() - conn.close() - - self.log.info("Extracted a total of %d SMS messages", len(self.results)) - - def _extract_sms_adb(self) -> None: - """Use the Android backup command to extract SMS data from the native - SMS app. - - It is crucial to use the under-documented "-nocompress" flag to disable - the non-standard Java compression algorithm. This module only supports - an unencrypted ADB backup. - """ - backup_tar = self._generate_backup("com.android.providers.telephony") - if not backup_tar: - return - - try: - self.results = parse_tar_for_sms(backup_tar) - except AndroidBackupParsingError: - self.log.info( - "Impossible to read SMS from the Android Backup, " - "please extract the SMS and try extracting it with " - "Android Backup Extractor" - ) - return - - self.log.info("Extracted a total of %d SMS messages", len(self.results)) - - def run(self) -> None: - self._adb_connect() - - try: - if self._adb_check_file_exists(os.path.join("/", SMS_BUGLE_PATH)): - self.sms_db_type = 1 - self._adb_process_file( - os.path.join("/", SMS_BUGLE_PATH), self._parse_db - ) - elif self._adb_check_file_exists(os.path.join("/", SMS_MMSSMS_PATH)): - self.sms_db_type = 2 - self._adb_process_file( - os.path.join("/", SMS_MMSSMS_PATH), self._parse_db - ) - - self._adb_disconnect() - return - except InsufficientPrivileges: - pass - - self.log.info( - "No SMS database found. Trying extraction of SMS data " - "using Android backup feature." - ) - self._extract_sms_adb() - - self._adb_disconnect() diff --git a/src/mvt/android/modules/adb/whatsapp.py b/src/mvt/android/modules/adb/whatsapp.py deleted file mode 100644 index 28ee170..0000000 --- a/src/mvt/android/modules/adb/whatsapp.py +++ /dev/null @@ -1,113 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import base64 -import logging -import os -import sqlite3 -from typing import Optional, Union - -from mvt.common.utils import check_for_links, convert_unix_to_iso - -from .base import AndroidExtraction - -WHATSAPP_PATH = "data/data/com.whatsapp/databases/msgstore.db" - - -class Whatsapp(AndroidExtraction): - """This module extracts all WhatsApp messages containing links.""" - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - def serialize(self, record: dict) -> Union[dict, list]: - text = record["data"].replace("\n", "\\n") - return { - "timestamp": record["isodate"], - "module": self.__class__.__name__, - "event": f"whatsapp_msg_{record['direction']}", - "data": f'"{text}"', - } - - def check_indicators(self) -> None: - if not self.indicators: - return - - for message in self.results: - if "data" not in message: - continue - - message_links = check_for_links(message["data"]) - if self.indicators.check_urls(message_links): - self.detected.append(message) - continue - - def _parse_db(self, db_path: str) -> None: - """Parse an Android msgstore.db WhatsApp database file. - - :param db_path: Path to the Android WhatsApp database file to process - - """ - conn = sqlite3.connect(db_path) - cur = conn.cursor() - cur.execute( - """ - SELECT * FROM messages; - """ - ) - names = [description[0] for description in cur.description] - - messages = [] - for item in cur: - message = {} - for index, value in enumerate(item): - message[names[index]] = value - - if not message["data"]: - continue - - message["direction"] = "send" if message["key_from_me"] == 1 else "received" - message["isodate"] = convert_unix_to_iso(message["timestamp"]) - - # If we find links in the messages or if they are empty we add them - # to the list. - if check_for_links(message["data"]) or message["data"].strip() == "": - if message.get("thumb_image"): - message["thumb_image"] = base64.b64encode(message["thumb_image"]) - - messages.append(message) - - cur.close() - conn.close() - - self.log.info( - "Extracted a total of %d WhatsApp messages containing links", len(messages) - ) - self.results = messages - - def run(self) -> None: - self._adb_connect() - - try: - self._adb_process_file(os.path.join("/", WHATSAPP_PATH), self._parse_db) - except Exception as exc: - self.log.error(exc) - - self._adb_disconnect() diff --git a/src/mvt/android/modules/androidqf/__init__.py b/src/mvt/android/modules/androidqf/__init__.py index 9009f52..1d8d619 100644 --- a/src/mvt/android/modules/androidqf/__init__.py +++ b/src/mvt/android/modules/androidqf/__init__.py @@ -5,12 +5,12 @@ from .aqf_files import AQFFiles from .aqf_getprop import AQFGetProp +from .aqf_log_timestamps import AQFLogTimestamps from .aqf_packages import AQFPackages from .aqf_processes import AQFProcesses from .aqf_settings import AQFSettings from .mounts import Mounts from .root_binaries import RootBinaries -from .sms import SMS ANDROIDQF_MODULES = [ AQFPackages, @@ -18,7 +18,7 @@ ANDROIDQF_MODULES = [ AQFGetProp, AQFSettings, AQFFiles, - SMS, + AQFLogTimestamps, RootBinaries, Mounts, ] diff --git a/src/mvt/android/modules/androidqf/aqf_files.py b/src/mvt/android/modules/androidqf/aqf_files.py index 90eb3b8..de9b44a 100644 --- a/src/mvt/android/modules/androidqf/aqf_files.py +++ b/src/mvt/android/modules/androidqf/aqf_files.py @@ -10,10 +10,15 @@ import logging try: import zoneinfo except ImportError: - from backports import zoneinfo -from typing import Optional, Union + from backports import zoneinfo # type: ignore +from typing import Optional from mvt.android.modules.androidqf.base import AndroidQFModule +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_datetime_to_iso SUSPICIOUS_PATHS = [ @@ -36,7 +41,7 @@ class AQFFiles(AndroidQFModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -47,7 +52,7 @@ class AQFFiles(AndroidQFModule): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: records = [] for ts in set( @@ -82,10 +87,11 @@ class AQFFiles(AndroidQFModule): return for result in self.results: - ioc = self.indicators.check_file_path(result["path"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_file_path(result["path"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue # NOTE: Update with final path used for Android collector. @@ -98,22 +104,19 @@ class AQFFiles(AndroidQFModule): if self.file_is_executable(result["mode"]): file_type = "executable " - self.log.warning( - 'Found %sfile at suspicious path "%s".', - file_type, - result["path"], + msg = f'Found {file_type}file at suspicious path "{result["path"]}"' + self.alertstore.high(msg, "", result) + + for hash_key in ("sha256", "sha1", "md5"): + file_hash = result.get(hash_key, "") + if not file_hash: + continue + ioc_match = self.indicators.check_file_hash(file_hash) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc ) - self.detected.append(result) - - if result.get("sha256", "") == "": - continue - - ioc = self.indicators.check_file_hash(result["sha256"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) - - # TODO: adds SHA1 and MD5 when available in MVT + break def run(self) -> None: if timezone := self._get_device_timezone(): @@ -128,7 +131,7 @@ class AQFFiles(AndroidQFModule): data = json.loads(rawdata) except json.decoder.JSONDecodeError: data = [] - for line in rawdata.split("\n"): + for line in rawdata.splitlines(): if line.strip() == "": continue data.append(json.loads(line)) @@ -139,11 +142,11 @@ class AQFFiles(AndroidQFModule): utc_timestamp = datetime.datetime.fromtimestamp( file_data[ts], tz=datetime.timezone.utc ) - # Convert the UTC timestamp to local tiem on Android device's local timezone + # Convert the UTC timestamp to local time on Android device's local timezone local_timestamp = utc_timestamp.astimezone(device_timezone) - # HACK: We only output the UTC timestamp in convert_datetime_to_iso, we - # set the timestamp timezone to UTC, to avoid the timezone conversion again. + # Preserve the device-local wall-clock time while using + # the project-wide ISO conversion helper. local_timestamp = local_timestamp.replace( tzinfo=datetime.timezone.utc ) diff --git a/src/mvt/android/modules/androidqf/aqf_getprop.py b/src/mvt/android/modules/androidqf/aqf_getprop.py index 35514f8..938fb80 100644 --- a/src/mvt/android/modules/androidqf/aqf_getprop.py +++ b/src/mvt/android/modules/androidqf/aqf_getprop.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.android.artifacts.getprop import GetProp as GetPropArtifact +from mvt.common.module_types import ModuleResults from .base import AndroidQFModule @@ -21,7 +22,7 @@ class AQFGetProp(GetPropArtifact, AndroidQFModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -31,7 +32,7 @@ class AQFGetProp(GetPropArtifact, AndroidQFModule): log=log, results=results, ) - self.results = [] + self.results: list = [] if results is None else results def run(self) -> None: getprop_files = self._get_files_by_pattern("*/getprop.txt") diff --git a/src/mvt/android/modules/androidqf/aqf_log_timestamps.py b/src/mvt/android/modules/androidqf/aqf_log_timestamps.py index e5a1410..305d6be 100644 --- a/src/mvt/android/modules/androidqf/aqf_log_timestamps.py +++ b/src/mvt/android/modules/androidqf/aqf_log_timestamps.py @@ -3,14 +3,16 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ -import os import datetime import logging +import os from typing import Optional -from mvt.common.utils import convert_datetime_to_iso -from .base import AndroidQFModule from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact +from mvt.common.module_types import ModuleResults +from mvt.common.utils import convert_datetime_to_iso + +from .base import AndroidQFModule class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule): @@ -25,7 +27,7 @@ class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -36,11 +38,13 @@ class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule): results=results, ) - def _get_file_modification_time(self, file_path: str) -> dict: + def _get_file_modification_time(self, file_path: str) -> datetime.datetime: if self.archive: file_timetuple = self.archive.getinfo(file_path).date_time return datetime.datetime(*file_timetuple) else: + if not self.parent_path: + raise ValueError("parent_path is not set") file_stat = os.stat(os.path.join(self.parent_path, file_path)) return datetime.datetime.fromtimestamp(file_stat.st_mtime) diff --git a/src/mvt/android/modules/androidqf/aqf_packages.py b/src/mvt/android/modules/androidqf/aqf_packages.py index 500b3d4..294ad53 100644 --- a/src/mvt/android/modules/androidqf/aqf_packages.py +++ b/src/mvt/android/modules/androidqf/aqf_packages.py @@ -5,16 +5,21 @@ import json import logging +import time from typing import Optional +from rich.progress import track + from mvt.android.utils import ( BROWSER_INSTALLERS, PLAY_STORE_INSTALLERS, ROOT_PACKAGES, - THIRD_PARTY_STORE_INSTALLERS, SECURITY_PACKAGES, SYSTEM_UPDATE_PACKAGES, + THIRD_PARTY_STORE_INSTALLERS, ) +from mvt.common.module_types import ModuleAtomicResult, ModuleResults +from mvt.common.virustotal import VTNoKey, VTQuotaExceeded, virustotal_lookup from .base import AndroidQFModule @@ -29,7 +34,7 @@ class AQFPackages(AndroidQFModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -43,78 +48,145 @@ class AQFPackages(AndroidQFModule): def check_indicators(self) -> None: for result in self.results: if result["name"] in ROOT_PACKAGES: - self.log.warning( - 'Found an installed package related to rooting/jailbreaking: "%s"', - result["name"], + self.alertstore.medium( + f'Found an installed package related to rooting/jailbreaking: "{result["name"]}"', + "", + result, ) - self.detected.append(result) continue - # Detections for apps installed via unusual methods + # Detections for apps installed via unusual methods. if result["installer"] in THIRD_PARTY_STORE_INSTALLERS: - self.log.warning( - 'Found a package installed via a third party store (installer="%s"): "%s"', - result["installer"], - result["name"], + self.alertstore.info( + f'Found a package installed via a third party store (installer="{result["installer"]}"): "{result["name"]}"', + "", + result, ) elif result["installer"] in BROWSER_INSTALLERS: - self.log.warning( - 'Found a package installed via a browser (installer="%s"): "%s"', - result["installer"], - result["name"], + self.alertstore.medium( + f'Found a package installed via a browser (installer="{result["installer"]}"): "{result["name"]}"', + "", + result, ) - self.detected.append(result) elif result["installer"] == "null" and result["system"] is False: - self.log.warning( - 'Found a non-system package installed via adb or another method: "%s"', - result["name"], + self.alertstore.medium( + f'Found a non-system package installed via adb or another method: "{result["name"]}"', + "", + result, ) - self.detected.append(result) elif result["installer"] in PLAY_STORE_INSTALLERS: pass - # Check for disabled security or software update packages + # Check for disabled security or software update packages. package_disabled = result.get("disabled", None) if result["name"] in SECURITY_PACKAGES and package_disabled: - self.log.warning( - 'Security package "%s" disabled on the phone', result["name"] + self.alertstore.medium( + f'Security package "{result["name"]}" disabled on the phone', + "", + result, ) if result["name"] in SYSTEM_UPDATE_PACKAGES and package_disabled: - self.log.warning( - 'System OTA update package "%s" disabled on the phone', - result["name"], + self.alertstore.medium( + f'System OTA update package "{result["name"]}" disabled on the phone', + "", + result, ) if not self.indicators: continue - ioc = self.indicators.check_app_id(result.get("name")) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_app_id(result.get("name") or "") + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) for package_file in result.get("files", []): - ioc = self.indicators.check_file_hash(package_file["sha256"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_file_hash( + package_file.get("sha256") or "" + ) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) if "certificate" not in package_file: continue - # The keys generated by AndroidQF have a leading uppercase character + # The keys generated by AndroidQF have a leading uppercase character. for hash_type in ["Md5", "Sha1", "Sha256"]: certificate_hash = package_file["certificate"][hash_type] - ioc = self.indicators.check_app_certificate_hash(certificate_hash) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_app_certificate_hash( + certificate_hash + ) + if ioc_match: + self.alertstore.critical( + ioc_match.message, + "", + result, + matched_indicator=ioc_match.ioc, + ) break - # Deduplicate the detected packages - dedupe_detected_dict = {str(item): item for item in self.detected} - self.detected = list(dedupe_detected_dict.values()) + if self.module_options.get("virustotal", False): + self.check_virustotal( + delay=self.module_options.get("virustotal_delay", 0) + ) + + def check_virustotal(self, delay: int = 0) -> None: + files_by_hash: dict[ + str, list[tuple[ModuleAtomicResult, ModuleAtomicResult]] + ] = {} + for package in self.results: + if package.get("system", False): + continue + + for package_file in package.get("files", []): + file_hash = package_file.get("sha256") + if not file_hash: + continue + + files_by_hash.setdefault(file_hash, []).append((package, package_file)) + + total_hashes = len(files_by_hash) + if total_hashes == 0: + return + + progress_desc = f"Looking up {total_hashes} package files on VirusTotal..." + for index, file_hash in enumerate( + track(files_by_hash, description=progress_desc) + ): + try: + results = virustotal_lookup(file_hash) + except VTNoKey as exc: + self.log.warning("%s", exc) + return + except VTQuotaExceeded as exc: + self.log.warning("Unable to continue VirusTotal lookups: %s", exc) + break + + if index < total_hashes - 1 and delay > 0: + time.sleep(delay) + + if not results: + continue + + attributes = results.get("attributes", {}) + stats = attributes.get("last_analysis_stats", {}) + positives = stats.get("malicious", 0) + total = len(attributes.get("last_analysis_results", {})) + detection = f"{positives}/{total}" + + for package, package_file in files_by_hash[file_hash]: + package_file["virustotal"] = detection + if positives > 0: + self.alertstore.high( + f'VirusTotal flagged package "{package["name"]}" file ' + f'"{package_file["path"]}" with {detection} detections', + "", + package, + ) def run(self) -> None: packages = self._get_files_by_pattern("*/packages.json") diff --git a/src/mvt/android/modules/androidqf/aqf_processes.py b/src/mvt/android/modules/androidqf/aqf_processes.py index 3faabb4..b940e67 100644 --- a/src/mvt/android/modules/androidqf/aqf_processes.py +++ b/src/mvt/android/modules/androidqf/aqf_processes.py @@ -9,6 +9,7 @@ from typing import Optional from mvt.android.artifacts.processes import Processes as ProcessesArtifact from .base import AndroidQFModule +from mvt.common.module_types import ModuleResults class AQFProcesses(ProcessesArtifact, AndroidQFModule): @@ -21,7 +22,7 @@ class AQFProcesses(ProcessesArtifact, AndroidQFModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/android/modules/androidqf/aqf_settings.py b/src/mvt/android/modules/androidqf/aqf_settings.py index 46a70fb..8d5bb51 100644 --- a/src/mvt/android/modules/androidqf/aqf_settings.py +++ b/src/mvt/android/modules/androidqf/aqf_settings.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.android.artifacts.settings import Settings as SettingsArtifact +from mvt.common.module_types import ModuleResults from .base import AndroidQFModule @@ -21,7 +22,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -31,7 +32,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule): log=log, results=results, ) - self.results = {} + self.results: dict = results if results is not None else {} def run(self) -> None: for setting_file in self._get_files_by_pattern("*/settings_*.txt"): @@ -39,7 +40,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule): self.results[namespace] = {} data = self._get_file_content(setting_file) - for line in data.decode("utf-8").split("\n"): + for line in data.decode("utf-8").splitlines(): line = line.strip() try: key, value = line.split("=", 1) diff --git a/src/mvt/android/modules/androidqf/base.py b/src/mvt/android/modules/androidqf/base.py index 43e6210..b0304d0 100644 --- a/src/mvt/android/modules/androidqf/base.py +++ b/src/mvt/android/modules/androidqf/base.py @@ -7,9 +7,10 @@ import fnmatch import logging import os import zipfile -from typing import Any, Dict, List, Optional, Union +from typing import List, Optional from mvt.common.module import MVTModule +from mvt.common.module_types import ModuleResults class AndroidQFModule(MVTModule): @@ -22,7 +23,7 @@ class AndroidQFModule(MVTModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Union[List[Dict[str, Any]], Dict[str, Any], None] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -32,8 +33,8 @@ class AndroidQFModule(MVTModule): log=log, results=results, ) - self.parent_path = None - self._path: str = target_path + self.parent_path: Optional[str] = None + self._path: Optional[str] = target_path self.files: List[str] = [] self.archive: Optional[zipfile.ZipFile] = None diff --git a/src/mvt/android/modules/androidqf/mounts.py b/src/mvt/android/modules/androidqf/mounts.py index 1a5ba5c..ea446c2 100644 --- a/src/mvt/android/modules/androidqf/mounts.py +++ b/src/mvt/android/modules/androidqf/mounts.py @@ -3,8 +3,8 @@ # 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 json +import logging from typing import Optional from mvt.android.artifacts.mounts import Mounts as MountsArtifact @@ -32,7 +32,7 @@ class Mounts(MountsArtifact, AndroidQFModule): log=log, results=results, ) - self.results = [] + self.results: list = [] if results is None else results def run(self) -> None: """ @@ -66,6 +66,9 @@ class Mounts(MountsArtifact, AndroidQFModule): # AndroidQF format: array of strings like # "/dev/block/dm-12 on / type ext4 (ro,seclabel,noatime)" mount_content = "\n".join(json_data) + else: + self.log.error("Expected mounts.json to contain a list of mount lines") + return self.parse(mount_content) except Exception as exc: self.log.error("Failed to parse mount information: %s", exc) diff --git a/src/mvt/android/modules/androidqf/root_binaries.py b/src/mvt/android/modules/androidqf/root_binaries.py index c5df729..81e8173 100644 --- a/src/mvt/android/modules/androidqf/root_binaries.py +++ b/src/mvt/android/modules/androidqf/root_binaries.py @@ -46,17 +46,16 @@ class RootBinaries(AndroidQFModule): # All found root binaries are considered indicators of rooting for result in self.results: - self.log.warning( - 'Found root binary "%s" at path "%s"', - result["binary_name"], - result["path"], + self.alertstore.high( + f'Found root binary "{result["binary_name"]}" at path "{result["path"]}"', + "", + result, ) - self.detected.append(result) - if self.detected: + if self.results: self.log.warning( "Device shows signs of rooting with %d root binaries found", - len(self.detected), + len(self.results), ) def run(self) -> None: diff --git a/src/mvt/android/modules/androidqf/sms.py b/src/mvt/android/modules/androidqf/sms.py deleted file mode 100644 index 893e517..0000000 --- a/src/mvt/android/modules/androidqf/sms.py +++ /dev/null @@ -1,106 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1 - -import logging -from typing import Optional - -from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password -from mvt.android.parsers.backup import ( - AndroidBackupParsingError, - InvalidBackupPassword, - parse_ab_header, - parse_backup_file, - parse_tar_for_sms, -) - -from .base import AndroidQFModule - - -class SMS(AndroidQFModule): - """ - This module analyse SMS file in backup - - XXX: We should also de-duplicate this AQF module, but first we - need to add tests for loading encrypted SMS backups through the backup - sub-module. - """ - - def __init__( - self, - file_path: Optional[str] = None, - target_path: Optional[str] = None, - results_path: Optional[str] = None, - module_options: Optional[dict] = None, - log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, - ) -> None: - super().__init__( - file_path=file_path, - target_path=target_path, - results_path=results_path, - module_options=module_options, - log=log, - results=results, - ) - - def check_indicators(self) -> None: - if not self.indicators: - return - - for message in self.results: - if "body" not in message: - continue - - if self.indicators.check_domains(message.get("links", [])): - self.detected.append(message) - - 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") - return - - password = None - if header["encryption"] != "none": - password = prompt_or_load_android_backup_password( - self.log, self.module_options - ) - if not password: - self.log.critical("No backup password provided.") - return - - try: - tardata = parse_backup_file(data, password=password) - except InvalidBackupPassword: - self.log.critical("Invalid backup password") - return - except AndroidBackupParsingError: - self.log.critical( - "Impossible to parse this backup file, please use" - " Android Backup Extractor instead" - ) - return - - if not tardata: - return - - try: - self.results = parse_tar_for_sms(tardata) - except AndroidBackupParsingError: - self.log.info( - "Impossible to read SMS from the Android Backup, " - "please extract the SMS and try extracting it with " - "Android Backup Extractor" - ) - return - - def run(self) -> None: - files = self._get_files_by_pattern("*/backup.ab") - if not files: - self.log.info("No backup data found") - return - - self.parse_backup(self._get_file_content(files[0])) - self.log.info("Identified %d SMS in backup data", len(self.results)) diff --git a/src/mvt/android/modules/backup/base.py b/src/mvt/android/modules/backup/base.py index 29238ba..6383e4b 100644 --- a/src/mvt/android/modules/backup/base.py +++ b/src/mvt/android/modules/backup/base.py @@ -9,10 +9,10 @@ import os from tarfile import TarFile from typing import List, Optional -from mvt.common.module import MVTModule +from mvt.common.module import ModuleResults, MVTModule -class BackupExtraction(MVTModule): +class BackupModule(MVTModule): """This class provides a base for all backup extractios modules""" def __init__( @@ -22,7 +22,7 @@ class BackupExtraction(MVTModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -32,10 +32,10 @@ class BackupExtraction(MVTModule): log=log, results=results, ) - self.ab = None - self.backup_path = None - self.tar = None - self.files = [] + self.ab: Optional[str] = None + self.backup_path: Optional[str] = None + self.tar: Optional[TarFile] = None + self.files: list = [] def from_dir(self, backup_path: Optional[str], files: List[str]) -> None: self.backup_path = backup_path @@ -55,12 +55,15 @@ class BackupExtraction(MVTModule): return fnmatch.filter(self.files, pattern) def _get_file_content(self, file_path: str) -> bytes: + handle = None if self.tar: try: member = self.tar.getmember(file_path) + handle = self.tar.extractfile(member) + if not handle: + raise ValueError(f"Could not extract file: {file_path}") except KeyError: - return None - handle = self.tar.extractfile(member) + raise FileNotFoundError(f"File not found in tar: {file_path}") elif self.backup_path: handle = open(os.path.join(self.backup_path, file_path), "rb") else: diff --git a/src/mvt/android/modules/backup/helpers.py b/src/mvt/android/modules/backup/helpers.py index 3e48078..4c16778 100644 --- a/src/mvt/android/modules/backup/helpers.py +++ b/src/mvt/android/modules/backup/helpers.py @@ -4,9 +4,8 @@ # https://license.mvt.re/1.1/ -from rich.prompt import Prompt - from mvt.common.config import settings +from mvt.common.password import prompt_password MVT_ANDROID_BACKUP_PASSWORD = "MVT_ANDROID_BACKUP_PASSWORD" @@ -49,7 +48,7 @@ def prompt_or_load_android_backup_password(log, module_options): # The default is to allow interactivity elif module_options.get("interactive", True): - backup_password = Prompt.ask(prompt="Enter backup password", password=True) + backup_password = prompt_password("Enter backup password: ") else: log.critical( "Cannot decrypt backup because interactivity" diff --git a/src/mvt/android/modules/backup/sms.py b/src/mvt/android/modules/backup/sms.py index a194a1e..1c73ea2 100644 --- a/src/mvt/android/modules/backup/sms.py +++ b/src/mvt/android/modules/backup/sms.py @@ -4,14 +4,15 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional +from typing import Any, Optional -from mvt.android.modules.backup.base import BackupExtraction +from mvt.android.modules.backup.base import BackupModule from mvt.android.parsers.backup import parse_sms_file +from mvt.common.module_types import ModuleResults from mvt.common.utils import check_for_links -class SMS(BackupExtraction): +class SMS(BackupModule): def __init__( self, file_path: Optional[str] = None, @@ -19,7 +20,7 @@ class SMS(BackupExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -29,12 +30,14 @@ class SMS(BackupExtraction): log=log, results=results, ) - self.results = [] + self.results: list[dict[str, Any]] = [] def check_indicators(self) -> None: if not self.indicators: return + messages = [] + url_batches = [] for message in self.results: if "body" not in message: continue @@ -43,9 +46,21 @@ class SMS(BackupExtraction): if message_links == []: message_links = check_for_links(message.get("text", "")) - if self.indicators.check_urls(message_links): - self.detected.append(message) - continue + messages.append(message) + url_batches.append(message_links) + + for message, ioc_match in zip( + messages, self.indicators.check_url_batches(url_batches) + ): + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", message, matched_indicator=ioc_match.ioc + ) + + def collect_url_results(self) -> None: + for message in self.results: + for url in message.get("links", []): + self.add_url_result(url, message.get("isodate"), "sms") def run(self) -> None: sms_path = "apps/com.android.providers.telephony/d_f/*_sms_backup" diff --git a/src/mvt/android/modules/bugreport/base.py b/src/mvt/android/modules/bugreport/base.py index 367393b..156e01c 100644 --- a/src/mvt/android/modules/bugreport/base.py +++ b/src/mvt/android/modules/bugreport/base.py @@ -6,11 +6,11 @@ import datetime import fnmatch import logging import os - +from pathlib import Path from typing import List, Optional from zipfile import ZipFile -from mvt.common.module import MVTModule +from mvt.common.module import ModuleResults, MVTModule class BugReportModule(MVTModule): @@ -23,7 +23,7 @@ class BugReportModule(MVTModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -69,14 +69,19 @@ class BugReportModule(MVTModule): if self.zip_archive: handle = self.zip_archive.open(file_path) else: - handle = open(os.path.join(self.extract_path, file_path), "rb") + if not self.extract_path: + raise ValueError("extract_path is not set") + joined = os.path.join(self.extract_path, file_path) + if not Path(joined).resolve().is_relative_to(Path(self.extract_path).resolve()): + raise ValueError("unsafe file_path") + handle = open(joined, "rb") data = handle.read() handle.close() return data - def _get_dumpstate_file(self) -> bytes: + def _get_dumpstate_file(self) -> Optional[bytes]: main = self._get_files_by_pattern("main_entry.txt") if main: main_content = self._get_file_content(main[0]) @@ -95,10 +100,12 @@ class BugReportModule(MVTModule): return None - def _get_file_modification_time(self, file_path: str) -> dict: + def _get_file_modification_time(self, file_path: str) -> datetime.datetime: if self.zip_archive: file_timetuple = self.zip_archive.getinfo(file_path).date_time return datetime.datetime(*file_timetuple) else: + if not self.extract_path: + raise ValueError("extract_path is not set") file_stat = os.stat(os.path.join(self.extract_path, file_path)) return datetime.datetime.fromtimestamp(file_stat.st_mtime) diff --git a/src/mvt/android/modules/bugreport/dumpsys_accessibility.py b/src/mvt/android/modules/bugreport/dumpsys_accessibility.py index e141b2f..72208c0 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_accessibility.py +++ b/src/mvt/android/modules/bugreport/dumpsys_accessibility.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact +from mvt.common.module_types import ModuleResults from .base import BugReportModule @@ -21,7 +22,7 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/android/modules/bugreport/dumpsys_activities.py b/src/mvt/android/modules/bugreport/dumpsys_activities.py index a58c6f4..2800e95 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_activities.py +++ b/src/mvt/android/modules/bugreport/dumpsys_activities.py @@ -9,6 +9,7 @@ from typing import Optional from mvt.android.artifacts.dumpsys_package_activities import ( DumpsysPackageActivitiesArtifact, ) +from mvt.common.module_types import ModuleResults from .base import BugReportModule @@ -23,7 +24,7 @@ class DumpsysActivities(DumpsysPackageActivitiesArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/android/modules/bugreport/dumpsys_adb_state.py b/src/mvt/android/modules/bugreport/dumpsys_adb_state.py index ff74368..06b29d5 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_adb_state.py +++ b/src/mvt/android/modules/bugreport/dumpsys_adb_state.py @@ -3,10 +3,15 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import base64 +import binascii +import datetime import logging from typing import Optional from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact +from mvt.common.module_types import ModuleResults +from mvt.common.utils import convert_datetime_to_iso from .base import BugReportModule @@ -21,7 +26,7 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -52,3 +57,122 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule): "Identified a total of %d trusted ADB keys", len(self.results[0].get("user_keys", [])), ) + + @staticmethod + def _key_material(public_key: object) -> str: + if isinstance(public_key, bytes): + public_key = public_key.decode("utf-8", errors="replace") + if not isinstance(public_key, str): + return "" + return public_key.strip().split(" ", 1)[0] + + @staticmethod + def _is_valid_key(public_key: str) -> bool: + if not public_key: + return False + try: + return bool(base64.b64decode(public_key, validate=True)) + except (binascii.Error, ValueError): + return False + + @staticmethod + def _parse_acquisition_time(value: object) -> Optional[datetime.datetime]: + if not isinstance(value, str): + return None + try: + timestamp = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if not timestamp.tzinfo: + timestamp = timestamp.replace(tzinfo=datetime.timezone.utc) + return timestamp.astimezone(datetime.timezone.utc) + + @staticmethod + def _parse_last_connected(value: object) -> Optional[datetime.datetime]: + try: + return datetime.datetime.fromtimestamp( + int(str(value)) / 1000, + tz=datetime.timezone.utc, + ) + except (OSError, OverflowError, TypeError, ValueError): + return None + + def _trusted_keys(self) -> list[dict]: + """Return unique trusted keys, preferring keystore connection metadata.""" + trusted_keys = [] + seen = set() + for result in self.results: + keystore = result.get("keystore", []) + candidates = keystore if isinstance(keystore, list) else [] + candidates = [*candidates, *result.get("user_keys", [])] + for candidate in candidates: + if not isinstance(candidate, dict): + continue + key = self._key_material(candidate.get("key")) + identity = key or repr(candidate) + if identity in seen: + continue + seen.add(identity) + trusted_keys.append(candidate) + return trusted_keys + + def check_indicators(self) -> None: + if "androidqf_acquisition" not in self.module_options: + return super().check_indicators() + + context = self.module_options.get("androidqf_acquisition") + if not isinstance(context, dict): + context = {} + acquisition_key = self._key_material(context.get("adb_host_public_key")) + if acquisition_key and not self._is_valid_key(acquisition_key): + acquisition_key = "" + acquisition_time = self._parse_acquisition_time(context.get("started")) + cutoff = ( + acquisition_time - datetime.timedelta(days=1) if acquisition_time else None + ) + + for trusted_key in self._trusted_keys(): + key = self._key_material(trusted_key.get("key")) + fingerprint = trusted_key.get("fingerprint") or "" + user = trusted_key.get("user") or "unknown user" + description = f"{fingerprint} ({user})" + last_connected = self._parse_last_connected( + trusted_key.get("last_connected") + ) + event_time = ( + convert_datetime_to_iso(last_connected) if last_connected else "" + ) + + if not self._is_valid_key(key): + self.alertstore.low( + f"Found an invalid trusted ADB host key: {description}", + event_time, + trusted_key, + ) + continue + + if not acquisition_key: + self.alertstore.low( + "Found a trusted ADB host key, but the AndroidQF acquisition " + f"does not include its host key: {description}", + event_time, + trusted_key, + ) + continue + + if key != acquisition_key: + self.alertstore.low( + "Found a trusted ADB host key different from the AndroidQF " + f"acquisition host: {description}", + event_time, + trusted_key, + ) + continue + + if cutoff and last_connected and last_connected <= cutoff: + self.alertstore.info( + "Found a trusted ADB host key last connected at least one day " + f"before the AndroidQF acquisition: {description}", + event_time, + trusted_key, + ) diff --git a/src/mvt/android/modules/bugreport/dumpsys_appops.py b/src/mvt/android/modules/bugreport/dumpsys_appops.py index 96b4796..91122cb 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_appops.py +++ b/src/mvt/android/modules/bugreport/dumpsys_appops.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.android.artifacts.dumpsys_appops import DumpsysAppopsArtifact +from mvt.common.module_types import ModuleResults from .base import BugReportModule @@ -21,7 +22,7 @@ class DumpsysAppops(DumpsysAppopsArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/android/modules/bugreport/dumpsys_battery_daily.py b/src/mvt/android/modules/bugreport/dumpsys_battery_daily.py index 7fc8329..a7c0c72 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_battery_daily.py +++ b/src/mvt/android/modules/bugreport/dumpsys_battery_daily.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.android.artifacts.dumpsys_battery_daily import DumpsysBatteryDailyArtifact +from mvt.common.module_types import ModuleResults from .base import BugReportModule @@ -21,7 +22,7 @@ class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/android/modules/bugreport/dumpsys_battery_history.py b/src/mvt/android/modules/bugreport/dumpsys_battery_history.py index 729f801..42d395d 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_battery_history.py +++ b/src/mvt/android/modules/bugreport/dumpsys_battery_history.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.android.artifacts.dumpsys_battery_history import DumpsysBatteryHistoryArtifact +from mvt.common.module_types import ModuleResults from .base import BugReportModule @@ -21,7 +22,7 @@ class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/android/modules/bugreport/dumpsys_dbinfo.py b/src/mvt/android/modules/bugreport/dumpsys_dbinfo.py index 73902bb..13ba8b3 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_dbinfo.py +++ b/src/mvt/android/modules/bugreport/dumpsys_dbinfo.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.android.artifacts.dumpsys_dbinfo import DumpsysDBInfoArtifact +from mvt.common.module_types import ModuleResults from .base import BugReportModule @@ -23,7 +24,7 @@ class DumpsysDBInfo(DumpsysDBInfoArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/android/modules/bugreport/dumpsys_getprop.py b/src/mvt/android/modules/bugreport/dumpsys_getprop.py index acec15c..198a1d2 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_getprop.py +++ b/src/mvt/android/modules/bugreport/dumpsys_getprop.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.android.artifacts.getprop import GetProp as GetPropArtifact +from mvt.common.module_types import ModuleResults from .base import BugReportModule @@ -21,7 +22,7 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/android/modules/bugreport/dumpsys_packages.py b/src/mvt/android/modules/bugreport/dumpsys_packages.py index fccf102..aebec4c 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_packages.py +++ b/src/mvt/android/modules/bugreport/dumpsys_packages.py @@ -8,6 +8,7 @@ from typing import Optional from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact from mvt.android.utils import DANGEROUS_PERMISSIONS, DANGEROUS_PERMISSIONS_THRESHOLD +from mvt.common.module_types import ModuleResults from .base import BugReportModule @@ -22,7 +23,7 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -42,8 +43,9 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule): ) return - data = data.decode("utf-8", errors="replace") - content = self.extract_dumpsys_section(data, "DUMP OF SERVICE package:") + content = self.extract_dumpsys_section( + data.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:" + ) self.parse(content) for result in self.results: diff --git a/src/mvt/android/modules/bugreport/dumpsys_platform_compat.py b/src/mvt/android/modules/bugreport/dumpsys_platform_compat.py index e9d10e6..968bc25 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_platform_compat.py +++ b/src/mvt/android/modules/bugreport/dumpsys_platform_compat.py @@ -9,6 +9,7 @@ from typing import Optional from mvt.android.artifacts.dumpsys_platform_compat import DumpsysPlatformCompatArtifact from mvt.android.modules.bugreport.base import BugReportModule +from mvt.common.module_types import ModuleResults class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule): @@ -21,7 +22,7 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -41,8 +42,10 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule): ) return - data = data.decode("utf-8", errors="replace") - content = self.extract_dumpsys_section(data, "DUMP OF SERVICE platform_compat:") + decoded_data = data.decode("utf-8", errors="replace") + content = self.extract_dumpsys_section( + decoded_data, "DUMP OF SERVICE platform_compat:" + ) self.parse(content) self.log.info("Found %d uninstalled apps", len(self.results)) diff --git a/src/mvt/android/modules/bugreport/dumpsys_receivers.py b/src/mvt/android/modules/bugreport/dumpsys_receivers.py index a16bc3b..8907d5e 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_receivers.py +++ b/src/mvt/android/modules/bugreport/dumpsys_receivers.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.android.artifacts.dumpsys_receivers import DumpsysReceiversArtifact +from mvt.common.module_types import ModuleResults from .base import BugReportModule @@ -21,7 +22,7 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -34,20 +35,6 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule): self.results = results if results else {} - def check_indicators(self) -> None: - for result in self.results: - if self.indicators: - receiver_name = self.results[result][0]["receiver"] - - # return IoC if the stix2 process name a substring of the receiver name - ioc = self.indicators.check_receiver_prefix(receiver_name) - if ioc: - self.results[result][0]["matched_indicator"] = ioc - self.detected.append(result) - continue - - - def run(self) -> None: content = self._get_dumpstate_file() if not content: diff --git a/src/mvt/android/modules/bugreport/fs_timestamps.py b/src/mvt/android/modules/bugreport/fs_timestamps.py index 14e1cd1..5a2ca48 100644 --- a/src/mvt/android/modules/bugreport/fs_timestamps.py +++ b/src/mvt/android/modules/bugreport/fs_timestamps.py @@ -8,6 +8,7 @@ from typing import Optional from mvt.common.utils import convert_datetime_to_iso from .base import BugReportModule +from mvt.common.module_types import ModuleResults from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact @@ -23,7 +24,7 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/android/modules/bugreport/tombstones.py b/src/mvt/android/modules/bugreport/tombstones.py index 6447e61..c4a7afb 100644 --- a/src/mvt/android/modules/bugreport/tombstones.py +++ b/src/mvt/android/modules/bugreport/tombstones.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact +from mvt.common.module_types import ModuleResults from .base import BugReportModule @@ -22,7 +23,7 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/android/modules/intrusion_logs/__init__.py b/src/mvt/android/modules/intrusion_logs/__init__.py new file mode 100644 index 0000000..f8be973 --- /dev/null +++ b/src/mvt/android/modules/intrusion_logs/__init__.py @@ -0,0 +1,20 @@ +# 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 .connect_event import ConnectEvent +from .dns_event import DnsEvent +from .security_event import SecurityEvent + +INTRUSION_LOGS_MODULES = [ + DnsEvent, + ConnectEvent, + SecurityEvent, +] + +KNOWN_INTRUSION_LOG_EVENT_TYPES = { + "connect_event", + "dns_event", + "security_event", +} diff --git a/src/mvt/android/modules/intrusion_logs/base.py b/src/mvt/android/modules/intrusion_logs/base.py new file mode 100644 index 0000000..4aac618 --- /dev/null +++ b/src/mvt/android/modules/intrusion_logs/base.py @@ -0,0 +1,395 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import datetime +import io +import json +import logging +import zipfile +from pathlib import Path +from typing import Optional, Union + +try: + import zoneinfo +except ImportError: + from backports import zoneinfo # type: ignore[no-redef] + +from mvt.common.module import MVTModule +from mvt.common.utils import convert_datetime_to_iso, convert_unix_to_iso + + +class IntrusionLogsModule(MVTModule): + """Base class for modules analyzing intrusion logs (newline-delimited JSON). + + Performance note + ---------------- + Log files can be large and are shared by every module in this package. + To avoid re-reading and re-parsing the same files N times (once per + module), the command layer should call :meth:`load_all_events` exactly + once and then assign the returned dict to the ``il_events_by_type`` + attribute of every module instance **before** calling ``run_module``. + + When ``il_events_by_type`` is populated: + * :meth:`collect_txt` becomes a no-op (no disk I/O). + * :meth:`parse_collected_txt` iterates the in-memory list for the + requested event type instead of re-parsing raw text. + + Modules that are used standalone (e.g. in tests) still work as before + because ``il_events_by_type`` defaults to ``None``, which preserves the + original file-loading code path. + """ + + def __init__( + self, + file_path: Optional[str] = None, + target_path: Optional[str] = None, + results_path: Optional[str] = None, + module_options: Optional[dict] = None, + log: logging.Logger = logging.getLogger(__name__), + results: Optional[list] = None, + ) -> None: + super().__init__( + file_path=file_path, + target_path=target_path, + results_path=results_path, + module_options=module_options, + log=log, + results=results, + ) + # Raw file content collected by collect_txt (fallback path only). + self.il_files: list[tuple[str, str]] = [] + + # Pre-parsed events injected by the command layer. + # Keys are event-type strings (e.g. "dns_event"), values are lists of + # raw event-data dicts exactly as they appear in the JSON lines. + # When this is not None, collect_txt and parse_collected_txt use it + # instead of touching the file system. + self.il_events_by_type: Optional[dict[str, list[dict]]] = None + + # ------------------------------------------------------------------ + # Serialization helper + # ------------------------------------------------------------------ + + def serialize(self, record: dict) -> Union[dict, list]: + """Serialize a record for timeline output.""" + return { + "timestamp": record.get("timestamp", record.get("isodate")), + "module": self.__class__.__name__, + "event": record.get("event_type", ""), + "data": str(record), + } + + # ------------------------------------------------------------------ + # File collection + # ------------------------------------------------------------------ + + def collect_txt(self, source) -> None: + """Collect text log files from *source* into ``self.il_files``. + + Entry points: + * directory → walk recursively + * zip file → walk zip entries + * anything else → silently skip + + If ``self.il_events_by_type`` has already been populated (i.e. the + command layer pre-loaded the events), this method returns immediately + without any disk I/O. + """ + if self.il_events_by_type is not None: + self.log.debug( + "Pre-loaded events available — skipping file collection for %s", + self.__class__.__name__, + ) + return + + path = Path(source) + + if path.is_dir(): + self._walk_directory(path) + return + + if path.is_file() and path.suffix.lower() == ".zip": + try: + with zipfile.ZipFile(path) as z: + self._walk_zip(z) + except zipfile.BadZipFile: + self.log.debug("Skipping invalid zip: %s", path) + return + + self.log.debug("Skipping unsupported source: %s", source) + + def _walk_directory(self, root: Path, prefix: str = "") -> None: + for item in root.iterdir(): + if item.is_dir(): + self._walk_directory(item, prefix=f"{prefix}{item.name}/") + continue + + if item.suffix.lower() == ".txt": + self.il_files.append( + (f"{prefix}{item.name}", item.read_text(errors="ignore")) + ) + + elif item.suffix.lower() == ".zip": + try: + with zipfile.ZipFile(item) as z: + self._walk_zip(z, prefix=f"{prefix}{item.name}::") + except zipfile.BadZipFile: + self.log.warning("Skipping invalid zip: %s", item) + + def _walk_zip(self, zf: zipfile.ZipFile, prefix: str = "") -> None: + for info in zf.infolist(): + if info.is_dir(): + continue + + name = info.filename + with zf.open(info) as f: + data = f.read() + + if name.lower().endswith(".txt"): + self.il_files.append((f"{prefix}{name}", data.decode(errors="ignore"))) + + elif name.lower().endswith(".zip"): + with zipfile.ZipFile(io.BytesIO(data)) as inner: + self._walk_zip(inner, prefix=f"{prefix}{name}::") + + # ------------------------------------------------------------------ + # Single-pass loader (used by the command layer) + # ------------------------------------------------------------------ + + def load_all_events(self, source) -> dict[str, list[dict]]: + """Read every log file under *source* **once** and parse all JSON + lines in a single pass, routing events into per-type buckets. + + Returns a ``dict`` mapping *event_type* strings to lists of raw + event-data dicts. The result is also stored in + ``self.il_events_by_type`` so that subsequent calls to + :meth:`collect_txt` and :meth:`parse_collected_txt` on *this* + instance are no-ops. + + Intended usage in the command layer:: + + loader = IntrusionLogsModule(target_path=target, log=log) + all_events = loader.load_all_events(target) + + for module_cls in INTRUSION_LOGS_MODULES: + m = module_cls(target_path=target, ...) + m.il_events_by_type = all_events # inject — no re-reading + run_module(m) + """ + # Reset so that _collect_txt actually runs (il_events_by_type is None). + self.il_events_by_type = None + self.il_files = [] + self.collect_txt(source) + + events_by_type: dict[str, list[dict]] = {} + # JSON fingerprints used to drop events that appear in more than one + # log file (overlapping daily files are the most common source of + # cross-file duplicates). + seen_fingerprints: set[str] = set() + total_lines = 0 + skipped_lines = 0 + duplicate_lines = 0 + + for file_name, text in self.il_files: + for line_num, line in enumerate(text.splitlines(), start=1): + line = line.strip() + if not line: + continue + + total_lines += 1 + try: + entry = json.loads(line) + for event_type, event_data in entry.items(): + if isinstance(event_data, dict): + fingerprint = json.dumps(event_data, sort_keys=True) + if fingerprint in seen_fingerprints: + duplicate_lines += 1 + continue + seen_fingerprints.add(fingerprint) + events_by_type.setdefault(event_type, []).append(event_data) + except json.JSONDecodeError as e: + skipped_lines += 1 + self.log.warning( + "Failed to parse JSON on line %d in %s: %s", + line_num, + file_name, + e, + ) + except Exception as e: + skipped_lines += 1 + self.log.warning( + "Error processing line %d in %s: %s", + line_num, + file_name, + e, + ) + + if duplicate_lines: + self.log.info( + "Removed %d duplicate event(s) seen across multiple log files", + duplicate_lines, + ) + + self.log.info( + "Loaded %d log files, parsed %d lines (%d skipped), found event types: %s", + len(self.il_files), + total_lines, + skipped_lines, + {k: len(v) for k, v in events_by_type.items()}, + ) + + # Cache so this instance also benefits from the fast path. + self.il_events_by_type = events_by_type + return events_by_type + + # ------------------------------------------------------------------ + # Parsing + # ------------------------------------------------------------------ + + def parse_collected_txt(self, event_type: str) -> None: + """Parse collected log text and dispatch events of *event_type*. + + Fast path + ~~~~~~~~~ + When ``self.il_events_by_type`` is populated (injected by the command + layer after a single shared :meth:`load_all_events` call), the method + iterates the already-parsed in-memory list for *event_type* — no + re-reading, no re-parsing of JSON. + + Fallback path + ~~~~~~~~~~~~~ + When ``self.il_events_by_type`` is ``None``, the method falls back to + iterating ``self.il_files`` and parsing each JSON line, which is the + original behaviour. + """ + if self.il_events_by_type is not None: + events = self.il_events_by_type.get(event_type, []) + self.log.debug( + "Using pre-loaded events: dispatching %d '%s' events", + len(events), + event_type, + ) + for event_data in events: + try: + # Work on a shallow copy so that mutations in one module + # (e.g. adding "timestamp") do not affect other modules + # that share the same dict reference. + self.process_event(dict(event_data)) + except Exception as e: + self.log.warning( + "Error processing pre-parsed '%s' event: %s", + event_type, + e, + ) + return + + # Fallback: parse raw text collected by collect_txt(). + # Use the same JSON-fingerprint approach as MVTModule._deduplicate_timeline + # to drop events that appear verbatim in more than one log file. + seen_fingerprints: set[str] = set() + duplicate_count = 0 + for file_name, text in self.il_files: + for line_num, line in enumerate(text.splitlines(), start=1): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + if event_type in entry: + event_data = entry[event_type] + fingerprint = json.dumps(event_data, sort_keys=True) + if fingerprint in seen_fingerprints: + duplicate_count += 1 + continue + seen_fingerprints.add(fingerprint) + event_data["event_type"] = event_type + self.process_event(event_data) + except json.JSONDecodeError as e: + self.log.warning( + "Failed to parse JSON on line %d in %s: %s", + line_num, + file_name, + str(e), + ) + except Exception as e: + self.log.warning( + "Error processing line %d in %s: %s", + line_num, + file_name, + str(e), + ) + if duplicate_count: + self.log.info( + "Removed %d duplicate '%s' event(s) seen across multiple log files", + duplicate_count, + event_type, + ) + + # ------------------------------------------------------------------ + # Event processing + # ------------------------------------------------------------------ + + def process_event(self, event_data: dict) -> None: + """Process an individual event. Override this in subclasses. + + Args: + event_data: Dictionary containing the event data. + """ + self.results.append(event_data) + + # ------------------------------------------------------------------ + # Timestamp localisation + # ------------------------------------------------------------------ + + def _localize_timestamp(self, event_time_seconds: float) -> str: + """Convert a Unix timestamp (in seconds) to an ISO string. + + When the device timezone is available via ``module_options["device_timezone"]`` + (a IANA timezone name such as ``"Europe/Paris"`` read from + ``persist.sys.timezone`` in ``getprop.txt``), the UTC instant is + converted to the device's local time before formatting — mirroring the + approach used by ``AQFFiles``. + + When no timezone is configured the method falls back to UTC, which is + consistent with all other MVT modules that call ``convert_unix_to_iso``. + + Args: + event_time_seconds: Unix epoch timestamp expressed in **seconds** + (callers are responsible for dividing ms/ns values first). + + Returns: + ISO-formatted datetime string (``YYYY-mm-dd HH:MM:SS.ffffff``). + The string always represents the device-local time (or UTC when no + timezone is known); no UTC offset suffix is appended, matching the + format produced by :func:`mvt.common.utils.convert_unix_to_iso`. + """ + tz_name: Optional[str] = self.module_options.get("device_timezone") + if tz_name: + try: + device_tz = zoneinfo.ZoneInfo(tz_name) + utc_dt = datetime.datetime.fromtimestamp( + event_time_seconds, tz=datetime.timezone.utc + ) + local_dt = utc_dt.astimezone(device_tz) + # Strip tzinfo so that convert_datetime_to_iso outputs the + # local wall-clock time without a timezone suffix. This is + # the same pattern used by AQFFiles. + return convert_datetime_to_iso(local_dt.replace(tzinfo=None)) + except Exception as e: + self.log.warning( + "Could not apply device timezone '%s', falling back to UTC: %s", + tz_name, + e, + ) + + return convert_unix_to_iso(event_time_seconds) + + # ------------------------------------------------------------------ + # Abstract interface + # ------------------------------------------------------------------ + + def run(self) -> None: + """Main execution method. Must be implemented by subclasses.""" + raise NotImplementedError("Subclasses must implement the run() method") diff --git a/src/mvt/android/modules/intrusion_logs/connect_event.py b/src/mvt/android/modules/intrusion_logs/connect_event.py new file mode 100644 index 0000000..054dcb1 --- /dev/null +++ b/src/mvt/android/modules/intrusion_logs/connect_event.py @@ -0,0 +1,121 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import logging +from typing import Optional, Union + +from .base import IntrusionLogsModule + + +class ConnectEvent(IntrusionLogsModule): + """This module analyzes network connection events from intrusion logs.""" + + def __init__( + self, + file_path: Optional[str] = None, + target_path: Optional[str] = None, + results_path: Optional[str] = None, + module_options: Optional[dict] = None, + log: logging.Logger = logging.getLogger(__name__), + results: Optional[list] = None, + ) -> None: + super().__init__( + file_path=file_path, + target_path=target_path, + results_path=results_path, + module_options=module_options, + log=log, + results=results, + ) + + def check_indicators(self) -> None: + """Check connection events against indicators of compromise.""" + if not self.indicators: + return + + for result in self.results: + # Check IP address against indicators + ip_address = result.get("ip_address", "") + if ip_address: + # Clean IP address (remove leading slash and extract IP from format like "ip6-localhost/::1") + if "/" in ip_address: + parts = ip_address.split("/") + clean_ip = parts[-1] if len(parts) > 1 else parts[0] + else: + clean_ip = ip_address.lstrip("/") + + # Skip localhost addresses + if clean_ip and clean_ip not in ["::1", "127.0.0.1", "0.0.0.0"]: + ioc = self.indicators.check_domain(clean_ip) + if ioc: + result["matched_ip"] = clean_ip + self.alertstore.critical( + ioc.message, + result.get("timestamp") or "", + result, + matched_indicator=ioc.ioc, + ) + + # Check package name against app identifiers + package_name = result.get("package_name", "") + if package_name: + ioc = self.indicators.check_app_id(package_name) + if ioc: + self.alertstore.critical( + ioc.message, + result.get("timestamp") or "", + result, + matched_indicator=ioc.ioc, + ) + + def serialize(self, record: dict) -> Union[dict, list]: + """Serialize a connection event record for timeline output.""" + ip_address = record.get("ip_address", "") + port = record.get("port", 0) + package_name = record.get("package_name", "") + matched_ip = record.get("matched_ip", "") + + # Clean IP address for display + if "/" in ip_address: + parts = ip_address.split("/") + clean_ip = parts[-1] if len(parts) > 1 else parts[0] + else: + clean_ip = ip_address.lstrip("/") + + # Indicate when IP matched an IoC + if matched_ip: + data = f"Connection to {clean_ip}:{port} by {package_name} [Matched IP: {matched_ip}]" + else: + data = f"Connection to {clean_ip}:{port} by {package_name}" + + return { + "timestamp": record.get("timestamp"), + "module": self.__class__.__name__, + "event": "network_connection", + "data": data, + } + + def process_event(self, event_data: dict) -> None: + """Process a connection event and add it to results.""" + # Convert event_time from milliseconds to ISO format + event_time = event_data.get("event_time") + if event_time: + # Android event times are in milliseconds since epoch + event_data["timestamp"] = self._localize_timestamp(event_time / 1000.0) + else: + event_data["timestamp"] = None + + self.results.append(event_data) + + def run(self) -> None: + """Extract and analyze connection events from intrusion logs.""" + if not self.target_path: + self.log.error("No target path specified") + return + + self.collect_txt(self.target_path) + self.parse_collected_txt("connect_event") + + self.log.info("Identified %d connection events", len(self.results)) diff --git a/src/mvt/android/modules/intrusion_logs/dns_event.py b/src/mvt/android/modules/intrusion_logs/dns_event.py new file mode 100644 index 0000000..242ae7b --- /dev/null +++ b/src/mvt/android/modules/intrusion_logs/dns_event.py @@ -0,0 +1,141 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import logging +from typing import Optional, Union + +from .base import IntrusionLogsModule + + +class DnsEvent(IntrusionLogsModule): + """This module analyzes DNS events from intrusion logs.""" + + def __init__( + self, + file_path: Optional[str] = None, + target_path: Optional[str] = None, + results_path: Optional[str] = None, + module_options: Optional[dict] = None, + log: logging.Logger = logging.getLogger(__name__), + results: Optional[list] = None, + ) -> None: + super().__init__( + file_path=file_path, + target_path=target_path, + results_path=results_path, + module_options=module_options, + log=log, + results=results, + ) + + def check_indicators(self) -> None: + """Check DNS events against indicators of compromise.""" + if not self.indicators: + return + + for result in self.results: + # Check hostname against domain indicators + hostname = result.get("hostname", "") + if hostname: + ioc = self.indicators.check_domain(hostname) + if ioc: + self.alertstore.critical( + ioc.message, + result.get("timestamp") or "", + result, + matched_indicator=ioc.ioc, + ) + + # Check IP addresses against indicators + ip_addresses = result.get("ip_addresses", []) + matched_ips = [] + for ip_addr in ip_addresses: + # Remove leading slash if present + clean_ip = ( + ip_addr.lstrip("/") if isinstance(ip_addr, str) else str(ip_addr) + ) + if clean_ip and clean_ip != "0.0.0.0": + ioc = self.indicators.check_domain(clean_ip) + if ioc: + matched_ips.append(clean_ip) + self.alertstore.critical( + ioc.message, + result.get("timestamp") or "", + result, + matched_indicator=ioc.ioc, + ) + + # Store matched IPs for timeline display + if matched_ips: + result["matched_ips"] = matched_ips + + # Check package name against app identifiers + package_name = result.get("package_name", "") + if package_name: + ioc = self.indicators.check_app_id(package_name) + if ioc: + self.alertstore.critical( + ioc.message, + result.get("timestamp") or "", + result, + matched_indicator=ioc.ioc, + ) + + def serialize(self, record: dict) -> Union[dict, list]: + """Serialize a DNS event record for timeline output.""" + hostname = record.get("hostname", "") + package_name = record.get("package_name", "") + + # Get IP addresses for display + ip_addresses = record.get("ip_addresses", []) + matched_ips = record.get("matched_ips", []) + + # Clean up IP addresses (remove leading slashes) + clean_ips = [] + for ip_addr in ip_addresses: + clean_ip = ip_addr.lstrip("/") if isinstance(ip_addr, str) else str(ip_addr) + if clean_ip and clean_ip != "0.0.0.0": + clean_ips.append(clean_ip) + + # Build the data string with actual IPs + if matched_ips: + # Highlight matched IPs in the output + ip_display = ", ".join(matched_ips) + data = f"DNS query for {hostname} by {package_name} [Matched IPs: {ip_display}]" + elif clean_ips: + ip_display = ", ".join(clean_ips) + data = f"DNS query for {hostname} by {package_name} [IPs: {ip_display}]" + else: + data = f"DNS query for {hostname} by {package_name}" + + return { + "timestamp": record.get("timestamp"), + "module": self.__class__.__name__, + "event": "dns_query", + "data": data, + } + + def process_event(self, event_data: dict) -> None: + """Process a DNS event and add it to results.""" + # Convert event_time from milliseconds to ISO format + event_time = event_data.get("event_time") + if event_time: + # Android event times are in milliseconds since epoch + event_data["timestamp"] = self._localize_timestamp(event_time / 1000.0) + else: + event_data["timestamp"] = None + + self.results.append(event_data) + + def run(self) -> None: + """Extract and analyze DNS events from intrusion logs.""" + if not self.target_path: + self.log.error("No target path specified") + return + + self.collect_txt(self.target_path) + self.parse_collected_txt("dns_event") + + self.log.info("Identified %d DNS events", len(self.results)) diff --git a/src/mvt/android/modules/intrusion_logs/security_event.py b/src/mvt/android/modules/intrusion_logs/security_event.py new file mode 100644 index 0000000..c2dea5d --- /dev/null +++ b/src/mvt/android/modules/intrusion_logs/security_event.py @@ -0,0 +1,789 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import logging +from typing import Optional, Union + +from .base import IntrusionLogsModule + +# Security event tags based on Android SecurityLog API +# Reference: https://developer.android.com/reference/android/app/admin/SecurityLog +SECURITY_EVENT_TAGS = { + # ADB events (API level 24) + "adb_shell_interactive": { + "tag_id": 210001, + "name": "ADB Shell Interactive", + "description": "An ADB interactive shell was opened via 'adb shell'", + }, + "adb_shell_cmd": { + "tag_id": 210002, + "name": "ADB Shell Command", + "description": "A shell command was issued over ADB via 'adb shell '", + }, + "adb_sync_recv_file": { + "tag_id": 210003, + "name": "ADB Sync Recv File", + "description": "A file was pulled from the device via adb daemon (adb pull)", + }, + "adb_sync_send_file": { + "tag_id": 210004, + "name": "ADB Sync Send File", + "description": "A file was pushed to the device via adb daemon (adb push)", + }, + # App process events (API level 24) + "app_process_start": { + "tag_id": 210005, + "name": "App Process Start", + "description": "An app process was started", + }, + # Keyguard events (API level 24) + "keyguard_dismissed": { + "tag_id": 210006, + "name": "Keyguard Dismissed", + "description": "Keyguard has been dismissed", + }, + "keyguard_dismiss_auth_attempt": { + "tag_id": 210007, + "name": "Keyguard Dismiss Auth Attempt", + "description": "Authentication attempt to dismiss keyguard", + }, + "keyguard_secured": { + "tag_id": 210008, + "name": "Keyguard Secured", + "description": "Device has been locked", + }, + # OS events (API level 28) + "os_startup": { + "tag_id": 210009, + "name": "OS Startup", + "description": "Android OS has started", + }, + "os_shutdown": { + "tag_id": 210010, + "name": "OS Shutdown", + "description": "Android OS has shutdown", + }, + # Logging events (API level 28) + "logging_started": { + "tag_id": 210011, + "name": "Logging Started", + "description": "Audit logging has started", + }, + "logging_stopped": { + "tag_id": 210012, + "name": "Logging Stopped", + "description": "Audit logging has stopped", + }, + # Media events (API level 28) + "media_mount": { + "tag_id": 210013, + "name": "Media Mount", + "description": "Removable media has been mounted", + }, + "media_unmount": { + "tag_id": 210014, + "name": "Media Unmount", + "description": "Removable media was unmounted", + }, + # Log buffer event (API level 28) + "log_buffer_size_critical": { + "tag_id": 210015, + "name": "Log Buffer Size Critical", + "description": "Audit log buffer has reached 90% capacity", + }, + # Password policy events (API level 28) + "password_expiration_set": { + "tag_id": 210016, + "name": "Password Expiration Set", + "description": "Admin set password expiration timeout", + }, + "password_complexity_set": { + "tag_id": 210017, + "name": "Password Complexity Set", + "description": "Admin set password complexity requirement", + }, + "password_history_length_set": { + "tag_id": 210018, + "name": "Password History Length Set", + "description": "Admin set password history length", + }, + "max_screen_lock_timeout_set": { + "tag_id": 210019, + "name": "Max Screen Lock Timeout Set", + "description": "Admin set maximum screen lock timeout", + }, + "max_password_attempts_set": { + "tag_id": 210020, + "name": "Max Password Attempts Set", + "description": "Admin set maximum failed password attempts before wipe", + }, + "keyguard_disabled_features_set": { + "tag_id": 210021, + "name": "Keyguard Disabled Features Set", + "description": "Admin set disabled keyguard features", + }, + # Remote lock event (API level 28) + "remote_lock": { + "tag_id": 210022, + "name": "Remote Lock", + "description": "Admin remotely locked the device or profile", + }, + # Wipe failure event (API level 28) + "wipe_failure": { + "tag_id": 210023, + "name": "Wipe Failure", + "description": "Failed to wipe device or user data", + }, + # Cryptographic key events (API level 28) + "key_generated": { + "tag_id": 210024, + "name": "Key Generated", + "description": "Cryptographic key was generated", + }, + "key_import": { + "tag_id": 210025, + "name": "Key Import", + "description": "Cryptographic key was imported", + }, + "key_destruction": { + "tag_id": 210026, + "name": "Key Destruction", + "description": "Cryptographic key was destroyed", + }, + # User restriction events (API level 28) + "user_restriction_added": { + "tag_id": 210027, + "name": "User Restriction Added", + "description": "Admin added a user restriction", + }, + "user_restriction_removed": { + "tag_id": 210028, + "name": "User Restriction Removed", + "description": "Admin removed a user restriction", + }, + # Certificate events (API level 28) + "cert_authority_installed": { + "tag_id": 210029, + "name": "Certificate Authority Installed", + "description": "Root certificate installed to trusted storage", + }, + "cert_authority_removed": { + "tag_id": 210030, + "name": "Certificate Authority Removed", + "description": "Root certificate removed from trusted storage", + }, + "crypto_self_test_completed": { + "tag_id": 210031, + "name": "Crypto Self Test Completed", + "description": "Cryptographic functionality self test completed", + }, + "key_integrity_violation": { + "tag_id": 210032, + "name": "Key Integrity Violation", + "description": "Key integrity violation detected", + }, + "cert_validation_failure": { + "tag_id": 210033, + "name": "Certificate Validation Failure", + "description": "X.509v3 certificate validation failed", + }, + # Camera policy event (API level 30) + "camera_policy_set": { + "tag_id": 210034, + "name": "Camera Policy Set", + "description": "Admin set policy to disable camera", + }, + # Password complexity events (API level 31/33) + "password_complexity_required": { + "tag_id": 210035, + "name": "Password Complexity Required", + "description": "Admin set password complexity requirement using predefined levels", + }, + "password_changed": { + "tag_id": 210036, + "name": "Password Changed", + "description": "User changed their lockscreen password", + }, + # WiFi events (API level 33) + "wifi_connection": { + "tag_id": 210037, + "name": "WiFi Connection", + "description": "Device attempted to connect to a managed WiFi network", + }, + "wifi_disconnection": { + "tag_id": 210038, + "name": "WiFi Disconnection", + "description": "Device disconnected from a managed WiFi network", + }, + # Bluetooth events (API level 33) + "bluetooth_connection": { + "tag_id": 210039, + "name": "Bluetooth Connection", + "description": "Device attempted to connect to a Bluetooth device", + }, + "bluetooth_disconnection": { + "tag_id": 210040, + "name": "Bluetooth Disconnection", + "description": "Device disconnected from a Bluetooth device", + }, + # Package events (API level 34) + "package_installed": { + "tag_id": 210041, + "name": "Package Installed", + "description": "Application package was installed", + }, + "package_updated": { + "tag_id": 210042, + "name": "Package Updated", + "description": "Application package was updated", + }, + "package_uninstalled": { + "tag_id": 210043, + "name": "Package Uninstalled", + "description": "Application package was uninstalled", + }, + # Backup service event (API level 35) + "backup_service_toggled": { + "tag_id": 210044, + "name": "Backup Service Toggled", + "description": "Admin enabled or disabled backup service", + }, + # NFC events (API level 36) + "nfc_enabled": { + "tag_id": 210045, + "name": "NFC Enabled", + "description": "NFC service is enabled", + }, + "nfc_disabled": { + "tag_id": 210046, + "name": "NFC Disabled", + "description": "NFC service is disabled", + }, +} + +SECURITY_EVENT_METADATA_KEYS = { + "event_id", + "event_time", + "event_type", + "timestamp", +} + +# Known platform failures that remain in the timeline but do not need a warning. +KNOWN_BENIGN_KEY_GENERATION_FAILURES = { + ("PinStorage_crossReboot_key", 1001), +} + + +class SecurityEvent(IntrusionLogsModule): + """This module analyzes security events from intrusion logs.""" + + def __init__( + self, + file_path: Optional[str] = None, + target_path: Optional[str] = None, + results_path: Optional[str] = None, + module_options: Optional[dict] = None, + log: logging.Logger = logging.getLogger(__name__), + results: Optional[list] = None, + ) -> None: + super().__init__( + file_path=file_path, + target_path=target_path, + results_path=results_path, + module_options=module_options, + log=log, + results=results, + ) + self.event_type_counts: dict[str, int] = {} + + def _get_event_tag(self, event_data: dict) -> Optional[str]: + """Return the security-event tag key, including tags unknown to MVT.""" + for key in event_data: + if key not in SECURITY_EVENT_METADATA_KEYS: + return key + + return None + + def check_indicators(self) -> None: + """Check security events against indicators of compromise.""" + 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"] + process_name = process_info.get("process", "") + if process_name: + # Check the full process name + ioc = self.indicators.check_app_id(process_name) + if ioc: + self.alertstore.critical( + ioc.message, + result.get("timestamp") or "", + result, + matched_indicator=ioc.ioc, + ) + + # Also check process components after the first colon + # Example: "com.google.android.webview:sandboxed_process0:org.chromium.content.app.SandboxedProcessService0:0" + # We want to check "sandboxed_process0" and subsequent components + if ":" in process_name: + components = process_name.split(":") + for component in components[ + 1: + ]: # Skip the first component (main package name) + if component: + ioc = self.indicators.check_app_id(component) + if ioc: + self.alertstore.critical( + ioc.message, + result.get("timestamp") or "", + result, + matched_indicator=ioc.ioc, + ) + break + + # Check package operations for suspicious packages + for pkg_event in [ + "package_installed", + "package_updated", + "package_uninstalled", + ]: + if pkg_event in result: + pkg_info = result[pkg_event] + pkg_name = pkg_info.get("package_name", "") + if pkg_name: + ioc = self.indicators.check_app_id(pkg_name) + if ioc: + self.alertstore.critical( + ioc.message, + result.get("timestamp") or "", + result, + matched_indicator=ioc.ioc, + ) + + # Check ADB shell commands for suspicious patterns + if "adb_shell_cmd" in result: + cmd_info = result["adb_shell_cmd"] + command = cmd_info.get("command", "") + if command: + # Check if command contains any suspicious app IDs + ioc = self.indicators.check_app_id(command) + if ioc: + self.alertstore.critical( + ioc.message, + result.get("timestamp") or "", + result, + matched_indicator=ioc.ioc, + ) + + # Check ADB file sync events for suspicious paths + for adb_event in ["adb_sync_recv_file", "adb_sync_send_file"]: + if adb_event in result: + file_info = result[adb_event] + file_path = file_info.get("path", "") + if file_path: + ioc = self.indicators.check_file_path(file_path) + if ioc: + self.alertstore.critical( + ioc.message, + result.get("timestamp") or "", + result, + matched_indicator=ioc.ioc, + ) + + 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: + key_info = result["key_generated"] + key_id = key_info.get("key_id", "unknown") + uid = key_info.get("uid") + is_known_benign_failure = ( + key_id, + uid, + ) in KNOWN_BENIGN_KEY_GENERATION_FAILURES + if not key_info.get("success", True) and not is_known_benign_failure: + self.log.warning( + "Failed key generation detected for key_id: %s", + key_id, + ) + + # 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( + "Certificate authority installed: " + f"{cert_info.get('subject', 'unknown')}", + result.get("timestamp") or "", + result, + ) + else: + self.log.warning( + "Failed certificate authority install attempt: %s", + cert_info.get("subject", "unknown"), + ) + + # 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( + "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 + event_subtype = None + event_data_str = "" + + event_subtype = self._get_event_tag(record) + if event_subtype: + event_info = record[event_subtype] + + if event_subtype in SECURITY_EVENT_TAGS: + # ADB events + if event_subtype == "adb_shell_interactive": + event_data_str = "ADB interactive shell opened" + elif event_subtype == "adb_shell_cmd": + command = event_info.get("command", "") + event_data_str = f"ADB shell command: {command}" + elif event_subtype == "adb_sync_recv_file": + path = event_info.get("path", "") + event_data_str = f"File pulled via ADB: {path}" + elif event_subtype == "adb_sync_send_file": + path = event_info.get("path", "") + event_data_str = f"File pushed via ADB: {path}" + + # App process events + elif event_subtype == "app_process_start": + process_name = event_info.get("process", "") + uid = event_info.get("uid", "") + pid = event_info.get("pid", "") + event_data_str = ( + f"Process started: {process_name} (UID: {uid}, PID: {pid})" + ) + + # Keyguard events + elif event_subtype == "keyguard_dismiss_auth_attempt": + success = event_info.get("success", False) + method = event_info.get("method_strength", 0) + event_data_str = f"Auth attempt: {'Success' if success else 'Failed'} (method strength: {method})" + elif event_subtype == "keyguard_dismissed": + event_data_str = "Keyguard dismissed" + elif event_subtype == "keyguard_secured": + event_data_str = "Device locked" + elif event_subtype == "keyguard_disabled_features_set": + admin = event_info.get("admin_package", "") + features = event_info.get("disabled_features", "") + event_data_str = ( + f"Keyguard features disabled by {admin}: {features}" + ) + + # Key events + elif event_subtype == "key_generated": + success = event_info.get("success", False) + key_id = event_info.get("key_id", "unknown") + uid = event_info.get("uid", "") + event_data_str = f"Key {'generated' if success else 'generation failed'}: {key_id} (UID: {uid})" + elif event_subtype == "key_destruction": + success = event_info.get("success", False) + key_id = event_info.get("key_id", "unknown") + uid = event_info.get("uid", "") + event_data_str = f"Key {'destroyed' if success else 'destruction failed'}: {key_id} (UID: {uid})" + elif event_subtype == "key_import": + success = event_info.get("success", False) + key_id = event_info.get("key_id", "unknown") + event_data_str = ( + f"Key {'imported' if success else 'import failed'}: {key_id}" + ) + elif event_subtype == "key_integrity_violation": + key_id = event_info.get("key_id", "unknown") + event_data_str = f"Key integrity violation: {key_id}" + + # Certificate events + elif event_subtype == "cert_authority_installed": + success = event_info.get("success", False) + subject = event_info.get("subject", "unknown") + event_data_str = f"Cert {'installed' if success else 'install failed'}: {subject}" + elif event_subtype == "cert_authority_removed": + success = event_info.get("success", False) + subject = event_info.get("subject", "unknown") + event_data_str = ( + f"Cert {'removed' if success else 'removal failed'}: {subject}" + ) + elif event_subtype == "cert_validation_failure": + reason = ( + event_info if isinstance(event_info, str) else str(event_info) + ) + event_data_str = f"Certificate validation failure: {reason}" + elif event_subtype == "crypto_self_test_completed": + if isinstance(event_info, dict): + success = event_info.get("success", False) + else: + success = event_info == 1 + event_data_str = ( + f"Crypto self test: {'passed' if success else 'FAILED'}" + ) + + # Package events + elif event_subtype in [ + "package_installed", + "package_updated", + "package_uninstalled", + ]: + pkg_name = event_info.get("package_name", "") + version = event_info.get("version_code", "") + user_id = event_info.get("user_id", "") + action = event_subtype.replace("package_", "").title() + event_data_str = ( + f"Package {action}: {pkg_name} (v{version}, user: {user_id})" + ) + + # OS events + elif event_subtype == "os_startup": + verified_boot = event_info.get("verified_boot_state", "") + dm_verity = event_info.get("dm_verity_mode", "") + event_data_str = f"OS startup (verified boot: {verified_boot}, dm-verity: {dm_verity})" + elif event_subtype == "os_shutdown": + event_data_str = "OS shutdown" + + # Logging events + elif event_subtype == "logging_started": + event_data_str = "Audit logging started" + elif event_subtype == "logging_stopped": + event_data_str = "Audit logging stopped" + elif event_subtype == "log_buffer_size_critical": + event_data_str = "Log buffer at 90% capacity" + + # Media events + elif event_subtype == "media_mount": + mount_point = event_info.get("mount_point", "") + label = event_info.get("volume_label", "") + event_data_str = f"Media mounted: {mount_point} ({label})" + elif event_subtype == "media_unmount": + mount_point = event_info.get("mount_point", "") + label = event_info.get("volume_label", "") + event_data_str = f"Media unmounted: {mount_point} ({label})" + + # Password policy events + elif event_subtype == "password_expiration_set": + admin = event_info.get("admin_package", "") + timeout = event_info.get("timeout_ms", "") + event_data_str = f"Password expiration set by {admin}: {timeout}ms" + elif event_subtype == "password_complexity_set": + admin = event_info.get("admin_package", "") + event_data_str = f"Password complexity set by {admin}" + elif event_subtype == "password_complexity_required": + admin = event_info.get("admin_package", "") + complexity = event_info.get("complexity", "") + event_data_str = ( + f"Password complexity required by {admin}: {complexity}" + ) + elif event_subtype == "password_history_length_set": + admin = event_info.get("admin_package", "") + length = event_info.get("length", "") + event_data_str = f"Password history length set by {admin}: {length}" + elif event_subtype == "password_changed": + complexity = event_info.get("complexity", "") + user_id = event_info.get("user_id", "") + event_data_str = ( + f"Password changed (complexity: {complexity}, user: {user_id})" + ) + elif event_subtype == "max_screen_lock_timeout_set": + admin = event_info.get("admin_package", "") + timeout = event_info.get("timeout_ms", "") + event_data_str = ( + f"Max screen lock timeout set by {admin}: {timeout}ms" + ) + elif event_subtype == "max_password_attempts_set": + admin = event_info.get("admin_package", "") + attempts = event_info.get("max_attempts", "") + event_data_str = f"Max password attempts set by {admin}: {attempts}" + + # Remote lock and wipe events + elif event_subtype == "remote_lock": + admin = event_info.get("admin_package", "") + event_data_str = f"Device remotely locked by {admin}" + elif event_subtype == "wipe_failure": + event_data_str = "Device wipe failed" + + # User restriction events + elif event_subtype == "user_restriction_added": + admin = event_info.get("admin_package", "") + restriction = event_info.get("restriction", "") + event_data_str = f"User restriction added by {admin}: {restriction}" + elif event_subtype == "user_restriction_removed": + admin = event_info.get("admin_package", "") + restriction = event_info.get("restriction", "") + event_data_str = ( + f"User restriction removed by {admin}: {restriction}" + ) + + # WiFi events + elif event_subtype == "wifi_connection": + bssid = event_info.get("bssid", "") + event_type = event_info.get("event_type", "") + reason = event_info.get("reason", "") + event_data_str = f"WiFi connection: {event_type} (BSSID: {bssid})" + if reason: + event_data_str += f" - {reason}" + elif event_subtype == "wifi_disconnection": + bssid = event_info.get("bssid", "") + reason = event_info.get("reason", "") + event_data_str = f"WiFi disconnection (BSSID: {bssid})" + if reason: + event_data_str += f" - {reason}" + + # Bluetooth events + elif event_subtype == "bluetooth_connection": + mac = event_info.get("mac_address", "") + success = event_info.get("success", False) + reason = event_info.get("reason", "") + event_data_str = f"Bluetooth {'connected' if success else 'connection failed'}: {mac}" + if reason: + event_data_str += f" - {reason}" + elif event_subtype == "bluetooth_disconnection": + mac = event_info.get("mac_address", "") + reason = event_info.get("reason", "") + event_data_str = f"Bluetooth disconnected: {mac}" + if reason: + event_data_str += f" - {reason}" + + # Camera policy event + elif event_subtype == "camera_policy_set": + admin = event_info.get("admin_package", "") + disabled = event_info.get("disabled", False) + event_data_str = ( + f"Camera {'disabled' if disabled else 'enabled'} by {admin}" + ) + + # Backup service event + elif event_subtype == "backup_service_toggled": + admin = event_info.get("admin_package", "") + enabled = event_info.get("enabled", False) + event_data_str = f"Backup service {'enabled' if enabled else 'disabled'} by {admin}" + + # NFC events + elif event_subtype == "nfc_enabled": + event_data_str = "NFC enabled" + elif event_subtype == "nfc_disabled": + event_data_str = "NFC disabled" + + else: + event_data_str = ( + f"{SECURITY_EVENT_TAGS.get(event_subtype, {}).get('name', event_subtype)}: " + f"{event_info}" + ) + else: + event_data_str = f"{event_subtype}: {event_info}" + + if not event_subtype: + event_subtype = "unknown" + event_data_str = str(record) + + return { + "timestamp": record.get("timestamp"), + "module": self.__class__.__name__, + "event": event_subtype, + "data": event_data_str, + } + + def process_event(self, event_data: dict) -> None: + """Process a security event and add it to results.""" + # Convert event_time to ISO format + # Security events use nanoseconds since epoch + event_time = event_data.get("event_time") + if event_time: + # Convert nanoseconds to seconds + event_data["timestamp"] = self._localize_timestamp( + event_time / 1_000_000_000.0 + ) + else: + event_data["timestamp"] = None + + # Track event type statistics, including future tags unknown to MVT. + event_tag = self._get_event_tag(event_data) + if event_tag: + self.event_type_counts[event_tag] = ( + self.event_type_counts.get(event_tag, 0) + 1 + ) + + self.results.append(event_data) + + def run(self) -> None: + """Extract and analyze security events from intrusion logs.""" + if not self.target_path: + self.log.error("No target path specified") + return + + self.collect_txt(self.target_path) + self.parse_collected_txt("security_event") + + self.log.info("Identified %d security events", len(self.results)) + + # Log event type breakdown + if self.event_type_counts: + self.log.info("Security event breakdown:") + for event_type, count in sorted( + self.event_type_counts.items(), key=lambda x: x[1], reverse=True + ): + event_name = SECURITY_EVENT_TAGS.get(event_type, {}).get( + "name", event_type + ) + self.log.info(" - %s: %d", event_name, count) + + unknown_event_types = sorted( + event_type + for event_type in self.event_type_counts + if event_type not in SECURITY_EVENT_TAGS + ) + if unknown_event_types: + self.log.warning( + "Found unknown intrusion logging security event type(s): %s. " + "Please open an issue on GitHub so MVT can add support for them.", + ", ".join(unknown_event_types), + ) diff --git a/src/mvt/android/parsers/backup.py b/src/mvt/android/parsers/backup.py index 105b4f2..1f8da9b 100644 --- a/src/mvt/android/parsers/backup.py +++ b/src/mvt/android/parsers/backup.py @@ -29,9 +29,6 @@ class InvalidBackupPassword(AndroidBackupParsingError): pass -# TODO: Need to clean all the following code and conform it to the coding style. - - def to_utf8_bytes(input_bytes): output = [] for byte in input_bytes: @@ -51,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} @@ -131,20 +131,23 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers if password is None: raise InvalidBackupPassword() - [ - user_salt, - checksum_salt, - pbkdf2_rounds, - user_iv, - master_key_blob, - encrypted_data, - ] = encrypted_backup.split(b"\n", 5) + try: + [ + user_salt, + checksum_salt, + pbkdf2_rounds, + user_iv, + master_key_blob, + encrypted_data, + ] = encrypted_backup.split(b"\n", 5) - user_salt = bytes.fromhex(user_salt.decode("utf-8")) - checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8")) - pbkdf2_rounds = int(pbkdf2_rounds) - user_iv = bytes.fromhex(user_iv.decode("utf-8")) - master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8")) + user_salt = bytes.fromhex(user_salt.decode("utf-8")) + checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8")) + pbkdf2_rounds = int(pbkdf2_rounds) + user_iv = bytes.fromhex(user_iv.decode("utf-8")) + master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8")) + except (UnicodeDecodeError, ValueError) as exc: + raise AndroidBackupParsingError("Invalid encrypted backup header") from exc # Derive decryption master key from password. master_key, master_iv = decrypt_master_key( @@ -157,13 +160,13 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers checksum_salt=checksum_salt, ) - # Decrypt and unpad backup data using derivied key. + # Decrypt and unpad backup data using derived key. cipher = Cipher(algorithms.AES(master_key), modes.CBC(master_iv)) decryptor = cipher.decryptor() decrypted_tar = decryptor.update(encrypted_data) + decryptor.finalize() unpadder = padding.PKCS7(128).unpadder() - return unpadder.update(decrypted_tar) + return unpadder.update(decrypted_tar) + unpadder.finalize() def parse_backup_file(data, password=None): @@ -174,10 +177,12 @@ def parse_backup_file(data, password=None): if not data.startswith(b"ANDROID BACKUP"): raise AndroidBackupParsingError("Invalid file header") - [_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4) - - version = int(version) - is_compressed = int(is_compressed) + try: + [_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4) + version = int(version) + is_compressed = int(is_compressed) + except ValueError as exc: + raise AndroidBackupParsingError("Invalid file header") from exc if encryption_algo != b"none": tar_data = decrypt_backup_data( @@ -210,6 +215,8 @@ def parse_tar_for_sms(data): or member.name.endswith("_mms_backup") ): dhandler = tar.extractfile(member) + if not dhandler: + continue res.extend(parse_sms_file(dhandler.read())) return res diff --git a/src/mvt/android/utils.py b/src/mvt/android/utils.py index 2455959..5032ccf 100644 --- a/src/mvt/android/utils.py +++ b/src/mvt/android/utils.py @@ -6,16 +6,15 @@ from datetime import datetime, timedelta from typing import List -def warn_android_patch_level(patch_level: str, log) -> bool: +def warn_android_patch_level(patch_level: str, log) -> str | bool: """Alert if Android patch level out-of-date""" patch_date = datetime.strptime(patch_level, "%Y-%m-%d") if (datetime.now() - patch_date) > timedelta(days=6 * 31): - log.warning( - "This phone has not received security updates " - "for more than six months (last update: %s)", - patch_level, + warning_message = ( + f"This phone has not received security updates " + f"for more than six months (last update: {patch_level})." ) - return True + return warning_message return False diff --git a/src/mvt/common/alerts.py b/src/mvt/common/alerts.py new file mode 100644 index 0000000..540325e --- /dev/null +++ b/src/mvt/common/alerts.py @@ -0,0 +1,250 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2025 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 csv +import inspect +import logging +from dataclasses import asdict, dataclass +from enum import Enum +from typing import Any, Dict, List, Optional + +from .log import CRITICAL_ALERT, HIGH_ALERT, INFO_ALERT, LOW_ALERT, MEDIUM_ALERT +from .module_types import ModuleAtomicResult + + +class AlertLevel(Enum): + INFORMATIONAL = 0 + LOW = 10 + MEDIUM = 20 + HIGH = 30 + CRITICAL = 40 + + +@dataclass +class Alert: + level: AlertLevel + module: str + message: str + event_time: str + event: ModuleAtomicResult + matched_indicator: Optional[Any] = None + + +class AlertStore: + def __init__(self, log: Optional[logging.Logger] = None) -> None: + self.__alerts: List[Alert] = [] + self.__log = log + + def _get_calling_module(self) -> str: + """ + Automatically detect the calling MVT module and return its slug. + + Walks up the call stack to find the first frame that belongs to an MVT module + (artifact or extraction module) and extracts its slug. + + :return: Module slug string + """ + frame = inspect.currentframe() + try: + # Walk up the call stack + while frame is not None: + frame = frame.f_back + if frame is None: + break + + # Get the 'self' object from the frame's local variables + frame_locals = frame.f_locals + if "self" in frame_locals: + obj = frame_locals["self"] + # Check if it has a get_slug method (MVT modules have this) + if hasattr(obj, "get_slug") and callable(obj.get_slug): + try: + return str(obj.get_slug()) + except Exception: + pass + + # Fallback: return "unknown" if we can't find the module + return "unknown" + finally: + del frame + + @property + def alerts(self) -> List[Alert]: + return self.__alerts + + def add(self, alert: Alert) -> None: + self.__alerts.append(alert) + self.log(alert) + + def extend(self, alerts: List[Alert]) -> None: + for alert in alerts: + self.add(alert) + + def info( + self, + message: str, + event_time: str, + event: ModuleAtomicResult, + matched_indicator: Optional[Any] = None, + ): + self.add( + Alert( + level=AlertLevel.INFORMATIONAL, + module=self._get_calling_module(), + message=message, + event_time=event_time, + event=event, + matched_indicator=matched_indicator, + ) + ) + + def low( + self, + message: str, + event_time: str, + event: ModuleAtomicResult, + matched_indicator: Optional[Any] = None, + ): + self.add( + Alert( + level=AlertLevel.LOW, + module=self._get_calling_module(), + message=message, + event_time=event_time, + event=event, + matched_indicator=matched_indicator, + ) + ) + + def medium( + self, + message: str, + event_time: str, + event: ModuleAtomicResult, + matched_indicator: Optional[Any] = None, + ): + self.add( + Alert( + level=AlertLevel.MEDIUM, + module=self._get_calling_module(), + message=message, + event_time=event_time, + event=event, + matched_indicator=matched_indicator, + ) + ) + + def high( + self, + message: str, + event_time: str, + event: ModuleAtomicResult, + matched_indicator: Optional[Any] = None, + ): + self.add( + Alert( + level=AlertLevel.HIGH, + module=self._get_calling_module(), + message=message, + event_time=event_time, + event=event, + matched_indicator=matched_indicator, + ) + ) + + def critical( + self, + message: str, + event_time: str, + event: ModuleAtomicResult, + matched_indicator: Optional[Any] = None, + ): + self.add( + Alert( + level=AlertLevel.CRITICAL, + module=self._get_calling_module(), + message=message, + event_time=event_time, + event=event, + matched_indicator=matched_indicator, + ) + ) + + def log(self, alert: Alert) -> None: + if not self.__log: + return + + if not alert.message: + return + + if alert.level == AlertLevel.INFORMATIONAL: + self.__log.log(INFO_ALERT, alert.message) + elif alert.level == AlertLevel.LOW: + self.__log.log(LOW_ALERT, alert.message) + elif alert.level == AlertLevel.MEDIUM: + self.__log.log(MEDIUM_ALERT, alert.message) + elif alert.level == AlertLevel.HIGH: + self.__log.log(HIGH_ALERT, alert.message) + elif alert.level == AlertLevel.CRITICAL: + self.__log.log(CRITICAL_ALERT, alert.message) + + def log_latest(self) -> None: + self.log(self.__alerts[-1]) + + def count(self, level: AlertLevel) -> int: + count = 0 + for alert in self.__alerts: + if alert.level == level: + count += 1 + + return count + + def as_json(self) -> List[Dict[str, Any]]: + alerts = [] + for alert in self.__alerts: + alert_dict = asdict(alert) + # This is required because an Enum is not JSON serializable. + alert_dict["level"] = alert.level.name + if isinstance(alert_dict.get("event"), dict): + event_matched_indicator = alert_dict["event"].pop( + "matched_indicator", None + ) + if alert_dict["matched_indicator"] is None: + alert_dict["matched_indicator"] = event_matched_indicator + + alerts.append(alert_dict) + + return alerts + + def save_timeline(self, timeline_path: str) -> None: + with open(timeline_path, "w", encoding="utf-8") as handle: + csvoutput = csv.writer( + handle, + delimiter=",", + quotechar='"', + quoting=csv.QUOTE_ALL, + escapechar="\\", + ) + csvoutput.writerow(["Event Time", "Module", "Message", "Event"]) + + timed_alerts = [] + for alert in self.alerts: + if not alert.event_time: + continue + + timed_alerts.append(asdict(alert)) + + for event in sorted( + timed_alerts, + key=lambda x: x["event_time"] if x["event_time"] is not None else "", + ): + csvoutput.writerow( + [ + event.get("event_time"), + event.get("module"), + event.get("message"), + event.get("event"), + ] + ) diff --git a/src/mvt/common/artifact.py b/src/mvt/common/artifact.py index 7cc0682..8d7b60d 100644 --- a/src/mvt/common/artifact.py +++ b/src/mvt/common/artifact.py @@ -2,27 +2,12 @@ # 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 .module import MVTModule -class Artifact: +class Artifact(MVTModule): + """Base class for artifacts. + + Artifacts share the MVTModule lifecycle so commands can run artifacts and + extraction modules through the same interface. """ - Main artifact class - """ - - def __init__(self, *args, **kwargs): - self.results = [] - self.detected = [] - self.indicators = None - super().__init__(*args, **kwargs) - - def parse(self, entry: str): - """ - Parse the artifact, adds the parsed information to self.results - """ - raise NotImplementedError - - def check_indicators(self) -> None: - """Check the results of this module against a provided list of - indicators coming from self.indicators - """ - raise NotImplementedError diff --git a/src/mvt/common/cli_plugins.py b/src/mvt/common/cli_plugins.py new file mode 100644 index 0000000..acf4c6d --- /dev/null +++ b/src/mvt/common/cli_plugins.py @@ -0,0 +1,297 @@ +# 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.metadata +import importlib.util +import logging +import os +import sys +from pathlib import Path +from types import ModuleType +from typing import Iterable + +import click + +IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins" +ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins" +MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS" +MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS" + +log = logging.getLogger(__name__) + + +class CustomCommandLoadError(Exception): + pass + + +class BrokenPluginCommand(click.Command): + """A placeholder for an installed or configured command that failed to load.""" + + def __init__(self, name: str, source: str, exception: BaseException): + super().__init__( + name, + help=( + f"Unable to load external command from {source}.\n\n" + f"{type(exception).__name__}: {exception}" + ), + short_help="Warning: external command could not be loaded.", + ) + self.source = source + self.exception = exception + + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + return args + + def invoke(self, ctx: click.Context) -> None: + raise click.ClickException( + f"Unable to load external command '{self.name}' from {self.source}: " + f"{type(self.exception).__name__}: {self.exception}" + ) + + +def _module_name_for_path(path: Path) -> str: + digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16] + return f"_mvt_custom_command_{path.stem}_{digest}" + + +def _iter_command_files(path: Path) -> Iterable[Path]: + if path.is_file(): + if path.suffix != ".py": + raise CustomCommandLoadError( + f"Custom command file is not a Python file: {path}" + ) + yield path + return + + if path.is_dir(): + for child in sorted(path.iterdir()): + if child.name.startswith(".") or child.name == "__init__.py": + continue + if child.is_file() and child.suffix == ".py": + yield child + return + + raise CustomCommandLoadError(f"Custom command 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 CustomCommandLoadError(f"Unable to load custom command file: {path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except (Exception, SystemExit) as exc: + raise CustomCommandLoadError( + f"Unable to import custom command {path}: {exc}" + ) from exc + + return module + + +def load_cli_command_file(path: Path) -> click.Command: + module = _load_python_file(path) + command = getattr(module, "cli", None) + if not isinstance(command, click.Command): + raise CustomCommandLoadError( + f"Custom command {path} must export a Click command or group named 'cli'" + ) + if command.name is None: + raise CustomCommandLoadError(f"Custom command {path} has no command name") + return command + + +def _register_command( + group: click.Group, + command: click.Command, + *, + name: str, + source: str, + collision_is_error: bool, +) -> bool: + if name in group.commands: + registered_sources = getattr(group, "_mvt_external_command_sources", {}) + if registered_sources.get(name) == source: + return False + message = ( + f"Unable to register external command '{name}' from {source}: " + "the command name is already registered" + ) + if collision_is_error: + raise CustomCommandLoadError(message) + log.warning(message) + return False + + group.add_command(command, name=name) + registered_sources = getattr(group, "_mvt_external_command_sources", {}) + registered_sources[name] = source + setattr(group, "_mvt_external_command_sources", registered_sources) + return True + + +def register_cli_commands_from_path( + group: click.Group, + path: str | Path, + *, + collision_is_error: bool = False, + failures_are_errors: bool = False, +) -> list[str]: + resolved_path = Path(path).expanduser().resolve() + registered: list[str] = [] + + try: + command_files = list(_iter_command_files(resolved_path)) + except CustomCommandLoadError as exc: + if failures_are_errors: + raise + name = resolved_path.stem.replace("_", "-") or "custom-command" + if _register_command( + group, + BrokenPluginCommand(name, str(resolved_path), exc), + name=name, + source=str(resolved_path), + collision_is_error=collision_is_error, + ): + registered.append(name) + return registered + + for command_file in command_files: + try: + command = load_cli_command_file(command_file) + except CustomCommandLoadError as exc: + if failures_are_errors: + raise + name = command_file.stem.replace("_", "-") + command = BrokenPluginCommand(name, str(command_file), exc) + + command_name = command.name + if command_name is None: + raise CustomCommandLoadError( + f"Custom command {command_file} has no command name" + ) + if _register_command( + group, + command, + name=command_name, + source=str(command_file), + collision_is_error=collision_is_error, + ): + registered.append(command_name) + + return registered + + +def _entry_point_source(entry_point: importlib.metadata.EntryPoint) -> str: + try: + distribution = getattr(entry_point, "dist", None) + if distribution is None: + return f"entry point {entry_point.value}" + + name = distribution.metadata.get("Name", "unknown distribution") + version = getattr(distribution, "version", None) + if version: + return f"{name} {version} ({entry_point.value})" + return f"{name} ({entry_point.value})" + except Exception: + return f"entry point {entry_point.value}" + + +def register_installed_cli_commands( + group: click.Group, + entry_point_group: str, +) -> list[str]: + try: + entry_points = importlib.metadata.entry_points(group=entry_point_group) + except Exception as exc: + log.warning( + "Unable to discover external commands in entry-point group %s: %s", + entry_point_group, + exc, + ) + return [] + ordered_entry_points = sorted( + entry_points, + key=lambda entry_point: ( + entry_point.name, + _entry_point_source(entry_point), + entry_point.value, + ), + ) + registered: list[str] = [] + + for entry_point in ordered_entry_points: + source = _entry_point_source(entry_point) + try: + command = entry_point.load() + if not isinstance(command, click.Command): + raise TypeError( + f"entry point must resolve to a Click command or group, " + f"not {type(command).__name__}" + ) + except (Exception, SystemExit) as exc: + command = BrokenPluginCommand(entry_point.name, source, exc) + + if _register_command( + group, + command, + name=entry_point.name, + source=source, + collision_is_error=False, + ): + registered.append(entry_point.name) + + return registered + + +def register_cli_plugins( + group: click.Group, + *, + entry_point_group: str, + environment_variable: str, +) -> None: + environment_path = os.environ.get(environment_variable) + if environment_path: + register_cli_commands_from_path(group, environment_path) + register_installed_cli_commands(group, entry_point_group) + + +def _load_command_option_callback( + ctx: click.Context, + param: click.Parameter, + paths: tuple[str, ...], +) -> tuple[str, ...]: + if not isinstance(ctx.command, click.Group): + raise click.ClickException("--load-command requires a Click command group") + + for path in paths: + try: + register_cli_commands_from_path( + ctx.command, + path, + collision_is_error=True, + failures_are_errors=True, + ) + except CustomCommandLoadError as exc: + raise click.BadParameter(str(exc), ctx=ctx, param=param) from exc + return paths + + +load_cli_commands_option = click.option( + "--load-command", + "load_commands", + type=click.Path(exists=True), + multiple=True, + expose_value=False, + is_eager=True, + callback=_load_command_option_callback, + help=( + "Load a custom CLI command from a Python file or folder " + "(can be invoked multiple times)" + ), +) diff --git a/src/mvt/common/cmd_check_iocs.py b/src/mvt/common/cmd_check_iocs.py index 1111b77..c227659 100644 --- a/src/mvt/common/cmd_check_iocs.py +++ b/src/mvt/common/cmd_check_iocs.py @@ -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) @@ -86,7 +91,7 @@ class CmdCheckIOCS(Command): except NotImplementedError: continue else: - total_detections += len(m.detected) + total_detections += len(m.alertstore.alerts) if total_detections > 0: log.warning( diff --git a/src/mvt/common/command.py b/src/mvt/common/command.py index a3b99ea..aab53e6 100644 --- a/src/mvt/common/command.py +++ b/src/mvt/common/command.py @@ -8,17 +8,26 @@ import logging import os import sys from datetime import datetime -from typing import Optional +from heapq import heappop, heappush +from typing import Any, Optional -from mvt.common.indicators import Indicators -from mvt.common.module import EncryptedBackupError, MVTModule, run_module, save_timeline -from mvt.common.utils import ( +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +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, URLResult +from .utils import ( + CustomJSONEncoder, convert_datetime_to_iso, generate_hashes_from_path, get_sha256_from_file_path, ) -from mvt.common.config import settings -from mvt.common.version import MVT_VERSION +from .version import MVT_VERSION class Command: @@ -36,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.modules = [] + 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 @@ -57,12 +69,11 @@ class Command: # This list will contain all executed modules. # We can use this to reference e.g. self.executed[0].results. - self.executed = [] - self.detected_count = 0 + self.executed: list[MVTModule] = [] self.hashes = hashes - self.hash_values = [] - self.timeline = [] - self.timeline_detected = [] + self.hash_values: list[dict[str, Any]] = [] + self.timeline: ModuleTimeline = [] + self.url_results: list[URLResult] = [] # Load IOCs self._create_storage() @@ -74,12 +85,14 @@ class Command: self.iocs = Indicators(self.log) self.iocs.load_indicators_files(self.ioc_files) + self.alertstore = AlertStore() + def _create_storage(self) -> None: if self.results_path and not os.path.exists(self.results_path): try: os.makedirs(self.results_path) except Exception as exc: - self.log.critical( + self.log.fatal( "Unable to create output folder %s: %s", self.results_path, exc ) sys.exit(1) @@ -98,14 +111,14 @@ class Command: file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(formatter) - # MVT can be run in a loop - # Old file handlers stick around in subsequent loops - # Remove any existing logging.FileHandler instances + # MVT can be run in a loop. + # Old file handlers stick around in subsequent loops. + # Remove any existing logging.FileHandler instances. for handler in logger.handlers: if isinstance(handler, logging.FileHandler): logger.removeHandler(handler) - # And finally add the new one + # And finally add the new one. logger.addHandler(file_handler) def _store_timeline(self) -> None: @@ -126,22 +139,42 @@ class Command: is_utc=is_utc, ) - if len(self.timeline_detected) > 0: - save_timeline( - self.timeline_detected, - os.path.join(self.results_path, "timeline_detected.csv"), - is_utc=is_utc, - ) + def _store_alerts(self) -> None: + if not self.results_path: + return + + alerts = self.alertstore.as_json() + if not alerts: + return + + alerts_path = os.path.join(self.results_path, "alerts.json") + with open(alerts_path, "w+", encoding="utf-8") as handle: + json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder) + + def _store_urls(self) -> None: + if not self.results_path or not self.url_results: + return + + urls_path = os.path.join(self.results_path, "urls.json") + with open(urls_path, "w", encoding="utf-8") as handle: + json.dump(self.url_results, handle, indent=4, cls=CustomJSONEncoder) + + def _store_alerts_timeline(self) -> None: + if not self.results_path: + return + + alerts_timeline_path = os.path.join(self.results_path, "alerts_timeline.csv") + self.alertstore.save_timeline(alerts_timeline_path) def _store_info(self) -> None: if not self.results_path: return - target_path = None + target_path: Optional[str] = None if self.target_path: target_path = os.path.abspath(self.target_path) - info = { + info: dict[str, Any] = { "target_path": target_path, "mvt_version": MVT_VERSION, "date": convert_datetime_to_iso(datetime.now()), @@ -179,9 +212,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 @@ -191,38 +239,135 @@ class Command: def finish(self) -> None: raise NotImplementedError - def _show_disable_adb_warning(self) -> None: - """Warn if ADB is enabled""" - if type(self).__name__ in ["CmdAndroidCheckADB", "CmdAndroidCheckAndroidQF"]: - self.log.info( - "Please disable Developer Options and ADB (Android Debug Bridge) on the device once finished with the acquisition. " - "ADB is a powerful tool which can allow unauthorized access to the device." + def show_alerts_brief(self) -> None: + console = Console() + + message = Text() + for i, level in enumerate(AlertLevel): + message.append( + f"MVT produced {self.alertstore.count(level)} {level.name} alerts." ) + if i < len(AlertLevel) - 1: + message.append("\n") + + panel = Panel( + message, title="ALERTS", style="sandy_brown", border_style="sandy_brown" + ) + console.print("") + console.print(panel) + + def show_disable_adb_warning(self) -> None: + console = Console() + message = Text( + "Please disable Developer Options and ADB (Android Debug Bridge) on the device once finished with the acquisition. " + "ADB is a powerful tool which can allow unauthorized access to the device." + ) + panel = Panel(message, title="NOTE", style="yellow", border_style="yellow") + console.print("") + console.print(panel) + + def show_support_message(self) -> None: + console = Console() + message = Text() - def _show_support_message(self) -> None: support_message = "Please seek reputable expert help if you have serious concerns about a possible spyware attack. Such support is available to human rights defenders and civil society through Amnesty International's Security Lab at https://securitylab.amnesty.org/get-help/?c=mvt" - if self.detected_count == 0: - self.log.info( - f"[bold]NOTE:[/bold] Using MVT with public indicators of compromise (IOCs) [bold]WILL NOT[/bold] automatically detect advanced attacks.\n\n{support_message}", - extra={"markup": True}, + if ( + self.alertstore.count(AlertLevel.HIGH) > 0 + or self.alertstore.count(AlertLevel.CRITICAL) > 0 + ): + message.append( + f"MVT produced HIGH or CRITICAL alerts. Only expert review can confirm if the detected indicators are signs of an attack.\n\n{support_message}", ) + panel = Panel(message, title="WARNING", style="red", border_style="red") else: - self.log.warning( - f"[bold]NOTE: Detected indicators of compromise[/bold]. Only expert review can confirm if the detected indicators are signs of an attack.\n\n{support_message}", - extra={"markup": True}, + message.append( + f"The lack of severe alerts does not equate to a clean bill of health.\n\n{support_message}", ) + panel = Panel(message, title="NOTE", style="yellow", border_style="yellow") + + console.print("") + console.print(panel) + + def _ordered_modules(self) -> Optional[list[type[MVTModule]]]: + """Return enabled modules in stable topological order.""" + modules = self._available_modules() + module_indexes = {module: index for index, module in enumerate(modules)} + + if self.module_name: + selected = [ + module for module in modules if module.__name__ == self.module_name + ] + else: + selected = [module for module in modules if module.enabled] + + required = set(selected) + pending = list(selected) + while pending: + module = pending.pop() + for dependency in module.dependencies: + if dependency not in module_indexes: + self.log.warning( + "Module %s depends on unavailable module %s. " + "No modules will be run.", + module.__name__, + dependency.__name__, + ) + return None + if dependency not in required: + required.add(dependency) + pending.append(dependency) + + dependents: dict[type[MVTModule], list[type[MVTModule]]] = { + module: [] for module in required + } + indegree = {module: 0 for module in required} + for module in required: + for dependency in module.dependencies: + if dependency not in required: + continue + dependents[dependency].append(module) + indegree[module] += 1 + + ready: list[tuple[int, type[MVTModule]]] = [] + for module, count in indegree.items(): + if count == 0: + heappush(ready, (module_indexes[module], module)) + + ordered = [] + while ready: + _, module = heappop(ready) + ordered.append(module) + for dependent in dependents[module]: + indegree[dependent] -= 1 + if indegree[dependent] == 0: + heappush(ready, (module_indexes[dependent], dependent)) + + if len(ordered) != len(required): + cyclic_modules = sorted( + (module.__name__ for module, count in indegree.items() if count > 0) + ) + self.log.warning( + "Circular module dependency detected involving: %s. " + "No modules will be run.", + ", ".join(cyclic_modules), + ) + return None + + return ordered def run(self) -> None: + ordered_modules = self._ordered_modules() + if ordered_modules is None: + return + try: self.init() except NotImplementedError: pass - for module in self.modules: - if self.module_name and module.__name__ != self.module_name: - continue + executed_by_type: dict[type[MVTModule], MVTModule] = {} + for module in ordered_modules: - # FIXME: do we need the logger here module_logger = logging.getLogger(module.__module__) m = module( @@ -231,6 +376,10 @@ class Command: module_options=self.module_options, log=module_logger, ) + m.dependency_modules = { + dependency: executed_by_type[dependency] + for dependency in module.dependencies + } if self.iocs.total_ioc_count: m.indicators = self.iocs @@ -254,11 +403,10 @@ class Command: return self.executed.append(m) - - self.detected_count += len(m.detected) - + executed_by_type[module] = m self.timeline.extend(m.timeline) - self.timeline_detected.extend(m.timeline_detected) + self.url_results.extend(m.url_results) + self.alertstore.extend(m.alertstore.alerts) try: self.finish() @@ -270,7 +418,7 @@ class Command: return self._store_timeline() + self._store_alerts_timeline() + self._store_alerts() + self._store_urls() self._store_info() - - self._show_disable_adb_warning() - self._show_support_message() diff --git a/src/mvt/common/completion.py b/src/mvt/common/completion.py new file mode 100644 index 0000000..6466a6d --- /dev/null +++ b/src/mvt/common/completion.py @@ -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}") diff --git a/src/mvt/common/config.py b/src/mvt/common/config.py index d2e4e20..ce29fb3 100644 --- a/src/mvt/common/config.py +++ b/src/mvt/common/config.py @@ -1,13 +1,12 @@ -import os -import yaml import json +import os +from typing import Optional, Tuple, Type -from typing import Tuple, Type, Optional +import yaml from appdirs import user_config_dir -from pydantic import AnyHttpUrl, Field +from pydantic import Field from pydantic_settings import ( BaseSettings, - InitSettingsSource, PydanticBaseSettingsSource, SettingsConfigDict, YamlConfigSettingsSource, @@ -22,51 +21,51 @@ class MVTSettings(BaseSettings): env_prefix="MVT_", env_nested_delimiter="_", extra="ignore", - nested_model_default_partial_updates=True, ) # Allow to decided if want to load environment variables load_env: bool = Field(True, exclude=True) # General settings - PYPI_UPDATE_URL: AnyHttpUrl = Field( - "https://pypi.org/pypi/mvt/json", - validate_default=False, + PYPI_UPDATE_URL: str = Field( + default="https://pypi.org/pypi/mvt/json", ) NETWORK_ACCESS_ALLOWED: bool = True NETWORK_TIMEOUT: int = 15 # Command default settings, all can be specified by MVT_ prefixed environment variables too. IOS_BACKUP_PASSWORD: Optional[str] = Field( - None, description="Default password to use to decrypt iOS backups" + default=None, description="Default password to use to decrypt iOS backups" ) ANDROID_BACKUP_PASSWORD: Optional[str] = Field( - None, description="Default password to use to decrypt Android backups" + default=None, description="Default password to use to decrypt Android backups" ) STIX2: Optional[str] = Field( - None, description="List of directories where STIX2 files are stored" + default=None, description="List of directories where STIX2 files are stored" ) VT_API_KEY: Optional[str] = Field( - None, description="API key to use for VirusTotal lookups" + default=None, description="API key to use for VirusTotal lookups" ) - PROFILE: bool = Field(False, description="Profile the execution of MVT modules") - HASH_FILES: bool = Field(False, description="Should MVT hash output files") + PROFILE: bool = Field( + default=False, description="Profile the execution of MVT modules" + ) + HASH_FILES: bool = Field(default=False, description="Should MVT hash output files") @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], - init_settings: InitSettingsSource, + init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: - sources = ( - YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH), + yaml_source = YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH) + sources: Tuple[PydanticBaseSettingsSource, ...] = ( + yaml_source, init_settings, ) - # Load env variables if enabled - if init_settings.init_kwargs.get("load_env", True): - sources = (env_settings,) + sources + # Always load env variables by default + sources = (env_settings,) + sources return sources def save_settings( @@ -94,11 +93,11 @@ class MVTSettings(BaseSettings): Afterwards we load the settings again, this time including the env variables. """ # Set invalid env prefix to avoid loading env variables. - settings = MVTSettings(load_env=False) + settings = cls(load_env=False) settings.save_settings() # Load the settings again with any ENV variables. - settings = MVTSettings(load_env=True) + settings = cls(load_env=True) return settings diff --git a/src/mvt/common/help.py b/src/mvt/common/help.py index c90004e..5101f93 100644 --- a/src/mvt/common/help.py +++ b/src/mvt/common/help.py @@ -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" @@ -33,21 +38,21 @@ HELP_MSG_BACKUP_KEYFILE = ( HELP_MSG_EXTRACT_KEY = "Extract decryption key from an iTunes backup" HELP_MSG_CHECK_IOS_BACKUP = "Extract artifacts from an iTunes backup" HELP_MSG_CHECK_FS = "Extract artifacts from a full filesystem dump" +HELP_MSG_CHECK_SYSDIAGNOSE = "Extract artifacts from an iOS sysdiagnose archive" # Android Specific -HELP_MSG_SERIAL = "Specify a device serial number or HOST:PORT connection string" -HELP_MSG_DOWNLOAD_APKS = "Download all or only non-system installed APKs" HELP_MSG_ANDROID_BACKUP_PASSWORD = "The backup password to use for an Android backup" -HELP_MSG_DOWNLOAD_ALL_APKS = ( - "Extract all packages installed on the phone, including system packages" +HELP_MSG_CHECK_ADB_REMOVED = "REMOVED: Check an Android device over ADB" +HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION = ( + "The 'mvt-android check-adb' command has been removed from MVT. " + "Use AndroidQF to collect full forensic artifacts from an Android device. \n\n" + "The 'mvt-android check-androidqf' command in MVT can be used to fully analyze " + "forensic data collected with AndroidQF. Minimal checks can also be performed " + "on an Android bugreport using the 'mvt-android check-bugreport' command." ) -HELP_MSG_VIRUS_TOTAL = "Check packages on VirusTotal" -HELP_MSG_APK_OUTPUT = "Specify a path to a folder where you want to store the APKs" -HELP_MSG_APKS_FROM_FILE = ( - "Instead of acquiring APKs from a phone, load an existing packages.json file for " - "lookups (mainly for debug purposes)" -) -HELP_MSG_CHECK_ADB = "Deprecated: Check an Android device over ADB. Prefer using the external AndroidQF project (https://github.com/mvt-project/androidqf) to acquire AndroidQF images for analysis." HELP_MSG_CHECK_BUGREPORT = "Check an Android Bug Report" HELP_MSG_CHECK_ANDROID_BACKUP = "Check an Android Backup" HELP_MSG_CHECK_ANDROIDQF = "Check data collected with AndroidQF" +HELP_MSG_CHECK_INTRUSION_LOGS = "Check Android Intrusion Logging files" +HELP_MSG_VIRUS_TOTAL = "Check package APK hashes on VirusTotal" +HELP_MSG_DELAY_CHECKS = "Delay in seconds between VirusTotal requests" diff --git a/src/mvt/common/indicators.py b/src/mvt/common/indicators.py index aec98f9..94afadf 100644 --- a/src/mvt/common/indicators.py +++ b/src/mvt/common/indicators.py @@ -7,20 +7,38 @@ import glob import json import logging import os +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass from functools import lru_cache -from typing import Any, Dict, Iterator, List, Optional, Union +from typing import Any, Dict, Iterator, List, Optional, Sequence import ahocorasick from appdirs import user_data_dir -from .url import URL from .config import settings +from .url import URL MVT_DATA_FOLDER = user_data_dir("mvt") MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators") logger = logging.getLogger(__name__) +URL_CHECK_MAX_WORKERS = 20 + + +@dataclass +class Indicator: + value: str + type: str + name: str + stix2_file_name: str + + +@dataclass +class IndicatorMatch: + ioc: Indicator + message: str + class Indicators: """This class is used to parse indicators from a STIX2 file and provide @@ -31,6 +49,7 @@ class Indicators: self.log = log self.ioc_collections: List[Dict[str, Any]] = [] self.total_ioc_count = 0 + self.resolved_urls: Dict[str, str] = {} def _load_downloaded_indicators(self) -> None: if not os.path.isdir(MVT_INDICATORS_FOLDER): @@ -56,7 +75,7 @@ class Indicators: self.parse_stix2(file) else: self.log.error( - "Path specified with env MVT_STIX2 is not a valid path: %s", path + "Path specified with env MVT_STIX2 is not a valid path: '%s'", path ) def _new_collection( @@ -206,13 +225,13 @@ class Indicators: :type file_path: str """ - self.log.info("Parsing STIX2 indicators file at path %s", file_path) + self.log.info("Parsing STIX2 indicators file at path '%s'", file_path) with open(file_path, "r", encoding="utf-8") as handle: try: data = json.load(handle) except json.decoder.JSONDecodeError: - self.log.critical( + self.log.warning( "Unable to parse STIX2 indicator file. " "The file is corrupted or in the wrong format!" ) @@ -323,7 +342,7 @@ class Indicators: if os.path.isfile(file_path): self.parse_stix2(file_path) else: - self.log.warning("No indicators file exists at path %s", file_path) + self.log.error("No indicators file exists at path %s", file_path) # Load downloaded indicators and any indicators from env variable. if load_default: @@ -332,19 +351,19 @@ class Indicators: self._check_stix2_env_variable() self.log.info("Loaded a total of %d unique indicators", self.total_ioc_count) - def get_iocs(self, ioc_type: str) -> Iterator[Dict[str, Any]]: + def get_iocs(self, ioc_type: str) -> Iterator[Indicator]: for ioc_collection in self.ioc_collections: for ioc in ioc_collection.get(ioc_type, []): - yield { - "value": ioc, - "type": ioc_type, - "name": ioc_collection["name"], - "stix2_file_name": ioc_collection["stix2_file_name"], - } + yield Indicator( + value=ioc, + type=ioc_type, + name=ioc_collection["name"], + stix2_file_name=ioc_collection["stix2_file_name"], + ) @lru_cache() def get_ioc_matcher( - self, ioc_type: Optional[str] = None, ioc_list: Optional[list] = None + self, ioc_type: Optional[str] = None, ioc_list: Optional[List[Indicator]] = None ) -> ahocorasick.Automaton: """ Build an Aho-Corasick automaton from a list of iocs (i.e indicators) @@ -364,19 +383,19 @@ class Indicators: """ automaton = ahocorasick.Automaton() if ioc_type: - iocs = self.get_iocs(ioc_type) + iocs: Iterator[Indicator] = self.get_iocs(ioc_type) elif ioc_list: - iocs = ioc_list + iocs = iter(ioc_list) else: raise ValueError("Must provide either ioc_type or ioc_list") for ioc in iocs: - automaton.add_word(ioc["value"], ioc) + automaton.add_word(ioc.value, ioc) automaton.make_automaton() return automaton @lru_cache() - def check_url(self, url: str) -> Union[dict, None]: + def check_url(self, url: str) -> Optional[IndicatorMatch]: """Check if a given URL matches any of the provided domain indicators. :param url: URL to match against domain indicators @@ -384,21 +403,16 @@ class Indicators: :returns: Indicator details if matched, otherwise None """ - if not url: - return None - if not isinstance(url, str): + if not url or not isinstance(url, str): return None # Check the URL first for ioc in self.get_iocs("urls"): - if ioc["value"] == url: - self.log.warning( - 'Found a known suspicious URL %s matching indicator "%s" from "%s"', - url, - ioc["value"], - ioc["name"], + if ioc.value == url: + return IndicatorMatch( + ioc=ioc, + message=f'Found a known suspicious URL {url} matching indicator "{ioc.value}" from "{ioc.name}"', ) - return ioc # Then check the domain # Create an Aho-Corasick automaton from the list of urls @@ -426,80 +440,62 @@ class Indicators: orig_url.url, dest_url.url, ) - return self.check_url(dest_url.url) + match = self.check_url(dest_url.url) + self.resolved_urls[url] = self.resolved_urls.get( + dest_url.url, dest_url.url + ) + return match final_url = dest_url + self.resolved_urls[url] = final_url.url else: # If it's not shortened, we just use the original URL object. final_url = orig_url except Exception: # If URL parsing failed, we just try to do a simple substring # match. - for idx, ioc in domain_matcher.iter(url): - if ioc["value"].lower() in url: - self.log.warning( - "Maybe found a known suspicious domain %s " - 'matching indicator "%s" from "%s"', - url, - ioc["value"], - ioc["name"], + for _, ioc in domain_matcher.iter(url): + if ioc.value.lower() in url: + return IndicatorMatch( + ioc=ioc, + message=f'Maybe found a known suspicious domain {url} matching indicator "{ioc.value}" from "{ioc.name}"', ) - return ioc # If nothing matched, we can quit here. return None # If all parsing worked, we start walking through available domain # indicators. - for idx, ioc in domain_matcher.iter(final_url.domain.lower()): + for _, ioc in domain_matcher.iter(final_url.domain.lower()): # First we check the full domain. - if final_url.domain.lower() == ioc["value"]: + if final_url.domain.lower() == ioc.value: if orig_url.is_shortened and orig_url.url != final_url.url: - self.log.warning( - "Found a known suspicious domain %s " - 'shortened as %s matching indicator "%s" from "%s"', - final_url.url, - orig_url.url, - ioc["value"], - ioc["name"], - ) + message = f'Found a known suspicious domain {final_url.url} shortened as {orig_url.url} matching indicator "{ioc.value}" from "{ioc.name}"' else: - self.log.warning( - "Found a known suspicious domain %s " - 'matching indicator "%s" from "%s"', - final_url.url, - ioc["value"], - ioc["name"], - ) - return ioc + message = f'Found a known suspicious domain {final_url.url} matching indicator "{ioc.value}" from "{ioc.name}"' + + return IndicatorMatch(ioc=ioc, message=message) # Then we just check the top level domain. - for idx, ioc in domain_matcher.iter(final_url.top_level.lower()): - if final_url.top_level.lower() == ioc["value"]: + for _, ioc in domain_matcher.iter(final_url.top_level.lower()): + if final_url.top_level.lower() == ioc.value: if orig_url.is_shortened and orig_url.url != final_url.url: - self.log.warning( - "Found a sub-domain with suspicious top " - "level %s shortened as %s matching " - 'indicator "%s" from "%s"', - final_url.url, - orig_url.url, - ioc["value"], - ioc["name"], - ) + message = f'Found a sub-domain with suspicious top level {final_url.url} shortened as {orig_url.url} matching indicator "{ioc.value}" from "{ioc.name}"' else: - self.log.warning( - "Found a sub-domain with a suspicious top " - 'level %s matching indicator "%s" from "%s"', - final_url.url, - ioc["value"], - ioc["name"], - ) + message = f'Found a sub-domain with a suspicious top level {final_url.url} matching indicator "{ioc.value}" from "{ioc.name}"' - return ioc + return IndicatorMatch(ioc=ioc, message=message) return None - def check_urls(self, urls: list) -> Union[dict, None]: + def get_expanded_url(self, url: str) -> Optional[str]: + """Return the final URL recorded while checking a shortened URL.""" + expanded_url = self.resolved_urls.get(url) + if expanded_url and expanded_url != url: + return expanded_url + return None + + def check_urls(self, urls: list) -> Optional[IndicatorMatch]: """Check a list of URLs against the provided list of domain indicators. :param urls: List of URLs to check against domain indicators @@ -510,14 +506,43 @@ class Indicators: if not urls: return None - for url in urls: - check = self.check_url(url) - if check: - return check + return self.check_url_batches([urls])[0] - return None + def check_url_batches( + self, url_batches: Sequence[Optional[Sequence[str]]] + ) -> List[Optional[IndicatorMatch]]: + """Check batches of URLs concurrently while preserving batch order. - def check_process(self, process: str) -> Union[dict, None]: + URLs are deduplicated across batches before checking. Each returned item + is the first indicator match from the corresponding input batch, using + the original URL order. + """ + batches = [list(urls) if urls else [] for urls in url_batches] + unique_urls = list( + dict.fromkeys(url for urls in batches for url in urls) + ) + + if not unique_urls: + return [None] * len(batches) + + if settings.NETWORK_ACCESS_ALLOWED and len(unique_urls) > 1: + worker_count = min(URL_CHECK_MAX_WORKERS, len(unique_urls)) + with ThreadPoolExecutor(max_workers=worker_count) as executor: + url_matches = dict( + zip(unique_urls, executor.map(self.check_url, unique_urls)) + ) + else: + url_matches = {url: self.check_url(url) for url in unique_urls} + + return [ + next( + (url_matches[url] for url in urls if url_matches[url] is not None), + None, + ) + for urls in batches + ] + + def check_process(self, process: str) -> Optional[IndicatorMatch]: """Check the provided process name against the list of process indicators. @@ -531,28 +556,22 @@ class Indicators: proc_name = os.path.basename(process) for ioc in self.get_iocs("processes"): - if proc_name == ioc["value"]: - self.log.warning( - 'Found a known suspicious process name "%s" ' - 'matching indicators from "%s"', - process, - ioc["name"], + if proc_name == ioc.value: + return IndicatorMatch( + ioc=ioc, + message=f'Found a known suspicious process name "{process}" matching indicators from "{ioc.name}"', ) - return ioc if len(proc_name) == 16: - if ioc["value"].startswith(proc_name): - self.log.warning( - "Found a truncated known suspicious " - 'process name "%s" matching indicators from "%s"', - process, - ioc["name"], + if ioc.value.startswith(proc_name): + return IndicatorMatch( + ioc=ioc, + message=f'Found a truncated known suspicious process name "{process}" matching indicators from "{ioc.name}"', ) - return ioc return None - def check_processes(self, processes: list) -> Union[dict, None]: + def check_processes(self, processes: list) -> Optional[IndicatorMatch]: """Check the provided list of processes against the list of process indicators. @@ -571,7 +590,7 @@ class Indicators: return None - def check_email(self, email: str) -> Union[dict, None]: + def check_email(self, email: str) -> Optional[IndicatorMatch]: """Check the provided email against the list of email indicators. :param email: Email address to check against email indicators @@ -583,18 +602,15 @@ class Indicators: return None for ioc in self.get_iocs("emails"): - if email.lower() == ioc["value"].lower(): - self.log.warning( - 'Found a known suspicious email address "%s" ' - 'matching indicators from "%s"', - email, - ioc["name"], + if email.lower() == ioc.value.lower(): + return IndicatorMatch( + ioc=ioc, + message=f'Found a known suspicious email address "{email}" matching indicators from "{ioc.name}"', ) - return ioc return None - def check_file_name(self, file_name: str) -> Union[dict, None]: + def check_file_name(self, file_name: str) -> Optional[IndicatorMatch]: """Check the provided file name against the list of file indicators. :param file_name: File name to check against file @@ -607,18 +623,15 @@ class Indicators: return None for ioc in self.get_iocs("file_names"): - if ioc["value"] == file_name: - self.log.warning( - 'Found a known suspicious file name "%s" ' - 'matching indicators from "%s"', - file_name, - ioc["name"], + if ioc.value == file_name: + return IndicatorMatch( + ioc=ioc, + message=f'Found a known suspicious file name "{file_name}" matching indicators from "{ioc.name}"', ) - return ioc return None - def check_file_path(self, file_path: str) -> Union[dict, None]: + def check_file_path(self, file_path: str) -> Optional[IndicatorMatch]: """Check the provided file path against the list of file indicators (both path and name). @@ -631,25 +644,22 @@ class Indicators: if not file_path: return None - ioc = self.check_file_name(os.path.basename(file_path)) - if ioc: - return ioc + ioc_match = self.check_file_name(os.path.basename(file_path)) + if ioc_match: + return ioc_match for ioc in self.get_iocs("file_paths"): # Strip any trailing slash from indicator paths to match # directories. - if file_path.startswith(ioc["value"].rstrip("/")): - self.log.warning( - 'Found a known suspicious file path "%s" ' - 'matching indicators form "%s"', - file_path, - ioc["name"], + if file_path.startswith(ioc.value.rstrip("/")): + return IndicatorMatch( + ioc=ioc, + message=f'Found a known suspicious file path "{file_path}" matching indicators form "{ioc.name}"', ) - return ioc return None - def check_file_path_process(self, file_path: str) -> Optional[Dict[str, Any]]: + def check_file_path_process(self, file_path: str) -> Optional[IndicatorMatch]: """Check the provided file path contains a process name from the list of indicators @@ -664,18 +674,15 @@ class Indicators: for ioc in self.get_iocs("processes"): parts = file_path.split("/") - if ioc["value"] in parts: - self.log.warning( - "Found known suspicious process name mentioned in file at " - 'path "%s" matching indicators from "%s"', - file_path, - ioc["name"], + if ioc.value in parts: + return IndicatorMatch( + ioc=ioc, + message=f'Found known suspicious process name mentioned in file at path "{file_path}" matching indicators from "{ioc.name}"', ) - return ioc return None - def check_profile(self, profile_uuid: str) -> Union[dict, None]: + def check_profile(self, profile_uuid: str) -> Optional[IndicatorMatch]: """Check the provided configuration profile UUID against the list of indicators. @@ -689,18 +696,15 @@ class Indicators: return None for ioc in self.get_iocs("ios_profile_ids"): - if profile_uuid in ioc["value"]: - self.log.warning( - 'Found a known suspicious profile ID "%s" ' - 'matching indicators from "%s"', - profile_uuid, - ioc["name"], + if profile_uuid in ioc.value: + return IndicatorMatch( + ioc=ioc, + message=f'Found a known suspicious profile ID "{profile_uuid}" matching indicators from "{ioc.name}"', ) - return ioc return None - def check_file_hash(self, file_hash: str) -> Union[dict, None]: + def check_file_hash(self, file_hash: str) -> Optional[IndicatorMatch]: """Check the provided file hash against the list of indicators. :param file_hash: hash to check @@ -719,18 +723,15 @@ class Indicators: hash_type = "sha256" for ioc in self.get_iocs("files_" + hash_type): - if file_hash.lower() == ioc["value"].lower(): - self.log.warning( - 'Found a known suspicious file with hash "%s" ' - 'matching indicators from "%s"', - file_hash, - ioc["name"], + if file_hash.lower() == ioc.value.lower(): + return IndicatorMatch( + ioc=ioc, + message=f'Found a known suspicious file with hash "{file_hash}" matching indicators from "{ioc.name}"', ) - return ioc return None - def check_app_certificate_hash(self, cert_hash: str) -> Union[dict, None]: + def check_app_certificate_hash(self, cert_hash: str) -> Optional[IndicatorMatch]: """Check the provided cert hash against the list of indicators. :param cert_hash: hash to check @@ -742,18 +743,15 @@ class Indicators: return None for ioc in self.get_iocs("app_cert_hashes"): - if cert_hash.lower() == ioc["value"].lower(): - self.log.warning( - 'Found a known suspicious app certfificate with hash "%s" ' - 'matching indicators from "%s"', - cert_hash, - ioc["name"], + if cert_hash.lower() == ioc.value.lower(): + return IndicatorMatch( + ioc=ioc, + message=f'Found a known suspicious app certfificate with hash "{cert_hash}" matching indicators from "{ioc.name}"', ) - return ioc return None - def check_app_id(self, app_id: str) -> Union[dict, None]: + def check_app_id(self, app_id: str) -> Optional[IndicatorMatch]: """Check the provided app identifier (typically an Android package name) against the list of indicators. @@ -766,42 +764,17 @@ class Indicators: return None for ioc in self.get_iocs("app_ids"): - if app_id.lower() == ioc["value"].lower(): - self.log.warning( - 'Found a known suspicious app with ID "%s" ' - 'matching indicators from "%s"', - app_id, - ioc["name"], + if app_id.lower() == ioc.value.lower(): + return IndicatorMatch( + ioc=ioc, + message=f'Found a known suspicious app with ID "{app_id}" matching indicators from "{ioc.name}"', ) - return ioc return None - - def check_receiver_prefix(self, receiver_name: str) -> Union[dict, None]: - """Check the provided receiver name against the list of indicators. - An IoC match is detected when a substring of the receiver matches the indicator - :param app_id: App ID to check against the list of indicators - :type app_id: str - :returns: Indicator details if matched, otherwise None - - """ - if not receiver_name: - return None - - for ioc in self.get_iocs("app_ids"): - if ioc["value"].lower() in receiver_name.lower(): - self.log.warning( - 'Found a known suspicious receiver with name "%s" ' - 'matching indicators from "%s"', - receiver_name, - ioc["name"], - ) - return ioc - - return None - - def check_android_property_name(self, property_name: str) -> Optional[dict]: + def check_android_property_name( + self, property_name: str + ) -> Optional[IndicatorMatch]: """Check the android property name against the list of indicators. :param property_name: Name of the Android property @@ -813,24 +786,21 @@ class Indicators: return None for ioc in self.get_iocs("android_property_names"): - if property_name.lower() == ioc["value"].lower(): - self.log.warning( - 'Found a known suspicious Android property "%s" ' - 'matching indicators from "%s"', - property_name, - ioc["name"], + if property_name.lower() == ioc.value.lower(): + return IndicatorMatch( + ioc=ioc, + message=f'Found a known suspicious Android property "{property_name}" matching indicators from "{ioc.name}"', ) - return ioc return None - def check_domain(self, url: str) -> Union[dict, None]: + def check_domain(self, url: str) -> Optional[IndicatorMatch]: """ Renamed check_url now, kept for compatibility """ return self.check_url(url) - def check_domains(self, urls: list) -> Union[dict, None]: + def check_domains(self, urls: list) -> Optional[IndicatorMatch]: """ Renamed check_domains, kept for compatibility """ diff --git a/src/mvt/common/log.py b/src/mvt/common/log.py new file mode 100644 index 0000000..498b13d --- /dev/null +++ b/src/mvt/common/log.py @@ -0,0 +1,65 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2025 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import logging +from rich.console import Console +from rich.logging import RichHandler +from typing import Optional + +INFO = logging.INFO +DEBUG = logging.DEBUG +ERROR = logging.ERROR +FATAL = logging.CRITICAL +WARNING = logging.WARNING + +INFO_ALERT = 25 +LOW_ALERT = 35 +MEDIUM_ALERT = 45 +HIGH_ALERT = 55 +CRITICAL_ALERT = 65 + +logging.addLevelName(INFO_ALERT, "INFO") +logging.addLevelName(LOW_ALERT, "LOW") +logging.addLevelName(MEDIUM_ALERT, "MEDIUM") +logging.addLevelName(HIGH_ALERT, "HIGH") +logging.addLevelName(CRITICAL_ALERT, "CRITICAL") + + +class MVTLogHandler(RichHandler): + def __init__(self, console: Optional[Console] = None, level: int = logging.DEBUG): + super().__init__(console=console, level=level) + + def __add_prefix_space(self, level: str) -> str: + max_length = len("CRITICAL ALERT") + space = max_length - len(level) + return f"{level}{' ' * space}" + + def emit(self, record: logging.LogRecord): + try: + msg = rf"[grey50]\[{record.name}][/] {self.format(record)}" + + if record.levelno == ERROR: + msg = f"[bold red]{self.__add_prefix_space('ERROR')}[/bold red] {msg}" + elif record.levelno == FATAL: + msg = f"[bold red]{self.__add_prefix_space('FATAL')}[/bold red] {msg}" + elif record.levelno == WARNING: + msg = f"[yellow]{self.__add_prefix_space('WARNING')}[/yellow] {msg}" + elif record.levelno == INFO_ALERT: + msg = f"[blue]{self.__add_prefix_space('INFO ALERT')}[/blue] {msg}" + elif record.levelno == LOW_ALERT: + msg = f"[yellow]{self.__add_prefix_space('LOW ALERT')}[/yellow] {msg}" + elif record.levelno == MEDIUM_ALERT: + msg = f"[sandy_brown]{self.__add_prefix_space('MEDIUM ALERT')}[/sandy_brown] {msg}" + elif record.levelno == HIGH_ALERT: + msg = f"[red]{self.__add_prefix_space('HIGH ALERT')}[/red] {msg}" + elif record.levelno == CRITICAL_ALERT: + msg = f"[bold red]{self.__add_prefix_space('CRITICAL ALERT')}[/bold red] {msg}" + else: + msg = f"{self.__add_prefix_space('')} {msg}" + + self.console.print(msg) + + except Exception: + self.handleError(record) diff --git a/src/mvt/common/module.py b/src/mvt/common/module.py index 9c26064..cd127a6 100644 --- a/src/mvt/common/module.py +++ b/src/mvt/common/module.py @@ -8,8 +8,18 @@ import json import logging import os import re -from typing import Any, Dict, List, Optional, Union +from dataclasses import asdict, is_dataclass +from typing import Any, Dict, Optional, Sequence +from .alerts import AlertStore +from .indicators import Indicators +from .module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, + ModuleTimeline, + URLResult, +) from .utils import CustomJSONEncoder, exec_or_profile @@ -32,8 +42,10 @@ class InsufficientPrivileges(Exception): class MVTModule: """This class provides a base for all extraction modules.""" - enabled = True + enabled: bool = True slug: Optional[str] = None + dependencies: Sequence[type["MVTModule"]] = () + supported_commands: Sequence[tuple[str, str]] = () def __init__( self, @@ -42,7 +54,7 @@ class MVTModule: results_path: Optional[str] = None, module_options: Optional[Dict[str, Any]] = None, log: logging.Logger = logging.getLogger(__name__), - results: Union[List[Dict[str, Any]], Dict[str, Any], None] = None, + results: Optional[ModuleResults] = None, ) -> None: """Initialize module. @@ -50,7 +62,7 @@ class MVTModule: :type file_path: str :param target_path: Path to the target folder (backup or filesystem dump) - :type file_path: str + :type target_path: str :param results_path: Folder where results will be stored :type results_path: str :param fast_mode: Flag to enable or disable slow modules @@ -59,32 +71,38 @@ class MVTModule: :param results: Provided list of results entries :type results: list """ - self.file_path = file_path - self.target_path = target_path - self.results_path = results_path - self.module_options = module_options if module_options else {} + self.file_path: Optional[str] = file_path + self.target_path: Optional[str] = target_path + self.results_path: Optional[str] = results_path + self.serial: Optional[str] = None + self.module_options: Dict[str, Any] = module_options if module_options else {} + self.log = log - self.indicators = None - self.results = results if results else [] - self.detected: List[Dict[str, Any]] = [] - self.timeline: List[Dict[str, str]] = [] - self.timeline_detected: List[Dict[str, str]] = [] + self.indicators: Optional[Indicators] = None + self.alertstore: AlertStore = AlertStore(log=log) + + self.results: ModuleResults = results if results is not None else [] + self.timeline: ModuleTimeline = [] + self.url_results: list[URLResult] = [] + self.dependency_modules: Dict[type["MVTModule"], "MVTModule"] = {} + + def get_dependency_results( + self, module_class: type["MVTModule"] + ) -> ModuleResults: + """Return the results produced by a prerequisite module.""" + return self.dependency_modules[module_class].results @classmethod def from_json(cls, json_path: str, log: logging.Logger): with open(json_path, "r", encoding="utf-8") as handle: - try: - results = json.load(handle) - if log: - log.info('Loaded %d results from "%s"', len(results), json_path) - return cls(results=results, log=log) - except json.decoder.JSONDecodeError as err: - log.error('Error to decode the json "%s" file: "%s"', json_path, err) - return None + results = json.load(handle) + if log: + log.info('Loaded %d results from "%s"', len(results), json_path) + + return cls(results=results, log=log) @classmethod def get_slug(cls) -> str: - """Use the module's class name to retrieve a slug""" if cls.slug: return cls.slug @@ -92,26 +110,49 @@ class MVTModule: return re.sub("([a-z0-9])([A-Z])", r"\1_\2", sub).lower() def check_indicators(self) -> None: - """Check the results of this module against a provided list of - indicators. - - - """ raise NotImplementedError + def collect_url_results(self) -> None: + """Collect URL records exposed by this module.""" + + def add_url_result(self, url: str, timestamp: Optional[str], source: str) -> None: + expanded_url = None + if self.indicators: + expanded_url = self.indicators.get_expanded_url(url) + + self.url_results.append( + { + "url": url, + "expanded_url": expanded_url, + "timestamp": timestamp, + "source": source, + } + ) + def save_to_json(self) -> None: - """Save the collected results to a json file.""" if not self.results_path: return name = self.get_slug() if self.results: + converted_results: Any + if isinstance(self.results, dict): + converted_results = self.results + else: + converted_results = [ + asdict(result) + if is_dataclass(result) and not isinstance(result, type) + else result + for result in self.results + ] results_file_name = f"{name}.json" results_json_path = os.path.join(self.results_path, results_file_name) with open(results_json_path, "w", encoding="utf-8") as handle: try: - json.dump(self.results, handle, indent=4, cls=CustomJSONEncoder) + json.dump( + converted_results, handle, indent=4, cls=CustomJSONEncoder + ) except Exception as exc: self.log.error( "Unable to store results of module %s to file %s: %s", @@ -120,13 +161,15 @@ class MVTModule: exc, ) - if self.detected: + if self.alertstore.alerts: detected_file_name = f"{name}_detected.json" detected_json_path = os.path.join(self.results_path, detected_file_name) with open(detected_json_path, "w", encoding="utf-8") as handle: - json.dump(self.detected, handle, indent=4, cls=CustomJSONEncoder) + json.dump( + self.alertstore.as_json(), handle, indent=4, cls=CustomJSONEncoder + ) - def serialize(self, record: dict) -> Union[dict, list, None]: + def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult: raise NotImplementedError @staticmethod @@ -138,30 +181,32 @@ class MVTModule: """ timeline_set = set() for record in timeline: - timeline_set.add(json.dumps(record, sort_keys=True)) + timeline_set.add( + json.dumps( + asdict(record) + if is_dataclass(record) and not isinstance(record, type) + else record, + sort_keys=True, + ) + ) + return [json.loads(record) for record in timeline_set] def to_timeline(self) -> None: """Convert results into a timeline.""" - for result in self.results: - record = self.serialize(result) - if record: - if isinstance(record, list): - self.timeline.extend(record) - else: - self.timeline.append(record) + if not self.results: + return - for detected in self.detected: - record = self.serialize(detected) + for result in self.results: + record: ModuleSerializedResult = self.serialize(result) if record: if isinstance(record, list): - self.timeline_detected.extend(record) + self.timeline.extend(record) # type: ignore[arg-type] else: - self.timeline_detected.append(record) + self.timeline.append(record) # type: ignore[arg-type] # De-duplicate timeline entries. self.timeline = self._deduplicate_timeline(self.timeline) - self.timeline_detected = self._deduplicate_timeline(self.timeline_detected) def run(self) -> None: """Run the main module procedure.""" @@ -218,11 +263,20 @@ def run_module(module: MVTModule) -> None: ) else: - if module.indicators and not module.detected: + if module.indicators and not module.alertstore.alerts: module.log.info( "The %s module produced no detections!", module.__class__.__name__ ) + try: + module.collect_url_results() + except Exception as exc: + module.log.exception( + "Error when collecting URLs from module %s: %s", + module.__class__.__name__, + exc, + ) + try: module.to_timeline() except NotImplementedError: @@ -244,7 +298,7 @@ def save_timeline(timeline: list, timeline_path: str, is_utc: bool = True) -> No :param timeline_path: Path to the csv file to store the timeline to """ - with open(timeline_path, "a+", encoding="utf-8") as handle: + with open(timeline_path, "w", encoding="utf-8") as handle: csvoutput = csv.writer( handle, delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL, escapechar="\\" ) diff --git a/src/mvt/common/module_loader.py b/src/mvt/common/module_loader.py new file mode 100644 index 0000000..da94826 --- /dev/null +++ b/src/mvt/common/module_loader.py @@ -0,0 +1,134 @@ +# 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 logging +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" +log = logging.getLogger(__name__) + + +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: + log.warning( + "Custom module %s has no supported_commands and will not be run. " + "Declare the platform/command pairs it supports.", + module_class.__name__, + ) + return False + + return (platform, command) in {tuple(entry) for entry in supported_commands} diff --git a/src/mvt/common/module_types.py b/src/mvt/common/module_types.py new file mode 100644 index 0000000..a47ffe4 --- /dev/null +++ b/src/mvt/common/module_types.py @@ -0,0 +1,44 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2025 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 dataclasses import dataclass +from typing import Any, Dict, List, Optional, TypedDict, Union + + +# ModuleAtomicResult is a flexible dictionary that can contain any data. +# Common fields include: +# - timestamp: Optional[str] - timestamp string +# - isodate: Optional[str] - ISO formatted date string +# - matched_indicator: Optional[Indicator] - indicator that matched this result +# - Any other module-specific fields +ModuleAtomicResult = Dict[str, Any] + + +# Extraction modules historically use either a list of records or grouped +# dictionaries keyed by source path. Keep this alias broad until those shapes +# are modeled per module. +ModuleResults = Any + + +class URLResult(TypedDict): + url: str + expanded_url: Optional[str] + timestamp: Optional[str] + source: str + + +@dataclass +class ModuleAtomicTimeline: + timestamp: str + module: str + event: str + data: str + + +ModuleTimeline = List[ModuleAtomicTimeline] +# ModuleSerializedResult can be a proper timeline object or a plain dict for compatibility +ModuleSerializedResult = Union[ + ModuleAtomicTimeline, ModuleTimeline, Dict[str, Any], List[Dict[str, Any]] +] diff --git a/src/mvt/common/password.py b/src/mvt/common/password.py new file mode 100644 index 0000000..b59ae49 --- /dev/null +++ b/src/mvt/common/password.py @@ -0,0 +1,143 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2026 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +"""Password prompts with masked keyboard feedback. + +This is based on the ``echo_char`` support added to :mod:`getpass` in Python +3.14. MVT supports Python 3.10 and later, so it cannot use that API directly. +""" + +import contextlib +import io +import os +import sys +import warnings +from typing import TextIO + + +def prompt_password(prompt: str) -> str: + """Return a password while displaying an asterisk for each character.""" + try: + import termios + + return _unix_getpass(prompt, termios) + except ImportError: + try: + import msvcrt + + return _windows_getpass(prompt, msvcrt) + except ImportError: + return _fallback_getpass(prompt) + + +def _unix_getpass(prompt: str, termios) -> str: + password = None + input_stream: TextIO + output_stream: TextIO + with contextlib.ExitStack() as stack: + try: + fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) + tty = io.FileIO(fd, "w+") + stack.enter_context(tty) + input_stream = io.TextIOWrapper(tty) + stack.enter_context(input_stream) + output_stream = input_stream + except OSError: + stack.close() + try: + fd = sys.stdin.fileno() + except (AttributeError, ValueError): + return _fallback_getpass(prompt) + input_stream = sys.stdin + output_stream = sys.stderr + + try: + old = termios.tcgetattr(fd) + new = old[:] + new[3] &= ~termios.ECHO + new[3] &= ~termios.ICANON + try: + termios.tcsetattr(fd, termios.TCSAFLUSH, new) + password = _readline_with_asterisks(output_stream, input_stream, prompt) + finally: + termios.tcsetattr(fd, termios.TCSAFLUSH, old) + output_stream.flush() + except termios.error: + if password is not None: + raise + password = _fallback_getpass(prompt) + + output_stream.write("\n") + return password + + +def _windows_getpass(prompt: str, msvcrt) -> str: + if sys.stdin is not sys.__stdin__: + return _fallback_getpass(prompt) + + for char in prompt: + msvcrt.putwch(char) + + password = "" + while True: + char = msvcrt.getwch() + if char in ("\r", "\n"): + break + if char == "\x03": + raise KeyboardInterrupt + if char == "\b": + if password: + msvcrt.putwch("\b") + msvcrt.putwch(" ") + msvcrt.putwch("\b") + password = password[:-1] + else: + password += char + msvcrt.putwch("*") + + msvcrt.putwch("\r") + msvcrt.putwch("\n") + return password + + +def _fallback_getpass(prompt: str) -> str: + warnings.warn( + "Can not control echo on the terminal.", + category=UserWarning, + stacklevel=2, + ) + print("Warning: Password input may be echoed.", file=sys.stderr) + return input(prompt) + + +def _readline_with_asterisks( + output_stream: TextIO, input_stream: TextIO, prompt: str +) -> str: + output_stream.write(prompt) + output_stream.flush() + + password = "" + eof_pressed = False + while True: + char = input_stream.read(1) + if char in ("\n", "\r"): + break + if char == "\x03": + raise KeyboardInterrupt + if char in ("\x7f", "\b"): + if password: + output_stream.write("\b \b") + output_stream.flush() + password = password[:-1] + elif char == "\x04": + if eof_pressed: + break + eof_pressed = True + elif char != "\x00": + password += char + output_stream.write("*") + output_stream.flush() + eof_pressed = False + return password diff --git a/src/mvt/common/updates.py b/src/mvt/common/updates.py index c9c380b..001a5c2 100644 --- a/src/mvt/common/updates.py +++ b/src/mvt/common/updates.py @@ -12,9 +12,9 @@ import requests import yaml from packaging import version +from .config import settings from .indicators import MVT_DATA_FOLDER, MVT_INDICATORS_FOLDER from .version import MVT_VERSION -from .config import settings log = logging.getLogger(__name__) @@ -25,7 +25,7 @@ INDICATORS_CHECK_FREQUENCY = 12 class MVTUpdates: def check(self) -> str: try: - res = requests.get(settings.PYPI_UPDATE_URL, timeout=5) + res = requests.get(str(settings.PYPI_UPDATE_URL), timeout=5) except requests.exceptions.RequestException as e: log.error("Failed to check for updates, skipping updates: %s", e) return "" @@ -180,10 +180,8 @@ class IndicatorsUpdates: def _get_remote_file_latest_commit( self, owner: str, repo: str, branch: str, path: str ) -> int: - # TODO: The branch is currently not taken into consideration. - # How do we specify which branch to look up to the API? file_commit_url = ( - f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}" + f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}&sha={branch}" ) try: res = requests.get(file_commit_url, timeout=5) diff --git a/src/mvt/common/url.py b/src/mvt/common/url.py index 40240c5..426d64b 100644 --- a/src/mvt/common/url.py +++ b/src/mvt/common/url.py @@ -3,11 +3,17 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import logging from typing import Optional +from urllib.parse import urlparse import requests from tld import get_tld +from .config import settings + +log = logging.getLogger(__name__) + SHORTENER_DOMAINS = [ "0rz.tw", "1drv.ms", @@ -338,11 +344,12 @@ class URL: :rtype: str """ - return ( - get_tld(self.url, as_object=True, fix_protocol=True) - .parsed_url.netloc.lower() - .lstrip("www.") - ) + tld_obj = get_tld(self.url, as_object=True, fix_protocol=True) + if isinstance(tld_obj, str): + return tld_obj + if tld_obj is None: + return "" + return tld_obj.parsed_url.netloc.lower().lstrip("www.") def get_top_level(self) -> str: """Get only the top-level domain from a URL. @@ -351,7 +358,12 @@ class URL: :rtype: str """ - return get_tld(self.url, as_object=True, fix_protocol=True).fld.lower() + tld_obj = get_tld(self.url, as_object=True, fix_protocol=True) + if isinstance(tld_obj, str): + return tld_obj + if tld_obj is None: + return "" + return tld_obj.fld.lower() def check_if_shortened(self) -> bool: """Check if the URL is among list of shortener services. @@ -362,6 +374,10 @@ class URL: :rtype: bool """ + parsed_url = urlparse(self.url if "://" in self.url else f"//{self.url}") + if self.domain.lower() == "goo.gl" and parsed_url.path.startswith("/maps/"): + return False + if self.domain.lower() in SHORTENER_DOMAINS: self.is_shortened = True @@ -369,7 +385,16 @@ class URL: def unshorten(self) -> Optional[str]: """Unshorten the URL by requesting an HTTP HEAD response.""" - res = requests.head(self.url) + + if settings.NETWORK_ACCESS_ALLOWED is False: + log.info( + "Network access disabled (MVT_NETWORK_ACCESS_ALLOWED=False), " + "skipping unshorten for %s", + self.url, + ) + return "" + + res = requests.head(self.url, timeout=settings.NETWORK_TIMEOUT) if str(res.status_code).startswith("30"): return res.headers["Location"] diff --git a/src/mvt/common/utils.py b/src/mvt/common/utils.py index 3d054f5..30de159 100644 --- a/src/mvt/common/utils.py +++ b/src/mvt/common/utils.py @@ -10,9 +10,10 @@ import json import logging import os import re +from dataclasses import asdict, is_dataclass from typing import Any, Iterator, Union -from rich.logging import RichHandler +from .log import MVTLogHandler from mvt.common.config import settings @@ -30,6 +31,9 @@ class CustomJSONEncoder(json.JSONEncoder): """ def default(self, o): + # Unwrap dataclass instances (such as Indicator) to dict. Skip class itself. + if is_dataclass(o) and not isinstance(o, type): + return asdict(o) if isinstance(o, bytes): # Decode as utf-8, replace any invalid UTF-8 bytes with escaped hex return o.decode("utf-8", errors="backslashreplace") @@ -119,10 +123,9 @@ def convert_mactime_to_datetime(timestamp: Union[int, float], from_2001: bool = if from_2001: timestamp = timestamp + 978307200 - # TODO: This is rather ugly. Happens sometimes with invalid timestamps. try: return convert_unix_to_utc_datetime(timestamp) - except Exception: + except (OSError, OverflowError, ValueError): return None @@ -234,11 +237,10 @@ def init_logging(verbose: bool = False): """ Initialise logging for the MVT module """ - # Setup logging using Rich. log = logging.getLogger("mvt") log.setLevel(logging.DEBUG) - consoleHandler = RichHandler(show_path=False, log_time_format="%X") - consoleHandler.setFormatter(logging.Formatter("[%(name)s] %(message)s")) + consoleHandler = MVTLogHandler() + consoleHandler.setFormatter(logging.Formatter("%(message)s")) if verbose: consoleHandler.setLevel(logging.DEBUG) else: diff --git a/src/mvt/common/version.py b/src/mvt/common/version.py index 7d659ab..c8e7716 100644 --- a/src/mvt/common/version.py +++ b/src/mvt/common/version.py @@ -3,4 +3,4 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ -MVT_VERSION = "2.7.0" +MVT_VERSION = "2026.7.29" diff --git a/src/mvt/common/virustotal.py b/src/mvt/common/virustotal.py index e0749ea..9a5df32 100644 --- a/src/mvt/common/virustotal.py +++ b/src/mvt/common/virustotal.py @@ -4,13 +4,13 @@ # https://license.mvt.re/1.1/ import logging -import os +from typing import Any, Optional import requests -log = logging.getLogger(__name__) +from .config import settings -MVT_VT_API_KEY = "MVT_VT_API_KEY" +log = logging.getLogger(__name__) class VTNoKey(Exception): @@ -21,21 +21,22 @@ class VTQuotaExceeded(Exception): pass -def virustotal_lookup(file_hash: str): - if MVT_VT_API_KEY not in os.environ: +def virustotal_lookup(file_hash: str) -> Optional[dict[str, Any]]: + if not settings.VT_API_KEY: raise VTNoKey( - "No VirusTotal API key provided: to use VirusTotal " - "lookups please provide your API key with " - "`export MVT_VT_API_KEY=`" + "No VirusTotal API key provided: to use VirusTotal lookups please set " + "MVT_VT_API_KEY or VT_API_KEY in the MVT configuration file" ) headers = { "User-Agent": "VirusTotal", "Content-Type": "application/json", - "x-apikey": os.environ[MVT_VT_API_KEY], + "x-apikey": settings.VT_API_KEY, } res = requests.get( - f"https://www.virustotal.com/api/v3/files/{file_hash}", headers=headers + f"https://www.virustotal.com/api/v3/files/{file_hash}", + headers=headers, + timeout=settings.NETWORK_TIMEOUT, ) if res.status_code == 200: @@ -47,6 +48,6 @@ def virustotal_lookup(file_hash: str): elif res.status_code == 429: raise VTQuotaExceeded("You have exceeded the quota for your VirusTotal API key") else: - raise Exception(f"Unexpected response from VirusTotal: {res.status_code}") + raise RuntimeError(f"Unexpected response from VirusTotal: {res.status_code}") return None diff --git a/src/mvt/ios/cli.py b/src/mvt/ios/cli.py index 3cb3421..c338fa5 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -8,9 +8,19 @@ import logging import os import click -from rich.prompt import Prompt - +from mvt.common.cli_plugins import ( + IOS_CLI_PLUGIN_GROUP, + MVT_IOS_CUSTOM_COMMANDS_ENV, + load_cli_commands_option, + register_cli_plugins, +) 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,17 +41,23 @@ 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, HELP_MSG_CHECK_IOCS, HELP_MSG_STIX2, HELP_MSG_CHECK_IOS_BACKUP, + HELP_MSG_CHECK_SYSDIAGNOSE, 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 mvt.common.password import prompt_password from .cmd_check_backup import CmdIOSCheckBackup from .cmd_check_fs import CmdIOSCheckFS +from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose from .decrypt import DecryptBackup from .modules.backup import BACKUP_MODULES from .modules.fs import FS_MODULES @@ -65,10 +81,18 @@ 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 # ============================================================================== @click.group(invoke_without_command=False) +@load_cli_commands_option @click.option( "--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK ) @@ -82,10 +106,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 +121,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 # ============================================================================== @@ -150,7 +209,7 @@ def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path): log.info("Using password from %s environment variable", MVT_IOS_BACKUP_PASSWORD) backup.decrypt_with_password(os.environ[MVT_IOS_BACKUP_PASSWORD]) else: - sekrit = Prompt.ask("Enter backup password", password=True) + sekrit = prompt_password("Enter backup password: ") backup.decrypt_with_password(sekrit) if not backup.can_process(): @@ -202,7 +261,7 @@ def extract_key(password, key_file, backup_path): log.info("Using password from %s environment variable", MVT_IOS_BACKUP_PASSWORD) password = os.environ[MVT_IOS_BACKUP_PASSWORD] else: - password = Prompt.ask("Enter backup password", password=True) + password = prompt_password("Enter backup password: ") backup.decrypt_with_password(password) backup.get_key() @@ -229,15 +288,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,20 +324,21 @@ 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: cmd.list_modules() return - log.info("Checking iTunes backup located at: %s", backup_path) + if not cmd.resolve_backup_path(): + ctx.exit(1) + + log.info("Checking iTunes backup located at: %s", cmd.target_path) cmd.run() - - if cmd.detected_count > 0: - log.warning( - "The analysis of the backup produced %d detections!", cmd.detected_count - ) + cmd.show_alerts_brief() + cmd.show_support_message() # ============================================================================== @@ -280,13 +357,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, @@ -297,6 +393,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: @@ -306,13 +403,80 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum log.info("Checking iOS filesystem located at: %s", dump_path) cmd.run() + cmd.show_alerts_brief() + cmd.show_support_message() - if cmd.detected_count > 0: - log.warning( - "The analysis of the iOS filesystem produced %d detections!", - cmd.detected_count, + +# ============================================================================== +# Command: check-sysdiagnose +# ============================================================================== +@cli.command( + "check-sysdiagnose", + context_settings=CONTEXT_SETTINGS, + help=HELP_MSG_CHECK_SYSDIAGNOSE, +) +@click.option( + "--iocs", + "-i", + type=click.Path(exists=True), + multiple=True, + default=[], + help=HELP_MSG_IOC, +) +@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("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) +@click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True)) +@click.pass_context +def check_sysdiagnose( + ctx, + iocs, + output, + list_modules, + module, + load_module, + hashes, + verbose, + sysdiagnose_path, +): + set_verbose_logging(verbose) + custom_modules = _load_custom_modules(load_module) + cmd = CmdIOSCheckSysdiagnose( + target_path=sysdiagnose_path, + results_path=output, + ioc_files=iocs, + module_name=module, + hashes=hashes, + disable_version_check=_get_disable_flags(ctx)[0], + disable_indicator_check=_get_disable_flags(ctx)[1], + custom_modules=custom_modules, + ) + + if not cmd._available_modules(): + raise click.ClickException( + "No custom modules support mvt-ios check-sysdiagnose. " + "Load a module that declares supported_commands = " + "((\"ios\", \"check-sysdiagnose\"),)." ) + if list_modules: + cmd.list_modules() + return + + log.info("Checking iOS sysdiagnose at path: %s", sysdiagnose_path) + cmd.run() + cmd.show_alerts_brief() + cmd.show_support_message() + # ============================================================================== # Command: check-iocs @@ -328,15 +492,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 @@ -345,6 +519,8 @@ def check_iocs(ctx, iocs, list_modules, module, folder): return cmd.run() + cmd.show_alerts_brief() + cmd.show_support_message() # ============================================================================== @@ -354,3 +530,10 @@ def check_iocs(ctx, iocs, list_modules, module, folder): def download_iocs(): ioc_updates = IndicatorsUpdates() ioc_updates.update() + + +register_cli_plugins( + cli, + entry_point_group=IOS_CLI_PLUGIN_GROUP, + environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV, +) diff --git a/src/mvt/ios/cmd_check_backup.py b/src/mvt/ios/cmd_check_backup.py index 9200964..1b90584 100644 --- a/src/mvt/ios/cmd_check_backup.py +++ b/src/mvt/ios/cmd_check_backup.py @@ -4,10 +4,12 @@ # https://license.mvt.re/1.1/ import logging +import os 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 @@ -15,6 +17,12 @@ from .modules.mixed import MIXED_MODULES log = logging.getLogger(__name__) +def is_ios_backup_folder(path: str) -> bool: + return os.path.isfile(os.path.join(path, "Manifest.db")) and os.path.isfile( + os.path.join(path, "Info.plist") + ) + + class CmdIOSCheckBackup(Command): def __init__( self, @@ -29,6 +37,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,10 +52,53 @@ 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 + def resolve_backup_path(self) -> bool: + target_path = getattr(self, "target_path", None) + if not isinstance(target_path, str) or not target_path: + return False + + if is_ios_backup_folder(target_path): + return True + + if not os.path.isdir(target_path): + self.log.critical( + "%s does not appear to be an iTunes backup folder. " + "Expected Manifest.db and Info.plist.", + target_path, + ) + return False + + candidates = [] + for entry_name in sorted(os.listdir(target_path)): + entry_path = os.path.join(target_path, entry_name) + if os.path.isdir(entry_path) and is_ios_backup_folder(entry_path): + candidates.append(entry_path) + + if len(candidates) == 1: + self.log.info("Found iTunes backup in subfolder: %s", candidates[0]) + self.target_path = candidates[0] + return True + + if candidates: + self.log.critical( + "Found multiple iTunes backups in %s. Please specify one backup folder.", + target_path, + ) + return False + + self.log.critical( + "%s does not appear to be an iTunes backup folder. " + "Expected Manifest.db and Info.plist.", + target_path, + ) + return False + def module_init(self, module): module.is_backup = True diff --git a/src/mvt/ios/cmd_check_fs.py b/src/mvt/ios/cmd_check_fs.py index 78325ba..e76146e 100644 --- a/src/mvt/ios/cmd_check_fs.py +++ b/src/mvt/ios/cmd_check_fs.py @@ -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 diff --git a/src/mvt/ios/cmd_check_sysdiagnose.py b/src/mvt/ios/cmd_check_sysdiagnose.py new file mode 100644 index 0000000..05d5b6b --- /dev/null +++ b/src/mvt/ios/cmd_check_sysdiagnose.py @@ -0,0 +1,173 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import json +import logging +import os +import shutil +import tarfile +from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory +from typing import Any, Optional + +from mvt.common.command import Command +from mvt.common.indicators import Indicators +from mvt.common.module import MVTModule + +log = logging.getLogger(__name__) + + +class CmdIOSCheckSysdiagnose(Command): + def __init__( + self, + target_path: Optional[str] = None, + results_path: Optional[str] = None, + ioc_files: Optional[list] = None, + iocs: Optional[Indicators] = None, + module_name: Optional[str] = None, + serial: Optional[str] = None, + module_options: Optional[dict] = None, + hashes: bool = False, + 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, + results_path=results_path, + ioc_files=ioc_files, + iocs=iocs, + module_name=module_name, + serial=serial, + module_options=module_options, + hashes=hashes, + sub_command=sub_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-sysdiagnose" + self.sysdiagnose_format: Optional[str] = None + self.sysdiagnose_archive: Optional[tarfile.TarFile] = None + self.sysdiagnose_files: list[str] = [] + self.ips_files: list[dict[str, Any]] = [] + self.temp_sysdiagnose_dir: Optional[TemporaryDirectory[str]] = None + self.extracted_sysdiagnose_path: Optional[str] = None + + @staticmethod + def _parse_bugtype_header(data: bytes) -> Optional[int]: + try: + header = json.loads(data.split(b"\n", 1)[0].decode("utf-8")) + return int(header["bug_type"]) + except (json.JSONDecodeError, KeyError, UnicodeDecodeError, ValueError): + return None + + def _add_ips_file(self, file_path: str, data: bytes) -> None: + bug_type = self._parse_bugtype_header(data) + if bug_type is not None: + self.ips_files.append({"file_path": file_path, "bug_type": bug_type}) + + def init(self) -> None: + if not self.target_path: + raise ValueError("A sysdiagnose path is required") + + if os.path.isdir(self.target_path): + self.sysdiagnose_format = "dir" + parent_path = Path(self.target_path).absolute().parent + for root, _, filenames in os.walk(self.target_path): + for filename in filenames: + absolute_path = os.path.join(root, filename) + file_path = os.path.relpath(absolute_path, parent_path) + self.sysdiagnose_files.append(file_path) + if filename.endswith(".ips"): + with open(absolute_path, "rb") as handle: + self._add_ips_file(absolute_path, handle.read()) + return + + if not os.path.isfile(self.target_path): + raise ValueError(f"Sysdiagnose path does not exist: {self.target_path}") + + self.log.info("Parsing sysdiagnose archive. This might take a while...") + self.sysdiagnose_format = "tar" + self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz") + self._extract_sysdiagnose_archive() + + def _extract_sysdiagnose_archive(self) -> None: + archive = self.sysdiagnose_archive + if archive is None: + raise RuntimeError("Sysdiagnose archive has not been initialized") + + self.temp_sysdiagnose_dir = TemporaryDirectory() + extraction_root = Path(self.temp_sysdiagnose_dir.name).resolve() + archive_roots = set() + + for member in archive: + member_path = PurePosixPath(member.name.replace("\\", "/")) + if member_path.is_absolute() or ".." in member_path.parts: + self.log.warning("Skipping unsafe sysdiagnose path %r", member.name) + continue + + destination = extraction_root.joinpath(*member_path.parts).resolve() + if not destination.is_relative_to(extraction_root): + self.log.warning("Skipping unsafe sysdiagnose path %r", member.name) + continue + + if not member_path.parts: + continue + archive_roots.add(member_path.parts[0]) + + if member.isdir(): + destination.mkdir(parents=True, exist_ok=True) + continue + + # Modules only need directories and regular files. Do not materialize + # links or device nodes from an untrusted sysdiagnose archive. + if not member.isfile(): + self.log.warning("Skipping unsafe sysdiagnose member %r", member.name) + continue + + normalized_name = member_path.as_posix() + self.sysdiagnose_files.append(normalized_name) + + source = archive.extractfile(member) + if source is None: + continue + + destination.parent.mkdir(parents=True, exist_ok=True) + with source, destination.open("wb") as output: + shutil.copyfileobj(source, output) + + if normalized_name.endswith(".ips"): + self._add_ips_file(str(destination), destination.read_bytes()) + + if len(archive_roots) != 1: + raise ValueError("Sysdiagnose archive must contain one top-level directory") + + self.extracted_sysdiagnose_path = str(extraction_root / archive_roots.pop()) + + def module_init(self, module) -> None: + module.ips_files = self.ips_files + if self.sysdiagnose_format == "tar": + if self.extracted_sysdiagnose_path is None: + raise RuntimeError("Sysdiagnose archive has not been extracted") + module.from_sysdiagnose_folder( + self.extracted_sysdiagnose_path, self.sysdiagnose_files + ) + return + if self.sysdiagnose_format == "dir" and self.target_path: + module.from_sysdiagnose_folder(self.target_path, self.sysdiagnose_files) + return + raise RuntimeError("Sysdiagnose input has not been initialized") + + def finish(self) -> None: + if self.sysdiagnose_archive is not None: + self.sysdiagnose_archive.close() + self.sysdiagnose_archive = None + if self.temp_sysdiagnose_dir is not None: + self.temp_sysdiagnose_dir.cleanup() + self.temp_sysdiagnose_dir = None diff --git a/src/mvt/ios/data/ios_versions.json b/src/mvt/ios/data/ios_versions.json index ea32c9f..16fbba5 100644 --- a/src/mvt/ios/data/ios_versions.json +++ b/src/mvt/ios/data/ios_versions.json @@ -911,6 +911,10 @@ "version": "15.8.7", "build": "19H411" }, + { + "version": "15.8.8", + "build": "19H422" + }, { "build": "20A362", "version": "16.0" @@ -1028,6 +1032,10 @@ "version": "16.7.15", "build": "20H380" }, + { + "version": "16.7.16", + "build": "20H392" + }, { "version": "17.0", "build": "21A327" @@ -1200,6 +1208,18 @@ "version": "18.7.7", "build": "22H333" }, + { + "version": "18.7.8", + "build": "22H352" + }, + { + "version": "18.7.9", + "build": "22H355" + }, + { + "version": "18.7.10", + "build": "22H373" + }, { "version": "26", "build": "23A341" @@ -1231,5 +1251,17 @@ { "version": "26.4", "build": "23E246" + }, + { + "version": "26.4.2", + "build": "23E261" + }, + { + "version": "26.5", + "build": "23F77" + }, + { + "version": "26.6.1", + "build": "23G82" } ] \ No newline at end of file diff --git a/src/mvt/ios/decrypt.py b/src/mvt/ios/decrypt.py index d615d05..2ed3efe 100644 --- a/src/mvt/ios/decrypt.py +++ b/src/mvt/ios/decrypt.py @@ -12,10 +12,12 @@ import plistlib import shutil import sqlite3 import tempfile +from pathlib import Path from typing import Optional from iphone_backup_decrypt import EncryptedBackup from iphone_backup_decrypt import google_iphone_dataprotection +from iphone_backup_decrypt.utils import FilePlist log = logging.getLogger(__name__) @@ -106,6 +108,28 @@ class MVTEncryptedBackup(EncryptedBackup): raise ValueError("No derived key available") return self._derived_key.hex() + def extract_file_by_id(self, *, file_id, file_bplist, output_filename): + """Extract one manifest entry without loading the whole file into memory.""" + self._read_and_unlock_keybag() + file_plist = FilePlist(file_bplist) + + if file_plist.encryption_key is None: + source_filename = os.path.join( + self._backup_directory, file_id[:2], file_id + ) + shutil.copy2(source_filename, output_filename) + return + + inner_key = self._keybag.unwrapKeyForClass( + file_plist.protection_class, file_plist.encryption_key + ) + self._decrypt_file_to_disk( + file_id=file_id, + key=inner_key, + file_plist=file_plist, + output_filepath=output_filename, + ) + def _unlock_keybag_with_derived_key(keybag, passphrase_key): """Unlock keybag class keys using a pre-derived passphrase_key, @@ -161,8 +185,8 @@ class DecryptBackup: """ self.backup_path = os.path.abspath(backup_path) self.dest_path = dest_path - self._backup = None - self._decryption_key = None + self._backup: Optional[MVTEncryptedBackup] = None + self._decryption_key: Optional[str] = None def can_process(self) -> bool: return self._backup is not None @@ -175,16 +199,21 @@ class DecryptBackup: """ conn = sqlite3.connect(os.path.join(backup_path, "Manifest.db")) - cur = conn.cursor() try: + cur = conn.cursor() cur.execute("SELECT fileID FROM Files LIMIT 1;") except sqlite3.DatabaseError: return True else: log.critical("The backup does not seem encrypted!") return False + finally: + conn.close() def process_backup(self) -> None: + assert self._backup is not None + assert self.dest_path is not None + if not os.path.exists(self.dest_path): os.makedirs(self.dest_path) @@ -195,6 +224,8 @@ class DecryptBackup: # Iterate over all files in the backup and decrypt them, # preserving the XX/file_id directory structure that downstream # modules expect. + backup_root = Path(self.backup_path).resolve() + dest_root = Path(self.dest_path).resolve() with self._backup.manifest_db_cursor() as cur: cur.execute( "SELECT fileID, domain, relativePath, file FROM Files WHERE flags=1" @@ -202,9 +233,10 @@ class DecryptBackup: for file_id, domain, relative_path, file_bplist in cur: # This may be a partial backup. Skip files from the manifest # which do not exist locally. - source_file_path = os.path.join( - self.backup_path, file_id[:2], file_id - ) + source_file_path = backup_root / file_id[:2] / file_id + if not source_file_path.resolve().is_relative_to(backup_root): + log.warning("Skipping unsafe file_id: %r", file_id) + continue if not os.path.exists(source_file_path): log.debug( "Skipping file %s. File not found in encrypted backup directory.", @@ -212,22 +244,23 @@ class DecryptBackup: ) continue - item_folder = os.path.join(self.dest_path, file_id[:2]) - os.makedirs(item_folder, exist_ok=True) + output_path = dest_root / file_id[:2] / file_id + if not output_path.resolve().is_relative_to(dest_root): + log.warning("Skipping unsafe file_id: %r", file_id) + continue + output_path.parent.mkdir(parents=True, exist_ok=True) try: - decrypted = self._backup._decrypt_inner_file( - file_id=file_id, file_bplist=file_bplist + self._backup.extract_file_by_id( + file_id=file_id, + file_bplist=file_bplist, + output_filename=str(output_path), ) - with open( - os.path.join(item_folder, file_id), "wb" - ) as handle: - handle.write(decrypted) log.info( "Decrypted file %s [%s] to %s/%s", relative_path, domain, - item_folder, + output_path.parent, file_id, ) except Exception as exc: diff --git a/src/mvt/ios/modules/backup/backup_info.py b/src/mvt/ios/modules/backup/backup_info.py index c8f55f6..6aadd45 100644 --- a/src/mvt/ios/modules/backup/backup_info.py +++ b/src/mvt/ios/modules/backup/backup_info.py @@ -9,6 +9,7 @@ import plistlib from typing import Optional from mvt.common.module import DatabaseNotFoundError +from mvt.common.module_types import ModuleResults from mvt.ios.versions import get_device_desc_from_id, is_ios_version_outdated from ..base import IOSExtraction @@ -24,7 +25,7 @@ class BackupInfo(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -35,9 +36,11 @@ class BackupInfo(IOSExtraction): results=results, ) - self.results = {} + self.results: dict = results if results is not None else {} def run(self) -> None: + if not self.target_path: + raise DatabaseNotFoundError("target_path is not set") info_path = os.path.join(self.target_path, "Info.plist") if not os.path.exists(info_path): raise DatabaseNotFoundError( diff --git a/src/mvt/ios/modules/backup/configuration_profiles.py b/src/mvt/ios/modules/backup/configuration_profiles.py index 3866971..9c0343e 100644 --- a/src/mvt/ios/modules/backup/configuration_profiles.py +++ b/src/mvt/ios/modules/backup/configuration_profiles.py @@ -7,8 +7,13 @@ import logging import os import plistlib from base64 import b64encode -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_datetime_to_iso from ..base import IOSExtraction @@ -28,7 +33,7 @@ class ConfigurationProfiles(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -39,7 +44,7 @@ class ConfigurationProfiles(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: if not record["install_date"]: return {} @@ -63,30 +68,52 @@ class ConfigurationProfiles(IOSExtraction): # Alert on any known malicious configuration profiles in the # indicator list. - ioc = self.indicators.check_profile(result["plist"]["PayloadUUID"]) - if ioc: - self.log.warning( - "Found a known malicious configuration " - 'profile "%s" with UUID %s', - result["plist"]["PayloadDisplayName"], - result["plist"]["PayloadUUID"], + ioc_match = self.indicators.check_profile( + result["plist"]["PayloadUUID"] + ) + if ioc_match: + warning_message = f'Found a known malicious configuration profile "{result["plist"]["PayloadDisplayName"]}" with UUID "{result["plist"]["PayloadUUID"]}"' + self.alertstore.critical( + warning_message, "", result, matched_indicator=ioc_match.ioc ) - result["matched_indicator"] = ioc - self.detected.append(result) continue # Highlight suspicious configuration profiles which may be used # to hide notifications. if payload_content["PayloadType"] in ["com.apple.notificationsettings"]: - self.log.warning( - "Found a potentially suspicious configuration profile " - '"%s" with payload type %s', - result["plist"]["PayloadDisplayName"], - payload_content["PayloadType"], - ) - self.detected.append(result) + warning_message = f'Found a potentially suspicious configuration profile "{result["plist"]["PayloadDisplayName"]}" with payload type {payload_content["PayloadType"]}' + self.alertstore.medium(warning_message, "", result) continue + @staticmethod + def _b64encode_key(d: dict, key: str) -> None: + if key in d: + d[key] = b64encode(d[key]) + + @staticmethod + def _b64encode_keys(d: dict, keys: list) -> None: + for key in keys: + if key in d: + d[key] = b64encode(d[key]) + + def _b64encode_plist_bytes(self, plist: dict) -> None: + """Encode binary plist values to base64 for JSON serialization.""" + if "SignerCerts" in plist: + plist["SignerCerts"] = [b64encode(x) for x in plist["SignerCerts"]] + + self._b64encode_keys(plist, ["PushTokenDataSentToServerKey", "LastPushTokenHash"]) + + if "OTAProfileStub" in plist: + stub = plist["OTAProfileStub"] + if "SignerCerts" in stub: + stub["SignerCerts"] = [b64encode(x) for x in stub["SignerCerts"]] + if "PayloadContent" in stub: + self._b64encode_key(stub["PayloadContent"], "EnrollmentIdentityPersistentID") + + if "PayloadContent" in plist: + for entry in plist["PayloadContent"]: + self._b64encode_keys(entry, ["PERSISTENT_REF", "IdentityPersistentRef"]) + def run(self) -> None: for conf_file in self._get_backup_files_from_manifest( domain=CONF_PROFILES_DOMAIN @@ -115,65 +142,7 @@ class ConfigurationProfiles(IOSExtraction): except Exception: conf_plist = {} - # TODO: Tidy up the following code hell. - - if "SignerCerts" in conf_plist: - conf_plist["SignerCerts"] = [ - b64encode(x) for x in conf_plist["SignerCerts"] - ] - - if "OTAProfileStub" in conf_plist: - if "SignerCerts" in conf_plist["OTAProfileStub"]: - conf_plist["OTAProfileStub"]["SignerCerts"] = [ - b64encode(x) - for x in conf_plist["OTAProfileStub"]["SignerCerts"] - ] - - if "PayloadContent" in conf_plist["OTAProfileStub"]: - if ( - "EnrollmentIdentityPersistentID" - in conf_plist["OTAProfileStub"]["PayloadContent"] - ): - conf_plist["OTAProfileStub"]["PayloadContent"][ - "EnrollmentIdentityPersistentID" - ] = b64encode( - conf_plist["OTAProfileStub"]["PayloadContent"][ - "EnrollmentIdentityPersistentID" - ] - ) - - if "PushTokenDataSentToServerKey" in conf_plist: - conf_plist["PushTokenDataSentToServerKey"] = b64encode( - conf_plist["PushTokenDataSentToServerKey"] - ) - - if "LastPushTokenHash" in conf_plist: - conf_plist["LastPushTokenHash"] = b64encode( - conf_plist["LastPushTokenHash"] - ) - - if "PayloadContent" in conf_plist: - for content_entry in range(len(conf_plist["PayloadContent"])): - if "PERSISTENT_REF" in conf_plist["PayloadContent"][content_entry]: - conf_plist["PayloadContent"][content_entry][ - "PERSISTENT_REF" - ] = b64encode( - conf_plist["PayloadContent"][content_entry][ - "PERSISTENT_REF" - ] - ) - - if ( - "IdentityPersistentRef" - in conf_plist["PayloadContent"][content_entry] - ): - conf_plist["PayloadContent"][content_entry][ - "IdentityPersistentRef" - ] = b64encode( - conf_plist["PayloadContent"][content_entry][ - "IdentityPersistentRef" - ] - ) + self._b64encode_plist_bytes(conf_plist) self.results.append( { diff --git a/src/mvt/ios/modules/backup/manifest.py b/src/mvt/ios/modules/backup/manifest.py index da5c1a8..cc74cbb 100644 --- a/src/mvt/ios/modules/backup/manifest.py +++ b/src/mvt/ios/modules/backup/manifest.py @@ -12,6 +12,11 @@ import sqlite3 from typing import Optional from mvt.common.module import DatabaseNotFoundError, EncryptedBackupError +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.url import URL from mvt.common.utils import convert_datetime_to_iso, convert_unix_to_iso @@ -28,7 +33,7 @@ class Manifest(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -61,8 +66,8 @@ class Manifest(IOSExtraction): return convert_unix_to_iso(timestamp_or_unix_time_int) - def serialize(self, record: dict) -> []: - records = [] + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: + records: list = [] if "modified" not in record or "status_changed" not in record: return records @@ -96,8 +101,11 @@ class Manifest(IOSExtraction): if not self.indicators: continue - if self.indicators.check_file_path("/" + result["relative_path"]): - self.detected.append(result) + ioc_match = self.indicators.check_file_path("/" + result["relative_path"]) + if ioc_match: + self.alertstore.high( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue rel_path = result["relative_path"].lower() @@ -108,17 +116,18 @@ class Manifest(IOSExtraction): except Exception: continue - ioc = self.indicators.check_url(part) - if ioc: - self.log.warning( - 'Found mention of domain "%s" in a backup file with path: %s', - ioc["value"], - rel_path, + ioc_match = self.indicators.check_url(part) + if ioc_match: + self.alertstore.high( + f'Found mention of domain "{ioc_match.ioc.value}" in a backup file with path: {rel_path}', + "", + result, + matched_indicator=ioc_match.ioc, ) - result["matched_indicator"] = ioc - self.detected.append(result) def run(self) -> None: + if not self.target_path: + raise DatabaseNotFoundError("target_path is not set") manifest_db_path = os.path.join(self.target_path, "Manifest.db") if not os.path.isfile(manifest_db_path): raise DatabaseNotFoundError("unable to find backup's Manifest.db") diff --git a/src/mvt/ios/modules/backup/profile_events.py b/src/mvt/ios/modules/backup/profile_events.py index eeb91e2..1fc6d7e 100644 --- a/src/mvt/ios/modules/backup/profile_events.py +++ b/src/mvt/ios/modules/backup/profile_events.py @@ -5,8 +5,13 @@ import logging import plistlib -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_datetime_to_iso from ..base import IOSExtraction @@ -29,7 +34,7 @@ class ProfileEvents(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -40,7 +45,7 @@ class ProfileEvents(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record.get("timestamp"), "module": self.__class__.__name__, @@ -51,24 +56,30 @@ class ProfileEvents(IOSExtraction): } def check_indicators(self) -> None: + for result in self.results: + message = f'On {result.get("timestamp")} process "{result.get("process")}" started operation "{result.get("operation")}" of profile "{result.get("profile_id")}"' + self.alertstore.low(message, result.get("timestamp") or "", result) + if not self.indicators: return for result in self.results: - ioc = self.indicators.check_process(result.get("process")) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_process(result.get("process") or "") + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue - ioc = self.indicators.check_profile(result.get("profile_id")) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_profile(result.get("profile_id") or "") + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) @staticmethod def parse_profile_events(file_data: bytes) -> list: - results = [] + results: list = [] events_plist = plistlib.loads(file_data) @@ -109,13 +120,4 @@ class ProfileEvents(IOSExtraction): with open(events_file_path, "rb") as handle: self.results.extend(self.parse_profile_events(handle.read())) - for result in self.results: - self.log.info( - 'On %s process "%s" started operation "%s" of profile "%s"', - result.get("timestamp"), - result.get("process"), - result.get("operation"), - result.get("profile_id"), - ) - self.log.info("Extracted %d profile events", len(self.results)) diff --git a/src/mvt/ios/modules/base.py b/src/mvt/ios/modules/base.py index f96d99a..a5c3354 100644 --- a/src/mvt/ios/modules/base.py +++ b/src/mvt/ios/modules/base.py @@ -9,9 +9,30 @@ import os import shutil import sqlite3 import subprocess +import tempfile +from pathlib import Path from typing import Iterator, Optional, Union -from mvt.common.module import DatabaseCorruptedError, DatabaseNotFoundError, MVTModule +from mvt.common.module import ( + DatabaseCorruptedError, + DatabaseNotFoundError, + ModuleResults, + MVTModule, +) + + +class TemporarySQLiteConnection(sqlite3.Connection): + """SQLite connection that owns a temporary copy of a database.""" + + temporary_directory: Optional[tempfile.TemporaryDirectory] = None + + def close(self) -> None: + try: + super().close() + finally: + if self.temporary_directory: + self.temporary_directory.cleanup() + self.temporary_directory = None class IOSExtraction(MVTModule): @@ -25,7 +46,7 @@ class IOSExtraction(MVTModule): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -38,6 +59,8 @@ class IOSExtraction(MVTModule): self.is_backup = False self.is_fs_dump = False + self._recovered_sqlite_paths: dict[str, str] = {} + self._sqlite_temp_directories: list[tempfile.TemporaryDirectory] = [] def _recover_sqlite_db_if_needed( self, file_path: str, forced: bool = False @@ -47,17 +70,12 @@ class IOSExtraction(MVTModule): :param file_path: Path to the malformed database file. """ - # TODO: Find a better solution. if not forced: - # If the database is open, do not use immutable - if os.path.isfile(file_path + "-shm"): - conn = sqlite3.connect(file_path) - else: - conn = self._open_sqlite_db(file_path) + conn = self._open_sqlite_db(file_path) cur = conn.cursor() + recover = False try: - recover = False cur.execute("SELECT name FROM sqlite_master WHERE type='table';") except sqlite3.DatabaseError as exc: if "database disk image is malformed" in str(exc): @@ -76,27 +94,54 @@ class IOSExtraction(MVTModule): raise DatabaseCorruptedError( "failed to recover without sqlite3 binary: please install sqlite3!" ) - if '"' in file_path: - raise DatabaseCorruptedError( - f"database at path '{file_path}' is corrupted. unable to " - 'recover because it has a quotation mark (") in its name' - ) - - bak_path = f"{file_path}.bak" - shutil.move(file_path, bak_path) + temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_recover_") + temporary_path = Path(temporary_directory.name) + source_path = temporary_path / "source.db" + recovered_path = temporary_path / "recovered.db" + shutil.copy2(file_path, source_path) + for suffix in ("-wal", "-shm"): + sidecar = Path(file_path + suffix) + if sidecar.is_file(): + shutil.copy2(sidecar, Path(str(source_path) + suffix)) ret = subprocess.call( - ["sqlite3", bak_path, f'.clone "{file_path}"'], + ["sqlite3", str(source_path), f'.clone "{recovered_path}"'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) if ret != 0: + temporary_directory.cleanup() raise DatabaseCorruptedError("failed to recover database") + self._sqlite_temp_directories.append(temporary_directory) + self._recovered_sqlite_paths[file_path] = str(recovered_path) self.log.info("Database at path %s recovered successfully!", file_path) def _open_sqlite_db(self, file_path: str) -> sqlite3.Connection: - return sqlite3.connect(f"file:{file_path}?immutable=1", uri=True) + database_path = self._recovered_sqlite_paths.get(file_path, file_path) + if not os.path.isfile(database_path + "-wal"): + uri = Path(database_path).resolve().as_uri() + "?mode=ro&immutable=1" + return sqlite3.connect(uri, uri=True) + + temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_") + temporary_path = Path(temporary_directory.name) / Path(database_path).name + shutil.copy2(database_path, temporary_path) + for suffix in ("-wal", "-shm"): + sidecar = Path(database_path + suffix) + if sidecar.is_file(): + shutil.copy2(sidecar, Path(str(temporary_path) + suffix)) + + try: + conn = sqlite3.connect( + temporary_path.resolve().as_uri() + "?mode=ro", + uri=True, + factory=TemporarySQLiteConnection, + ) + except Exception: + temporary_directory.cleanup() + raise + conn.temporary_directory = temporary_directory + return conn def _get_backup_files_from_manifest( self, relative_path: Optional[str] = None, domain: Optional[str] = None @@ -109,12 +154,16 @@ class IOSExtraction(MVTModule): (Default value = None) """ + if not self.target_path: + raise DatabaseNotFoundError("target_path is not set") manifest_db_path = os.path.join(self.target_path, "Manifest.db") if not os.path.exists(manifest_db_path): raise DatabaseNotFoundError("unable to find backup's Manifest.db") base_sql = "SELECT fileID, domain, relativePath FROM Files WHERE " + conn: Optional[sqlite3.Connection] = None + cur: Optional[sqlite3.Cursor] = None try: conn = self._open_sqlite_db(manifest_db_path) cur = conn.cursor() @@ -134,24 +183,42 @@ class IOSExtraction(MVTModule): cur.execute(f"{base_sql} relativePath = ?;", (relative_path,)) elif domain: cur.execute(f"{base_sql} domain = ?;", (domain,)) + records = [ + { + "file_id": row[0], + "domain": row[1], + "relative_path": row[2], + } + for row in cur + ] except Exception as exc: raise DatabaseCorruptedError(f"failed to query Manifest.db: {exc}") from exc + finally: + if cur: + cur.close() + if conn: + conn.close() - for row in cur: - yield { - "file_id": row[0], - "domain": row[1], - "relative_path": row[2], - } + return iter(records) def _get_backup_file_from_id(self, file_id: str) -> Union[str, None]: + if not self.target_path: + return None file_path = os.path.join(self.target_path, file_id[0:2], file_id) + if ( + not Path(file_path) + .resolve() + .is_relative_to(Path(self.target_path).resolve()) + ): + return None if os.path.exists(file_path): return file_path return None def _get_fs_files_from_patterns(self, root_paths: list) -> Iterator[str]: + if not self.target_path: + return for root_path in root_paths: for found_path in glob.glob(os.path.join(self.target_path, root_path)): if not os.path.exists(found_path): @@ -173,9 +240,10 @@ class IOSExtraction(MVTModule): :param backup_ids: Default value = None) """ - file_path = None + file_path: Optional[str] = self.file_path # First we check if the was an explicit file path specified. - if not self.file_path: + if not file_path: + # Type narrowing: we know self.file_path is None here, work with local file_path # If not, we first try with backups. # We construct the path to the file according to the iTunes backup # folder structure, if we have a valid ID. @@ -197,8 +265,9 @@ class IOSExtraction(MVTModule): # If we do not find any, we fail. if file_path: - self.file_path = file_path + self.file_path = file_path # type: str else: raise DatabaseNotFoundError("unable to find the module's database file") + assert self.file_path is not None self._recover_sqlite_db_if_needed(self.file_path) diff --git a/src/mvt/ios/modules/fs/analytics.py b/src/mvt/ios/modules/fs/analytics.py index fecab57..2bf29e4 100644 --- a/src/mvt/ios/modules/fs/analytics.py +++ b/src/mvt/ios/modules/fs/analytics.py @@ -7,8 +7,13 @@ import copy import logging import plistlib import sqlite3 -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_mactime_to_iso from ..base import IOSExtraction @@ -29,7 +34,7 @@ class Analytics(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -39,8 +44,9 @@ class Analytics(IOSExtraction): log=log, results=results, ) + self.results: list = [] if results is None else results - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -57,24 +63,24 @@ class Analytics(IOSExtraction): if not isinstance(value, str): continue - ioc = self.indicators.check_process(value) - if ioc: - self.log.warning( - 'Found mention of a malicious process "%s" in %s file at %s', - value, - result["artifact"], - result["isodate"], - ) + ioc_match = self.indicators.check_process(value) + if ioc_match: + warning_message = f'Found mention of a malicious process "{value}" in {result["artifact"]} file at {result["isodate"]}' new_result = copy.copy(result) - new_result["matched_indicator"] = ioc - self.detected.append(new_result) + self.alertstore.critical( + warning_message, "", new_result, matched_indicator=ioc_match.ioc + ) continue - ioc = self.indicators.check_url(value) - if ioc: + ioc_match = self.indicators.check_url(value) + if ioc_match: new_result = copy.copy(result) - new_result["matched_indicator"] = ioc - self.detected.append(new_result) + self.alertstore.critical( + ioc_match.message, + "", + new_result, + matched_indicator=ioc_match.ioc, + ) def _extract_analytics_data(self): artifact = self.file_path.split("/")[-1] @@ -128,6 +134,8 @@ class Analytics(IOSExtraction): isodate = "" data = plistlib.loads(row[1]) data["isodate"] = isodate + else: + continue data["artifact"] = artifact diff --git a/src/mvt/ios/modules/fs/analytics_ios_versions.py b/src/mvt/ios/modules/fs/analytics_ios_versions.py index 16ac9fc..783bb45 100644 --- a/src/mvt/ios/modules/fs/analytics_ios_versions.py +++ b/src/mvt/ios/modules/fs/analytics_ios_versions.py @@ -5,9 +5,14 @@ import logging from datetime import datetime -from typing import Optional, Union +from typing import Optional from mvt.ios.versions import find_version_by_build +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleSerializedResult, + ModuleResults, +) from ..base import IOSExtraction from .analytics import Analytics @@ -25,7 +30,7 @@ class AnalyticsIOSVersions(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -36,7 +41,7 @@ class AnalyticsIOSVersions(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, diff --git a/src/mvt/ios/modules/fs/cache_files.py b/src/mvt/ios/modules/fs/cache_files.py index 120ed1d..54a34eb 100644 --- a/src/mvt/ios/modules/fs/cache_files.py +++ b/src/mvt/ios/modules/fs/cache_files.py @@ -6,7 +6,13 @@ import logging import os import sqlite3 -from typing import Optional, Union +from typing import Optional + +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from ..base import IOSExtraction @@ -19,7 +25,7 @@ class CacheFiles(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -30,7 +36,7 @@ class CacheFiles(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: records = [] for item in self.results[record]: records.append( @@ -48,18 +54,17 @@ class CacheFiles(IOSExtraction): if not self.indicators: return - self.detected = {} for key, values in self.results.items(): for value in values: - ioc = self.indicators.check_url(value["url"]) - if ioc: - value["matched_indicator"] = ioc - if key not in self.detected: - self.detected[key] = [ - value, - ] - else: - self.detected[key].append(value) + ioc_match = self.indicators.check_url(value["url"]) + if ioc_match: + value["cache_file"] = key + self.alertstore.critical( + ioc_match.message, + value.get("isodate", ""), + value, + matched_indicator=ioc_match.ioc, + ) def _process_cache_file(self, file_path): self.log.info("Processing cache file at path: %s", file_path) @@ -68,28 +73,36 @@ class CacheFiles(IOSExtraction): cur = conn.cursor() try: - cur.execute("SELECT * FROM cfurl_cache_response;") - except sqlite3.OperationalError: - return + try: + cur.execute("SELECT * FROM cfurl_cache_response;") + except sqlite3.OperationalError: + return - key_name = os.path.relpath(file_path, self.target_path) - if key_name not in self.results: - self.results[key_name] = [] + key_name = os.path.relpath(file_path, self.target_path) + if key_name not in self.results: + self.results[key_name] = [] - for row in cur: - self.results[key_name].append( - { - "entry_id": row[0], - "version": row[1], - "hash_value": row[2], - "storage_policy": row[3], - "url": row[4], - "isodate": row[5], - } - ) + for row in cur: + self.results[key_name].append( + { + "entry_id": row[0], + "version": row[1], + "hash_value": row[2], + "storage_policy": row[3], + "url": row[4], + "isodate": row[5], + } + ) + finally: + cur.close() + conn.close() def run(self) -> None: - self.results = {} + self.results: dict = {} + if not self.target_path: + self.log.error("No filesystem dump path provided") + return + for root, _, files in os.walk(self.target_path): for file_name in files: if file_name != "Cache.db": diff --git a/src/mvt/ios/modules/fs/filesystem.py b/src/mvt/ios/modules/fs/filesystem.py index 87c5a0b..563d039 100644 --- a/src/mvt/ios/modules/fs/filesystem.py +++ b/src/mvt/ios/modules/fs/filesystem.py @@ -5,9 +5,14 @@ import logging import os -from typing import Optional, Union +from typing import Optional from mvt.common.utils import convert_unix_to_iso +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleSerializedResult, + ModuleResults, +) from ..base import IOSExtraction @@ -24,7 +29,7 @@ class Filesystem(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -35,7 +40,7 @@ class Filesystem(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["modified"], "module": self.__class__.__name__, @@ -51,21 +56,27 @@ class Filesystem(IOSExtraction): if "path" not in result: continue - ioc = self.indicators.check_file_path(result["path"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_file_path(result["path"]) + if ioc_match: + self.alertstore.high( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) # If we are instructed to run fast, we skip the rest. if self.module_options.get("fast_mode", None): continue - ioc = self.indicators.check_file_path_process(result["path"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_file_path_process(result["path"]) + if ioc_match: + self.alertstore.high( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) def run(self) -> None: + if not self.target_path: + self.log.error("No filesystem dump path provided") + return + for root, dirs, files in os.walk(self.target_path): for dir_name in dirs: try: diff --git a/src/mvt/ios/modules/fs/net_netusage.py b/src/mvt/ios/modules/fs/net_netusage.py index ac36a79..91451d6 100644 --- a/src/mvt/ios/modules/fs/net_netusage.py +++ b/src/mvt/ios/modules/fs/net_netusage.py @@ -7,6 +7,7 @@ import logging import sqlite3 from typing import Optional +from mvt.common.module_types import ModuleResults from ..net_base import NetBase NETUSAGE_ROOT_PATHS = [ @@ -29,7 +30,7 @@ class Netusage(NetBase): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/ios/modules/fs/safari_favicon.py b/src/mvt/ios/modules/fs/safari_favicon.py index 72bcc9b..a9c0b65 100644 --- a/src/mvt/ios/modules/fs/safari_favicon.py +++ b/src/mvt/ios/modules/fs/safari_favicon.py @@ -4,8 +4,13 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_mactime_to_iso from ..base import IOSExtraction @@ -26,7 +31,7 @@ class SafariFavicon(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -36,8 +41,9 @@ class SafariFavicon(IOSExtraction): log=log, results=results, ) + self.results: list = [] if results is None else results - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -51,13 +57,14 @@ class SafariFavicon(IOSExtraction): return for result in self.results: - ioc = self.indicators.check_url(result["url"]) - if not ioc: - ioc = self.indicators.check_url(result["icon_url"]) + ioc_match = self.indicators.check_url(result["url"]) + if not ioc_match: + ioc_match = self.indicators.check_url(result["icon_url"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) def _process_favicon_db(self, file_path): conn = self._open_sqlite_db(file_path) diff --git a/src/mvt/ios/modules/fs/shutdownlog.py b/src/mvt/ios/modules/fs/shutdownlog.py index 3d2be78..3136177 100644 --- a/src/mvt/ios/modules/fs/shutdownlog.py +++ b/src/mvt/ios/modules/fs/shutdownlog.py @@ -4,14 +4,21 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module import DatabaseNotFoundError +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_mactime_to_iso from ..base import IOSExtraction SHUTDOWN_LOG_PATH = [ "private/var/db/diagnostics/shutdown.log", + "private/var/db/diagnostics/shutdown.*.log", ] @@ -25,7 +32,7 @@ class ShutdownLog(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -36,7 +43,7 @@ class ShutdownLog(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -50,22 +57,22 @@ class ShutdownLog(IOSExtraction): return for result in self.results: - ioc = self.indicators.check_file_path(result["client"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_file_path(result["client"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue for ioc in self.indicators.get_iocs("processes"): parts = result["client"].split("/") - if ioc in parts: - self.log.warning( - 'Found mention of a known malicious process "%s" in ' - "shutdown.log", - ioc, + if ioc.value in parts: + self.alertstore.critical( + f'Found mention of a known malicious process "{ioc.value}" in shutdown.log', + "", + result, + matched_indicator=ioc, ) - result["matched_indicator"] = ioc - self.detected.append(result) continue def process_shutdownlog(self, content): @@ -73,7 +80,7 @@ class ShutdownLog(IOSExtraction): recent_processes = [] times_delayed = 0 delay = 0.0 - for line in content.split("\n"): + for line in content.splitlines(): line = line.strip() if line.startswith("remaining client pid:"): @@ -127,7 +134,17 @@ class ShutdownLog(IOSExtraction): self.results = sorted(self.results, key=lambda entry: entry["isodate"]) def run(self) -> None: - self._find_ios_database(root_paths=SHUTDOWN_LOG_PATH) - self.log.info("Found shutdown log at path: %s", self.file_path) - with open(self.file_path, "r", encoding="utf-8") as handle: - self.process_shutdownlog(handle.read()) + if self.file_path: + shutdown_log_paths = [self.file_path] + else: + shutdown_log_paths = sorted( + self._get_fs_files_from_patterns(SHUTDOWN_LOG_PATH) + ) + + if not shutdown_log_paths: + raise DatabaseNotFoundError("unable to find any shutdown log files") + + for shutdown_log_path in shutdown_log_paths: + self.log.info("Found shutdown log at path: %s", shutdown_log_path) + with open(shutdown_log_path, "r", encoding="utf-8") as handle: + self.process_shutdownlog(handle.read()) diff --git a/src/mvt/ios/modules/fs/version_history.py b/src/mvt/ios/modules/fs/version_history.py index 44b9b13..c3c583c 100644 --- a/src/mvt/ios/modules/fs/version_history.py +++ b/src/mvt/ios/modules/fs/version_history.py @@ -6,8 +6,13 @@ import datetime import json import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_datetime_to_iso from ..base import IOSExtraction @@ -27,7 +32,7 @@ class IOSVersionHistory(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -37,8 +42,9 @@ class IOSVersionHistory(IOSExtraction): log=log, results=results, ) + self.results: list = [] if results is None else results - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, diff --git a/src/mvt/ios/modules/fs/webkit_base.py b/src/mvt/ios/modules/fs/webkit_base.py index 7e4b245..5cccfd5 100644 --- a/src/mvt/ios/modules/fs/webkit_base.py +++ b/src/mvt/ios/modules/fs/webkit_base.py @@ -18,10 +18,12 @@ class WebkitBase(IOSExtraction): return for result in self.results: - ioc = self.indicators.check_url(result["url"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_url(result["url"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) + continue def _process_webkit_folder(self, root_paths): for found_path in self._get_fs_files_from_patterns(root_paths): diff --git a/src/mvt/ios/modules/fs/webkit_indexeddb.py b/src/mvt/ios/modules/fs/webkit_indexeddb.py index aba91c3..42f0895 100644 --- a/src/mvt/ios/modules/fs/webkit_indexeddb.py +++ b/src/mvt/ios/modules/fs/webkit_indexeddb.py @@ -4,8 +4,13 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from .webkit_base import WebkitBase WEBKIT_INDEXEDDB_ROOT_PATHS = [ @@ -29,7 +34,7 @@ class WebkitIndexedDB(WebkitBase): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -40,7 +45,7 @@ class WebkitIndexedDB(WebkitBase): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, diff --git a/src/mvt/ios/modules/fs/webkit_localstorage.py b/src/mvt/ios/modules/fs/webkit_localstorage.py index dfb117f..d1ad05f 100644 --- a/src/mvt/ios/modules/fs/webkit_localstorage.py +++ b/src/mvt/ios/modules/fs/webkit_localstorage.py @@ -4,8 +4,13 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleResults, + ModuleSerializedResult, + ModuleAtomicResult, +) from .webkit_base import WebkitBase WEBKIT_LOCALSTORAGE_ROOT_PATHS = [ @@ -27,7 +32,7 @@ class WebkitLocalStorage(WebkitBase): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -38,7 +43,7 @@ class WebkitLocalStorage(WebkitBase): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, diff --git a/src/mvt/ios/modules/fs/webkit_safariviewservice.py b/src/mvt/ios/modules/fs/webkit_safariviewservice.py index 9e18c93..62c9481 100644 --- a/src/mvt/ios/modules/fs/webkit_safariviewservice.py +++ b/src/mvt/ios/modules/fs/webkit_safariviewservice.py @@ -6,6 +6,7 @@ import logging from typing import Optional +from mvt.common.module_types import ModuleResults from .webkit_base import WebkitBase WEBKIT_SAFARIVIEWSERVICE_ROOT_PATHS = [ @@ -27,7 +28,7 @@ class WebkitSafariViewService(WebkitBase): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/ios/modules/mixed/applications.py b/src/mvt/ios/modules/mixed/applications.py index 8c15130..45b88cd 100644 --- a/src/mvt/ios/modules/mixed/applications.py +++ b/src/mvt/ios/modules/mixed/applications.py @@ -8,11 +8,17 @@ import logging import os import plistlib from datetime import datetime, timezone -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional from mvt.common.module import DatabaseNotFoundError +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_datetime_to_iso -from mvt.ios.modules.base import IOSExtraction + +from ..base import IOSExtraction APPLICATIONS_DB_PATH = [ "private/var/containers/Bundle/Application/*/iTunesMetadata.plist" @@ -35,7 +41,7 @@ class Applications(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -46,7 +52,7 @@ class Applications(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: if "isodate" in record: return { "timestamp": record["isodate"], @@ -60,41 +66,47 @@ class Applications(IOSExtraction): for result in self.results: if self.indicators: if "softwareVersionBundleId" not in result: - self.log.warning( - "Suspicious application identified without softwareVersionBundleId" + self.alertstore.medium( + "Suspicious application identified without softwareVersionBundleId", + "", + result, ) - self.detected.append(result) continue - ioc = self.indicators.check_process(result["softwareVersionBundleId"]) - if ioc: - self.log.warning( - "Malicious application %s identified", - result["softwareVersionBundleId"], + ioc_match = self.indicators.check_process( + result["softwareVersionBundleId"] + ) + if ioc_match: + self.alertstore.critical( + f"Malicious application {result['softwareVersionBundleId']} identified", + "", + result, + matched_indicator=ioc_match.ioc, ) - result["matched_indicator"] = ioc - self.detected.append(result) continue - ioc = self.indicators.check_app_id(result["softwareVersionBundleId"]) - if ioc: - self.log.warning( - "Malicious application %s identified", - result["softwareVersionBundleId"], + ioc_match = self.indicators.check_app_id( + result["softwareVersionBundleId"] + ) + if ioc_match: + self.alertstore.critical( + f"Malicious application {result['softwareVersionBundleId']} identified", + "", + result, + matched_indicator=ioc_match.ioc, ) - result["matched_indicator"] = ioc - self.detected.append(result) continue + # Some apps installed from apple store with sourceApp "com.apple.AppStore.ProductPageExtension" if ( result.get("sourceApp", "com.apple.AppStore") not in KNOWN_APP_INSTALLERS ): - self.log.warning( - "Suspicious app not installed from the App Store or MDM: %s", - result["softwareVersionBundleId"], + self.alertstore.medium( + f"Suspicious app not installed from the App Store or MDM: {result['softwareVersionBundleId']}", + "", + result, ) - self.detected.append(result) def _parse_itunes_timestamp(self, entry: Dict[str, Any]) -> None: """ @@ -145,6 +157,8 @@ class Applications(IOSExtraction): def run(self) -> None: if self.is_backup: + if not self.target_path: + return plist_path = os.path.join(self.target_path, "Info.plist") if not os.path.isfile(plist_path): raise DatabaseNotFoundError("Impossible to find Info.plist file") diff --git a/src/mvt/ios/modules/mixed/calendar.py b/src/mvt/ios/modules/mixed/calendar.py index bfd1fc7..40f8ad9 100644 --- a/src/mvt/ios/modules/mixed/calendar.py +++ b/src/mvt/ios/modules/mixed/calendar.py @@ -4,8 +4,13 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_mactime_to_iso from ..base import IOSExtraction @@ -26,7 +31,7 @@ class Calendar(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -44,7 +49,7 @@ class Calendar(IOSExtraction): "participant_last_modified", ] - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: records = [] for timestamp in self.timestamps: if timestamp not in record or not record[timestamp]: @@ -64,18 +69,20 @@ class Calendar(IOSExtraction): def check_indicators(self) -> None: for result in self.results: if result["participant_email"] and self.indicators: - ioc = self.indicators.check_email(result["participant_email"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_email(result["participant_email"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue # Custom check for Quadream exploit if result["summary"] == "Meeting" and result["description"] == "Notes": - self.log.warning( - "Potential Quadream exploit event identified: %s", result["uuid"] + self.alertstore.high( + f"Potential Quadream exploit event identified: {result['uuid']}", + "", + result, ) - self.detected.append(result) def _parse_calendar_db(self): """ diff --git a/src/mvt/ios/modules/mixed/calls.py b/src/mvt/ios/modules/mixed/calls.py index e29be35..cee5862 100644 --- a/src/mvt/ios/modules/mixed/calls.py +++ b/src/mvt/ios/modules/mixed/calls.py @@ -4,8 +4,9 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult from mvt.common.utils import convert_mactime_to_iso from ..base import IOSExtraction @@ -21,12 +22,12 @@ class Calls(IOSExtraction): def __init__( self, - file_path: str = None, - target_path: str = None, - results_path: str = None, + file_path: Optional[str] = None, + target_path: Optional[str] = None, + results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: list = [], + results: Optional[list] = None, ) -> None: super().__init__( file_path=file_path, @@ -37,7 +38,7 @@ class Calls(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -52,6 +53,8 @@ class Calls(IOSExtraction): ) self.log.info("Found Calls database at path: %s", self.file_path) + if not self.file_path: + return conn = self._open_sqlite_db(self.file_path) cur = conn.cursor() cur.execute( @@ -70,7 +73,9 @@ class Calls(IOSExtraction): "duration": row[1], "location": row[2], "number": ( - row[3].decode("utf-8") if row[3] and row[3] is bytes else row[3] + row[3].decode("utf-8") + if isinstance(row[3], bytes) + else row[3] ), "provider": row[4], } diff --git a/src/mvt/ios/modules/mixed/chrome_favicon.py b/src/mvt/ios/modules/mixed/chrome_favicon.py index f50ee29..af3df2e 100644 --- a/src/mvt/ios/modules/mixed/chrome_favicon.py +++ b/src/mvt/ios/modules/mixed/chrome_favicon.py @@ -4,14 +4,18 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_chrometime_to_datetime, convert_datetime_to_iso from ..base import IOSExtraction CHROME_FAVICON_BACKUP_IDS = ["55680ab883d0fdcffd94f959b1632e5fbbb18c5b"] -# TODO: Confirm Chrome database path. CHROME_FAVICON_ROOT_PATHS = [ "private/var/mobile/Containers/Data/Application/*/Library/Application Support/Google/Chrome/Default/Favicons", ] @@ -27,7 +31,7 @@ class ChromeFavicon(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -38,7 +42,7 @@ class ChromeFavicon(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -51,12 +55,14 @@ class ChromeFavicon(IOSExtraction): return for result in self.results: - ioc = self.indicators.check_url(result["url"]) - if not ioc: - ioc = self.indicators.check_url(result["icon_url"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_url(result["url"]) + if not ioc_match: + ioc_match = self.indicators.check_url(result["icon_url"]) + + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue def run(self) -> None: @@ -65,6 +71,8 @@ class ChromeFavicon(IOSExtraction): ) self.log.info("Found Chrome favicon cache database at path: %s", self.file_path) + if not self.file_path: + return conn = self._open_sqlite_db(self.file_path) # Fetch icon cache diff --git a/src/mvt/ios/modules/mixed/chrome_history.py b/src/mvt/ios/modules/mixed/chrome_history.py index e59ea9f..4c5afae 100644 --- a/src/mvt/ios/modules/mixed/chrome_history.py +++ b/src/mvt/ios/modules/mixed/chrome_history.py @@ -4,8 +4,13 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_chrometime_to_datetime, convert_datetime_to_iso from ..base import IOSExtraction @@ -13,7 +18,6 @@ from ..base import IOSExtraction CHROME_HISTORY_BACKUP_IDS = [ "faf971ce92c3ac508c018dce1bef2a8b8e9838f1", ] -# TODO: Confirm Chrome database path. CHROME_HISTORY_ROOT_PATHS = [ "private/var/mobile/Containers/Data/Application/*/Library/Application Support/Google/Chrome/Default/History", # pylint: disable=line-too-long ] @@ -29,7 +33,7 @@ class ChromeHistory(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -40,7 +44,7 @@ class ChromeHistory(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -55,10 +59,11 @@ class ChromeHistory(IOSExtraction): return for result in self.results: - ioc = self.indicators.check_url(result["url"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_url(result["url"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) def run(self) -> None: self._find_ios_database( @@ -66,6 +71,8 @@ class ChromeHistory(IOSExtraction): ) self.log.info("Found Chrome history database at path: %s", self.file_path) + if not self.file_path: + return conn = self._open_sqlite_db(self.file_path) cur = conn.cursor() cur.execute( diff --git a/src/mvt/ios/modules/mixed/contacts.py b/src/mvt/ios/modules/mixed/contacts.py index 5f842c6..ca0470c 100644 --- a/src/mvt/ios/modules/mixed/contacts.py +++ b/src/mvt/ios/modules/mixed/contacts.py @@ -7,6 +7,8 @@ import logging import sqlite3 from typing import Optional +from mvt.common.module_types import ModuleResults + from ..base import IOSExtraction CONTACTS_BACKUP_IDS = [ @@ -27,7 +29,7 @@ class Contacts(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -44,33 +46,36 @@ class Contacts(IOSExtraction): ) self.log.info("Found Contacts database at path: %s", self.file_path) + if not self.file_path: + return conn = self._open_sqlite_db(self.file_path) cur = conn.cursor() try: - cur.execute( + try: + cur.execute( + """ + SELECT + multi.value, person.first, person.middle, person.last, + person.organization + FROM ABPerson person, ABMultiValue multi + WHERE person.rowid = multi.record_id and multi.value not null + ORDER by person.rowid ASC; """ - SELECT - multi.value, person.first, person.middle, person.last, - person.organization - FROM ABPerson person, ABMultiValue multi - WHERE person.rowid = multi.record_id and multi.value not null - ORDER by person.rowid ASC; - """ - ) - except sqlite3.OperationalError as e: - self.log.info("Error while reading the contact table: %s", e) - return None - names = [description[0] for description in cur.description] + ) + except sqlite3.OperationalError as e: + self.log.info("Error while reading the contact table: %s", e) + return None + names = [description[0] for description in cur.description] - for row in cur: - new_contact = {} - for index, value in enumerate(row): - new_contact[names[index]] = value + for row in cur: + new_contact = {} + for index, value in enumerate(row): + new_contact[names[index]] = value - self.results.append(new_contact) - - cur.close() - conn.close() + self.results.append(new_contact) + finally: + cur.close() + conn.close() self.log.info( "Extracted a total of %d contacts from the address book", len(self.results) diff --git a/src/mvt/ios/modules/mixed/firefox_favicon.py b/src/mvt/ios/modules/mixed/firefox_favicon.py index 8c88e4a..ad92e73 100644 --- a/src/mvt/ios/modules/mixed/firefox_favicon.py +++ b/src/mvt/ios/modules/mixed/firefox_favicon.py @@ -4,8 +4,13 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_unix_to_iso from ..base import IOSExtraction @@ -28,7 +33,7 @@ class FirefoxFavicon(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -39,7 +44,7 @@ class FirefoxFavicon(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -53,13 +58,14 @@ class FirefoxFavicon(IOSExtraction): return for result in self.results: - ioc = self.indicators.check_url(result.get("url", "")) - if not ioc: - ioc = self.indicators.check_url(result.get("history_url", "")) + ioc_match = self.indicators.check_url(result.get("url", "")) + if not ioc_match: + ioc_match = self.indicators.check_url(result.get("history_url", "")) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) def run(self) -> None: self._find_ios_database( @@ -67,6 +73,8 @@ class FirefoxFavicon(IOSExtraction): ) self.log.info("Found Firefox favicon database at path: %s", self.file_path) + if not self.file_path: + return conn = self._open_sqlite_db(self.file_path) cur = conn.cursor() cur.execute( diff --git a/src/mvt/ios/modules/mixed/firefox_history.py b/src/mvt/ios/modules/mixed/firefox_history.py index 69bc034..7ab1eba 100644 --- a/src/mvt/ios/modules/mixed/firefox_history.py +++ b/src/mvt/ios/modules/mixed/firefox_history.py @@ -4,8 +4,13 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_unix_to_iso from ..base import IOSExtraction @@ -32,7 +37,7 @@ class FirefoxHistory(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -43,7 +48,7 @@ class FirefoxHistory(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -56,10 +61,11 @@ class FirefoxHistory(IOSExtraction): return for result in self.results: - ioc = self.indicators.check_url(result["url"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_url(result["url"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) def run(self) -> None: self._find_ios_database( @@ -67,6 +73,8 @@ class FirefoxHistory(IOSExtraction): ) self.log.info("Found Firefox history database at path: %s", self.file_path) + if not self.file_path: + return conn = self._open_sqlite_db(self.file_path) cur = conn.cursor() cur.execute( diff --git a/src/mvt/ios/modules/mixed/global_preferences.py b/src/mvt/ios/modules/mixed/global_preferences.py index 63e960f..97a2aef 100644 --- a/src/mvt/ios/modules/mixed/global_preferences.py +++ b/src/mvt/ios/modules/mixed/global_preferences.py @@ -7,6 +7,8 @@ import logging import plistlib from typing import Optional +from mvt.common.module_types import ModuleResults + from ..base import IOSExtraction GLOBAL_PREFERENCES_BACKUP_IDS = ["0dc926a1810f7aee4e8f38793ed788701f93bf9d"] @@ -25,7 +27,7 @@ class GlobalPreferences(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -40,11 +42,10 @@ class GlobalPreferences(IOSExtraction): for entry in self.results: if entry["entry"] == "LDMGlobalEnabled": if entry["value"]: - self.log.warning("Lockdown mode enabled") + self.alertstore.info("Lockdown mode enabled", "", entry) else: - self.log.warning("Lockdown mode disabled") - return - self.log.warning("Lockdown mode disabled") + self.alertstore.low("Lockdown mode disabled", "", entry) + continue def process_file(self, file_path: str) -> None: with open(file_path, "rb") as handle: @@ -60,6 +61,8 @@ class GlobalPreferences(IOSExtraction): ) self.log.info("Found Global Preference database at path: %s", self.file_path) + if not self.file_path: + return self.process_file(self.file_path) self.log.info("Extracted a total of %d Global Preferences", len(self.results)) diff --git a/src/mvt/ios/modules/mixed/idstatuscache.py b/src/mvt/ios/modules/mixed/idstatuscache.py index e8f4157..c841f1e 100644 --- a/src/mvt/ios/modules/mixed/idstatuscache.py +++ b/src/mvt/ios/modules/mixed/idstatuscache.py @@ -6,8 +6,13 @@ import collections import logging import plistlib -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_mactime_to_iso from ..base import IOSExtraction @@ -31,7 +36,7 @@ class IDStatusCache(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -42,7 +47,7 @@ class IDStatusCache(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -58,18 +63,19 @@ class IDStatusCache(IOSExtraction): for result in self.results: if result.get("user", "").startswith("mailto:"): email = result["user"][7:].strip("'") - ioc = self.indicators.check_email(email) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_email(email) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue if "\\x00\\x00" in result.get("user", ""): - self.log.warning( - "Found an ID Status Cache entry with suspicious patterns: %s", - result.get("user"), + self.alertstore.high( + f"Found an ID Status Cache entry with suspicious patterns: {result.get('user')}", + "", + result, ) - self.detected.append(result) def _extract_idstatuscache_entries(self, file_path): with open(file_path, "rb") as handle: diff --git a/src/mvt/ios/modules/mixed/interactionc.py b/src/mvt/ios/modules/mixed/interactionc.py index 744decd..81a67e2 100644 --- a/src/mvt/ios/modules/mixed/interactionc.py +++ b/src/mvt/ios/modules/mixed/interactionc.py @@ -5,8 +5,13 @@ import logging import sqlite3 -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_mactime_to_iso from ..base import IOSExtraction @@ -223,7 +228,7 @@ class InteractionC(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -247,7 +252,7 @@ class InteractionC(IOSExtraction): "last_outgoing_recipient_date", ] - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: records = [] processed = [] for timestamp in self.timestamps: @@ -280,41 +285,44 @@ class InteractionC(IOSExtraction): ) self.log.info("Found InteractionC database at path: %s", self.file_path) + if not self.file_path: + return conn = self._open_sqlite_db(self.file_path) cur = conn.cursor() try: - cur.execute(QUERIES[0]) - except sqlite3.OperationalError: try: - cur.execute(QUERIES[1]) + cur.execute(QUERIES[0]) except sqlite3.OperationalError: try: - cur.execute(QUERIES[2]) + cur.execute(QUERIES[1]) except sqlite3.OperationalError: try: - cur.execute(QUERIES[3]) - except sqlite3.OperationalError as e: - self.log.info( - "Error while reading the InteractionC table: %s", e - ) - return None + cur.execute(QUERIES[2]) + except sqlite3.OperationalError: + try: + cur.execute(QUERIES[3]) + except sqlite3.OperationalError as e: + self.log.info( + "Error while reading the InteractionC table: %s", e + ) + return None - names = [description[0] for description in cur.description] - for item in cur: - entry = {} - for index, value in enumerate(item): - if names[index] in self.timestamps: - if value is None or isinstance(value, str): - entry[names[index]] = value + names = [description[0] for description in cur.description] + for item in cur: + entry = {} + for index, value in enumerate(item): + if names[index] in self.timestamps: + if value is None or isinstance(value, str): + entry[names[index]] = value + else: + entry[names[index]] = convert_mactime_to_iso(value) else: - entry[names[index]] = convert_mactime_to_iso(value) - else: - entry[names[index]] = value + entry[names[index]] = value - self.results.append(entry) - - cur.close() - conn.close() + self.results.append(entry) + finally: + cur.close() + conn.close() self.log.info("Extracted a total of %d InteractionC events", len(self.results)) diff --git a/src/mvt/ios/modules/mixed/locationd.py b/src/mvt/ios/modules/mixed/locationd.py index c190589..86de85b 100644 --- a/src/mvt/ios/modules/mixed/locationd.py +++ b/src/mvt/ios/modules/mixed/locationd.py @@ -6,8 +6,13 @@ import base64 import logging import plistlib -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_mactime_to_iso from ..base import IOSExtraction @@ -31,7 +36,7 @@ class LocationdClients(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -54,7 +59,7 @@ class LocationdClients(IOSExtraction): "BeaconRegionTimeStopped", ] - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: records = [] for timestamp in self.timestamps: if timestamp in record.keys(): @@ -77,59 +82,61 @@ class LocationdClients(IOSExtraction): parts = result["package"].split("/") proc_name = parts[len(parts) - 1] - ioc = self.indicators.check_process(proc_name) - if ioc: - self.log.warning( - "Found a suspicious process name in LocationD entry %s", - result["package"], + ioc_match = self.indicators.check_process(proc_name) + if ioc_match: + self.alertstore.high( + f"Found a suspicious process name in LocationD entry {result['package']}", + "", + result, + matched_indicator=ioc_match.ioc, ) - result["matched_indicator"] = ioc - self.detected.append(result) continue if "BundleId" in result: - ioc = self.indicators.check_process(result["BundleId"]) - if ioc: - self.log.warning( - "Found a suspicious process name in LocationD entry %s", - result["package"], + ioc_match = self.indicators.check_process(result["BundleId"]) + if ioc_match: + self.alertstore.high( + f"Found a suspicious process name in LocationD entry {result['package']}", + "", + result, + matched_indicator=ioc_match.ioc, ) - result["matched_indicator"] = ioc if "BundlePath" in result: - ioc = self.indicators.check_file_path(result["BundlePath"]) - if ioc: - self.log.warning( - "Found a suspicious file path in Location D: %s", - result["BundlePath"], + ioc_match = self.indicators.check_file_path(result["BundlePath"]) + if ioc_match: + self.alertstore.high( + f"Found a known malicious domain in LocationD entry {result['package']}", + "", + result, + matched_indicator=ioc_match.ioc, ) - result["matched_indicator"] = ioc - self.detected.append(result) continue if "Executable" in result: - ioc = self.indicators.check_file_path(result["Executable"]) - if ioc: - self.log.warning( - "Found a suspicious file path in Location D: %s", - result["Executable"], + ioc_match = self.indicators.check_file_path(result["Executable"]) + if ioc_match: + self.alertstore.high( + f"Found a suspicious file path in LocationD entry {result['Executable']}", + "", + result, + matched_indicator=ioc_match.ioc, ) - result["matched_indicator"] = ioc - self.detected.append(result) continue if "Registered" in result: # Sometimes registered is a bool if isinstance(result["Registered"], bool): continue - ioc = self.indicators.check_file_path(result["Registered"]) - if ioc: - self.log.warning( - "Found a suspicious file path in Location D: %s", - result["Registered"], + + ioc_match = self.indicators.check_file_path(result["Registered"]) + if ioc_match: + self.alertstore.high( + f"Found a suspicious file path in LocationD entry {result['Registered']}", + "", + result, + matched_indicator=ioc_match.ioc, ) - result["matched_indicator"] = ioc - self.detected.append(result) continue def _extract_locationd_entries(self, file_path): @@ -140,7 +147,6 @@ class LocationdClients(IOSExtraction): # Some migration information are int and not dicts if not isinstance(file_plist[key], dict): continue - # FIXME: unclear key format in iOS 17 result = file_plist[key] result["package"] = key.rstrip(":") for timestamp in self.timestamps: diff --git a/src/mvt/ios/modules/mixed/net_datausage.py b/src/mvt/ios/modules/mixed/net_datausage.py index ce52179..61b0eef 100644 --- a/src/mvt/ios/modules/mixed/net_datausage.py +++ b/src/mvt/ios/modules/mixed/net_datausage.py @@ -6,6 +6,7 @@ import logging from typing import Optional +from mvt.common.module_types import ModuleResults from ..net_base import NetBase DATAUSAGE_BACKUP_IDS = [ @@ -30,7 +31,7 @@ class Datausage(NetBase): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, diff --git a/src/mvt/ios/modules/mixed/osanalytics_addaily.py b/src/mvt/ios/modules/mixed/osanalytics_addaily.py index aea5dbf..8a5db3f 100644 --- a/src/mvt/ios/modules/mixed/osanalytics_addaily.py +++ b/src/mvt/ios/modules/mixed/osanalytics_addaily.py @@ -5,8 +5,13 @@ import logging import plistlib -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_datetime_to_iso from ..base import IOSExtraction @@ -30,7 +35,7 @@ class OSAnalyticsADDaily(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -41,7 +46,7 @@ class OSAnalyticsADDaily(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["ts"], "module": self.__class__.__name__, @@ -57,10 +62,11 @@ class OSAnalyticsADDaily(IOSExtraction): return for result in self.results: - ioc = self.indicators.check_process(result["package"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_process(result["package"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) def run(self) -> None: self._find_ios_database( @@ -71,6 +77,8 @@ class OSAnalyticsADDaily(IOSExtraction): "Found com.apple.osanalytics.addaily plist at path: %s", self.file_path ) + if not self.file_path: + return with open(self.file_path, "rb") as handle: file_plist = plistlib.load(handle) diff --git a/src/mvt/ios/modules/mixed/safari_browserstate.py b/src/mvt/ios/modules/mixed/safari_browserstate.py index f97463d..e67b7d3 100644 --- a/src/mvt/ios/modules/mixed/safari_browserstate.py +++ b/src/mvt/ios/modules/mixed/safari_browserstate.py @@ -8,16 +8,28 @@ import logging import os import plistlib import sqlite3 -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_mactime_to_iso, keys_bytes_to_string from ..base import IOSExtraction -SAFARI_BROWSER_STATE_BACKUP_RELPATH = "Library/Safari/BrowserState.db" +# Safari profiles (iOS 17 and later) keep their per-profile databases under +# Library/Safari/Profiles//, separate from the default profile's. +SAFARI_BROWSER_STATE_BACKUP_RELPATHS = [ + "Library/Safari/BrowserState.db", + "Library/Safari/Profiles/*/BrowserState.db", +] SAFARI_BROWSER_STATE_ROOT_PATHS = [ "private/var/mobile/Library/Safari/BrowserState.db", + "private/var/mobile/Library/Safari/Profiles/*/BrowserState.db", "private/var/mobile/Containers/Data/Application/*/Library/Safari/BrowserState.db", + "private/var/mobile/Containers/Data/Application/*/Library/Safari/Profiles/*/BrowserState.db", ] @@ -31,7 +43,7 @@ class SafariBrowserState(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -44,7 +56,7 @@ class SafariBrowserState(IOSExtraction): self._session_history_count = 0 - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["last_viewed_timestamp"], "module": self.__class__.__name__, @@ -58,10 +70,11 @@ class SafariBrowserState(IOSExtraction): for result in self.results: if "tab_url" in result: - ioc = self.indicators.check_url(result["tab_url"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_url(result["tab_url"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) continue if "session_data" not in result: @@ -69,10 +82,14 @@ class SafariBrowserState(IOSExtraction): for session_entry in result["session_data"]: if "entry_url" in session_entry: - ioc = self.indicators.check_url(session_entry["entry_url"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_url(session_entry["entry_url"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, + "", + result, + matched_indicator=ioc_match.ioc, + ) def _process_browser_state_db(self, db_path): self._recover_sqlite_db_if_needed(db_path) @@ -80,99 +97,106 @@ class SafariBrowserState(IOSExtraction): cur = conn.cursor() try: - cur.execute( - """ - SELECT - tabs.title, - tabs.url, - tabs.user_visible_url, - tabs.last_viewed_time, - tab_sessions.session_data - FROM tabs - JOIN tab_sessions ON tabs.uuid = tab_sessions.tab_uuid - ORDER BY tabs.last_viewed_time; - """ - ) - except sqlite3.OperationalError: - # Old version iOS <12 likely try: cur.execute( """ SELECT - title, url, user_visible_url, last_viewed_time, session_data + tabs.title, + tabs.url, + tabs.user_visible_url, + tabs.last_viewed_time, + tab_sessions.session_data + FROM tabs + JOIN tab_sessions ON tabs.uuid = tab_sessions.tab_uuid + ORDER BY tabs.last_viewed_time; + """ + ) + except sqlite3.OperationalError: + # Old version iOS <12 likely + try: + cur.execute( + """ + SELECT + title, url, user_visible_url, last_viewed_time, session_data FROM tabs ORDER BY last_viewed_time; """ - ) - except sqlite3.OperationalError as e: - self.log.error(f"Error executing query: {e}") + ) + except sqlite3.OperationalError as e: + self.log.error(f"Error executing query: {e}") + return - for row in cur: - session_entries = [] + for row in cur: + session_entries = [] - if row[4]: - # Skip a 4 byte header before the plist content. - session_plist = row[4][4:] - session_data = {} - try: - session_data = plistlib.load(io.BytesIO(session_plist)) - session_data = keys_bytes_to_string(session_data) - except plistlib.InvalidFileException: - pass + if row[4]: + # Skip a 4 byte header before the plist content. + session_plist = row[4][4:] + session_data = {} + try: + session_data = plistlib.load(io.BytesIO(session_plist)) + session_data = keys_bytes_to_string(session_data) + except plistlib.InvalidFileException: + pass - if "SessionHistoryEntries" in session_data.get("SessionHistory", {}): - for session_entry in session_data["SessionHistory"].get( - "SessionHistoryEntries" - ): - self._session_history_count += 1 + if "SessionHistoryEntries" in session_data.get("SessionHistory", {}): + for session_entry in session_data["SessionHistory"].get( + "SessionHistoryEntries" + ): + self._session_history_count += 1 - data_length = 0 - if "SessionHistoryEntryData" in session_entry: - data_length = len( - session_entry.get("SessionHistoryEntryData") + data_length = 0 + if "SessionHistoryEntryData" in session_entry: + data_length = len( + session_entry.get("SessionHistoryEntryData") + ) + + session_entries.append( + { + "entry_title": session_entry.get( + "SessionHistoryEntryOriginalURL" + ), + "entry_url": session_entry.get( + "SessionHistoryEntryURL" + ), + "data_length": data_length, + } ) - session_entries.append( - { - "entry_title": session_entry.get( - "SessionHistoryEntryOriginalURL" - ), - "entry_url": session_entry.get( - "SessionHistoryEntryURL" - ), - "data_length": data_length, - } - ) - - self.results.append( - { - "tab_title": row[0], - "tab_url": row[1], - "tab_visible_url": row[2], - "last_viewed_timestamp": convert_mactime_to_iso(row[3]), - "session_data": session_entries, - "safari_browser_state_db": os.path.relpath( - db_path, self.target_path - ), - } - ) + self.results.append( + { + "tab_title": row[0], + "tab_url": row[1], + "tab_visible_url": row[2], + "last_viewed_timestamp": convert_mactime_to_iso(row[3]), + "session_data": session_entries, + "safari_browser_state_db": os.path.relpath( + db_path, self.target_path + ), + } + ) + finally: + cur.close() + conn.close() def run(self) -> None: if self.is_backup: - for backup_file in self._get_backup_files_from_manifest( - relative_path=SAFARI_BROWSER_STATE_BACKUP_RELPATH - ): - browserstate_path = self._get_backup_file_from_id( - backup_file["file_id"] - ) + for relative_path in SAFARI_BROWSER_STATE_BACKUP_RELPATHS: + for backup_file in self._get_backup_files_from_manifest( + relative_path=relative_path + ): + browserstate_path = self._get_backup_file_from_id( + backup_file["file_id"] + ) - if not browserstate_path: - continue + if not browserstate_path: + continue - self.log.info( - "Found Safari browser state database at path: %s", browserstate_path - ) - self._process_browser_state_db(browserstate_path) + self.log.info( + "Found Safari browser state database at path: %s", + browserstate_path, + ) + self._process_browser_state_db(browserstate_path) elif self.is_fs_dump: for browserstate_path in self._get_fs_files_from_patterns( SAFARI_BROWSER_STATE_ROOT_PATHS diff --git a/src/mvt/ios/modules/mixed/safari_history.py b/src/mvt/ios/modules/mixed/safari_history.py index 56bc9d0..4a50f02 100644 --- a/src/mvt/ios/modules/mixed/safari_history.py +++ b/src/mvt/ios/modules/mixed/safari_history.py @@ -5,17 +5,29 @@ import logging import os -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.url import URL from mvt.common.utils import convert_mactime_to_datetime, convert_mactime_to_iso from ..base import IOSExtraction -SAFARI_HISTORY_BACKUP_RELPATH = "Library/Safari/History.db" +# Safari profiles (iOS 17 and later) each keep their own History.db under +# Library/Safari/Profiles//, separate from the default profile's database. +SAFARI_HISTORY_BACKUP_RELPATHS = [ + "Library/Safari/History.db", + "Library/Safari/Profiles/*/History.db", +] SAFARI_HISTORY_ROOT_PATHS = [ "private/var/mobile/Library/Safari/History.db", + "private/var/mobile/Library/Safari/Profiles/*/History.db", "private/var/mobile/Containers/Data/Application/*/Library/Safari/History.db", + "private/var/mobile/Containers/Data/Application/*/Library/Safari/Profiles/*/History.db", ] @@ -33,7 +45,7 @@ class SafariHistory(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -44,7 +56,7 @@ class SafariHistory(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -70,6 +82,9 @@ class SafariHistory(IOSExtraction): # We loop again through visits in order to find redirect record. for redirect in self.results: + if redirect["safari_history_db"] != result["safari_history_db"]: + continue + if redirect["visit_id"] != result["redirect_destination"]: continue @@ -95,9 +110,10 @@ class SafariHistory(IOSExtraction): elapsed_ms = elapsed_time.microseconds / 1000 if elapsed_time.seconds == 0: - self.log.warning( - "Redirect took less than a second! (%d milliseconds)", - elapsed_ms, + self.alertstore.medium( + f"Redirect took less than a second! ({elapsed_ms} milliseconds)", + convert_mactime_to_iso(result["timestamp"]), + result, ) def check_indicators(self) -> None: @@ -107,10 +123,11 @@ class SafariHistory(IOSExtraction): return for result in self.results: - ioc = self.indicators.check_url(result["url"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_url(result["url"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) def _process_history_db(self, history_path): self._recover_sqlite_db_if_needed(history_path) @@ -152,17 +169,22 @@ class SafariHistory(IOSExtraction): def run(self) -> None: if self.is_backup: - for history_file in self._get_backup_files_from_manifest( - relative_path=SAFARI_HISTORY_BACKUP_RELPATH - ): - history_path = self._get_backup_file_from_id(history_file["file_id"]) + for relative_path in SAFARI_HISTORY_BACKUP_RELPATHS: + for history_file in self._get_backup_files_from_manifest( + relative_path=relative_path + ): + history_path = self._get_backup_file_from_id( + history_file["file_id"] + ) - if not history_path: - continue + if not history_path: + continue - self.log.info("Found Safari history database at path: %s", history_path) + self.log.info( + "Found Safari history database at path: %s", history_path + ) - self._process_history_db(history_path) + self._process_history_db(history_path) elif self.is_fs_dump: for history_path in self._get_fs_files_from_patterns( SAFARI_HISTORY_ROOT_PATHS diff --git a/src/mvt/ios/modules/mixed/shortcuts.py b/src/mvt/ios/modules/mixed/shortcuts.py index f61168b..eb749ee 100644 --- a/src/mvt/ios/modules/mixed/shortcuts.py +++ b/src/mvt/ios/modules/mixed/shortcuts.py @@ -8,8 +8,13 @@ import itertools import logging import plistlib import sqlite3 -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import check_for_links, convert_mactime_to_iso from ..base import IOSExtraction @@ -32,7 +37,7 @@ class Shortcuts(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -43,7 +48,7 @@ class Shortcuts(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: found_urls = "" if record["action_urls"]: found_urls = f"- URLs in actions: {', '.join(record['action_urls'])}" @@ -71,11 +76,14 @@ class Shortcuts(IOSExtraction): if not self.indicators: return - for result in self.results: - ioc = self.indicators.check_urls(result["action_urls"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + url_batches = [result["action_urls"] for result in self.results] + for result, ioc_match in zip( + self.results, self.indicators.check_url_batches(url_batches) + ): + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) def run(self) -> None: self._find_ios_database( @@ -83,6 +91,8 @@ class Shortcuts(IOSExtraction): ) self.log.info("Found Shortcuts database at path: %s", self.file_path) + if not self.file_path: + return conn = self._open_sqlite_db(self.file_path) conn.text_factory = bytes cur = conn.cursor() diff --git a/src/mvt/ios/modules/mixed/sms.py b/src/mvt/ios/modules/mixed/sms.py index a8db73b..d1afd06 100644 --- a/src/mvt/ios/modules/mixed/sms.py +++ b/src/mvt/ios/modules/mixed/sms.py @@ -6,8 +6,13 @@ import logging import sqlite3 from base64 import b64encode -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import check_for_links, convert_mactime_to_iso from ..base import IOSExtraction @@ -30,7 +35,7 @@ class SMS(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -41,7 +46,7 @@ class SMS(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: text = record["text"].replace("\n", "\\n") sms_data = f'{record["service"]}: {record["guid"]} "{text}" from {record["phone_number"]} ({record["account"]})' records = [ @@ -71,28 +76,43 @@ class SMS(IOSExtraction): if message.get("text", "").startswith(alert_old) or message.get( "text", "" ).startswith(alert_new): - self.log.warning( - "Apple warning about state-sponsored attack received on the %s", + self.alertstore.high( + f"Apple warning about state-sponsored attack received on {message['isodate']}", message["isodate"], + message, ) if not self.indicators: return + url_batches = [] for result in self.results: message_links = result.get("links", []) # Making sure not link was ignored if message_links == []: message_links = check_for_links(result.get("text", "")) - ioc = self.indicators.check_urls(message_links) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + url_batches.append(message_links) + + for result, ioc_match in zip( + self.results, self.indicators.check_url_batches(url_batches) + ): + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) + + def collect_url_results(self) -> None: + for message in self.results: + for url in message.get("links", []): + self.add_url_result(url, message.get("isodate"), "sms") def run(self) -> None: self._find_ios_database(backup_ids=SMS_BACKUP_IDS, root_paths=SMS_ROOT_PATHS) self.log.info("Found SMS database at path: %s", self.file_path) + if not self.file_path: + return + try: conn = self._open_sqlite_db(self.file_path) cur = conn.cursor() @@ -110,6 +130,7 @@ class SMS(IOSExtraction): except sqlite3.DatabaseError as exc: conn.close() if "database disk image is malformed" in str(exc): + assert self.file_path is not None self._recover_sqlite_db_if_needed(self.file_path, forced=True) conn = self._open_sqlite_db(self.file_path) cur = conn.cursor() diff --git a/src/mvt/ios/modules/mixed/sms_attachments.py b/src/mvt/ios/modules/mixed/sms_attachments.py index e11fb69..d02bd27 100644 --- a/src/mvt/ios/modules/mixed/sms_attachments.py +++ b/src/mvt/ios/modules/mixed/sms_attachments.py @@ -6,8 +6,13 @@ import logging import sqlite3 from base64 import b64encode -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_mactime_to_iso from ..base import IOSExtraction @@ -30,7 +35,7 @@ class SMSAttachments(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -41,7 +46,7 @@ class SMSAttachments(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: return { "timestamp": record["isodate"], "module": self.__class__.__name__, @@ -57,27 +62,33 @@ class SMSAttachments(IOSExtraction): def check_indicators(self) -> None: for attachment in self.results: # Check for known malicious filenames. - if self.indicators and self.indicators.check_file_path( - attachment["filename"] - ): - self.detected.append(attachment) + if self.indicators: + ioc_match = self.indicators.check_file_path(attachment["filename"]) + if ioc_match: + self.alertstore.high( + ioc_match.message, + "", + attachment, + matched_indicator=ioc_match.ioc, + ) if ( attachment["filename"].startswith("/var/tmp/") and attachment["filename"].endswith("-1") and attachment["direction"] == "received" ): - self.log.warning( - "Suspicious iMessage attachment %s on %s", - attachment["filename"], + self.alertstore.medium( + f"Suspicious iMessage attachment {attachment['filename']} on {attachment['isodate']}", attachment["isodate"], + attachment, ) - self.detected.append(attachment) def run(self) -> None: self._find_ios_database(backup_ids=SMS_BACKUP_IDS, root_paths=SMS_ROOT_PATHS) self.log.info("Found SMS database at path: %s", self.file_path) + if not self.file_path: + return conn = self._open_sqlite_db(self.file_path) cur = conn.cursor() try: diff --git a/src/mvt/ios/modules/mixed/tcc.py b/src/mvt/ios/modules/mixed/tcc.py index 73d9aa1..ed878c5 100644 --- a/src/mvt/ios/modules/mixed/tcc.py +++ b/src/mvt/ios/modules/mixed/tcc.py @@ -5,8 +5,13 @@ import logging import sqlite3 -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_unix_to_iso from ..base import IOSExtraction @@ -51,7 +56,7 @@ class TCC(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -62,7 +67,7 @@ class TCC(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: if "last_modified" in record: if "allowed_value" in record: msg = ( @@ -89,10 +94,11 @@ class TCC(IOSExtraction): return for result in self.results: - ioc = self.indicators.check_process(result["client"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_process(result["client"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) def process_db(self, file_path): conn = self._open_sqlite_db(file_path) diff --git a/src/mvt/ios/modules/mixed/webkit_resource_load_statistics.py b/src/mvt/ios/modules/mixed/webkit_resource_load_statistics.py index e0c2833..ef7b58f 100644 --- a/src/mvt/ios/modules/mixed/webkit_resource_load_statistics.py +++ b/src/mvt/ios/modules/mixed/webkit_resource_load_statistics.py @@ -6,8 +6,13 @@ import logging import os import sqlite3 -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import convert_unix_to_iso from ..base import IOSExtraction @@ -32,7 +37,7 @@ class WebkitResourceLoadStatistics(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -45,7 +50,7 @@ class WebkitResourceLoadStatistics(IOSExtraction): self.results = [] if not results else results - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: msg = f"Webkit resource loaded from {record['registrable_domain']}" if record["domain"] != "": msg += f" by app in domain {record['domain']}" @@ -60,12 +65,12 @@ class WebkitResourceLoadStatistics(IOSExtraction): if not self.indicators: return - self.detected = [] for result in self.results: - ioc = self.indicators.check_url(result["registrable_domain"]) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_url(result["registrable_domain"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) def _process_observations_db(self, db_path: str, domain: str, path: str) -> None: self.log.info( @@ -79,23 +84,35 @@ class WebkitResourceLoadStatistics(IOSExtraction): cur = conn.cursor() try: - # FIXME: table contains extra fields with timestamp here - cur.execute( - """ - SELECT - domainID, - registrableDomain, - lastSeen, - hadUserInteraction - from ObservedDomains; - """ - ) - except sqlite3.OperationalError: - return + try: + cur.execute("PRAGMA table_info(ObservedDomains);") + available_columns = {row[1] for row in cur} + required_columns = [ + "domainID", + "registrableDomain", + "lastSeen", + "hadUserInteraction", + ] + if not set(required_columns).issubset(available_columns): + return - for row in cur: - self.results.append( - { + optional_columns = [ + column + for column in [ + "mostRecentUserInteractionTime", + "mostRecentWebPushInteractionTime", + ] + if column in available_columns + ] + selected_columns = required_columns + optional_columns + cur.execute( + f"SELECT {', '.join(selected_columns)} FROM ObservedDomains;" + ) + except sqlite3.OperationalError: + return + + for row in cur: + result = { "domain_id": row[0], "registrable_domain": row[1], "last_seen": row[2], @@ -104,7 +121,23 @@ class WebkitResourceLoadStatistics(IOSExtraction): "domain": domain, "path": path, } - ) + for index, column in enumerate(optional_columns, start=4): + field = { + "mostRecentUserInteractionTime": ( + "most_recent_user_interaction_time" + ), + "mostRecentWebPushInteractionTime": ( + "most_recent_web_push_interaction_time" + ), + }[column] + timestamp = row[index] + result[field] = timestamp + if timestamp is not None and timestamp >= 0: + result[f"{field}_isodate"] = convert_unix_to_iso(timestamp) + self.results.append(result) + finally: + cur.close() + conn.close() if len(self.results) > 0: self.log.info( diff --git a/src/mvt/ios/modules/mixed/webkit_session_resource_log.py b/src/mvt/ios/modules/mixed/webkit_session_resource_log.py index 5acbc81..eb34311 100644 --- a/src/mvt/ios/modules/mixed/webkit_session_resource_log.py +++ b/src/mvt/ios/modules/mixed/webkit_session_resource_log.py @@ -8,6 +8,7 @@ import os import plistlib from typing import Optional +from mvt.common.module_types import ModuleResults from mvt.common.utils import convert_datetime_to_iso from ..base import IOSExtraction @@ -38,7 +39,7 @@ class WebkitSessionResourceLog(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -49,7 +50,7 @@ class WebkitSessionResourceLog(IOSExtraction): results=results, ) - self.results = {} if not results else results + self.results: dict = results if results is not None else {} @staticmethod def _extract_domains(entries): @@ -69,6 +70,8 @@ class WebkitSessionResourceLog(IOSExtraction): if not self.indicators: return + records = [] + url_batches = [] for _, entries in self.results.items(): for entry in entries: source_domains = self._extract_domains(entry["redirect_source"]) @@ -76,44 +79,59 @@ class WebkitSessionResourceLog(IOSExtraction): entry["redirect_destination"] ) - # TODO: Currently not used. - # subframe_origins = self._extract_domains( - # entry["subframe_under_origin"]) - # subresource_domains = self._extract_domains( - # entry["subresource_under_origin"]) - - all_origins = set( - [entry["origin"]] + source_domains + destination_domains + subframe_origins = self._extract_domains( + entry["subframe_under_origin"] + ) + subresource_domains = self._extract_domains( + entry["subresource_under_origin"] ) - ioc = self.indicators.check_urls(all_origins) - if ioc: - entry["matched_indicator"] = ioc - self.detected.append(entry) - - redirect_path = "" - if len(source_domains) > 0: - redirect_path += "SOURCE: " - for idx, item in enumerate(source_domains): - source_domains[idx] = f'"{item}"' - - redirect_path += ", ".join(source_domains) - redirect_path += " -> " - - redirect_path += f'ORIGIN: "{entry["origin"]}"' - - if len(destination_domains) > 0: - redirect_path += " -> " - redirect_path += "DESTINATION: " - for idx, item in enumerate(destination_domains): - destination_domains[idx] = f'"{item}"' - - redirect_path += ", ".join(destination_domains) - - self.log.warning( - "Found HTTP redirect between suspicious domains: %s", - redirect_path, + all_origins = list( + set( + [entry["origin"]] + + source_domains + + destination_domains + + subframe_origins + + subresource_domains ) + ) + + records.append((entry, source_domains, destination_domains)) + url_batches.append(all_origins) + + for record, ioc_match in zip( + records, self.indicators.check_url_batches(url_batches) + ): + if ioc_match: + entry, source_domains, destination_domains = record + self.alertstore.critical( + ioc_match.message, "", entry, matched_indicator=ioc_match.ioc + ) + + redirect_path = "" + if len(source_domains) > 0: + redirect_path += "SOURCE: " + for idx, item in enumerate(source_domains): + source_domains[idx] = f'"{item}"' + + redirect_path += ", ".join(source_domains) + redirect_path += " -> " + + redirect_path += f'ORIGIN: "{entry["origin"]}"' + + if len(destination_domains) > 0: + redirect_path += " -> " + redirect_path += "DESTINATION: " + for idx, item in enumerate(destination_domains): + destination_domains[idx] = f'"{item}"' + + redirect_path += ", ".join(destination_domains) + + self.alertstore.high( + f"Found HTTP redirect between suspicious domains: {redirect_path}", + "", + entry, + ) def _extract_browsing_stats(self, log_path): items = [] @@ -185,6 +203,8 @@ class WebkitSessionResourceLog(IOSExtraction): self.log.info( "Found Safari browsing session resource log at path: %s", log_path ) + if not self.target_path: + continue key = os.path.relpath(log_path, self.target_path) self.results[key] = self._extract_browsing_stats(log_path) diff --git a/src/mvt/ios/modules/mixed/whatsapp.py b/src/mvt/ios/modules/mixed/whatsapp.py index 4e5d8db..0a80aad 100644 --- a/src/mvt/ios/modules/mixed/whatsapp.py +++ b/src/mvt/ios/modules/mixed/whatsapp.py @@ -4,8 +4,13 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional, Union +from typing import Optional +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) from mvt.common.utils import check_for_links, convert_mactime_to_iso from ..base import IOSExtraction @@ -28,7 +33,7 @@ class Whatsapp(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -39,7 +44,7 @@ class Whatsapp(IOSExtraction): results=results, ) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: text = record.get("ZTEXT", "").replace("\n", "\\n") links_text = "" if record.get("links"): @@ -56,11 +61,19 @@ class Whatsapp(IOSExtraction): if not self.indicators: return - for result in self.results: - ioc = self.indicators.check_urls(result.get("links", [])) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + url_batches = [result.get("links", []) for result in self.results] + for result, ioc_match in zip( + self.results, self.indicators.check_url_batches(url_batches) + ): + if ioc_match: + self.alertstore.critical( + ioc_match.message, "", result, matched_indicator=ioc_match.ioc + ) + + def collect_url_results(self) -> None: + for message in self.results: + for url in message.get("links", []): + self.add_url_result(url, message.get("isodate"), "whatsapp") def run(self) -> None: self._find_ios_database( @@ -68,6 +81,8 @@ class Whatsapp(IOSExtraction): ) self.log.info("Found WhatsApp database at path: %s", self.file_path) + if not self.file_path: + return conn = self._open_sqlite_db(self.file_path) cur = conn.cursor() @@ -97,7 +112,9 @@ class Whatsapp(IOSExtraction): for index, value in enumerate(message_row): message[names[index]] = value - message["isodate"] = convert_mactime_to_iso(message.get("ZMESSAGEDATE")) + message["isodate"] = convert_mactime_to_iso( + message.get("ZMESSAGEDATE") or 0 + ) message["ZTEXT"] = message["ZTEXT"] if message["ZTEXT"] else "" # Extract links from the WhatsApp message. URLs can be stored in diff --git a/src/mvt/ios/modules/net_base.py b/src/mvt/ios/modules/net_base.py index 1773e29..eb13fdf 100644 --- a/src/mvt/ios/modules/net_base.py +++ b/src/mvt/ios/modules/net_base.py @@ -7,11 +7,16 @@ import logging import operator import sqlite3 from pathlib import Path -from typing import Optional, Union +from typing import Optional from mvt.common.utils import convert_mactime_to_iso from .base import IOSExtraction +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleSerializedResult, + ModuleResults, +) class NetBase(IOSExtraction): @@ -25,7 +30,7 @@ class NetBase(IOSExtraction): results_path: Optional[str] = None, module_options: Optional[dict] = None, log: logging.Logger = logging.getLogger(__name__), - results: Optional[list] = None, + results: Optional[ModuleResults] = None, ) -> None: super().__init__( file_path=file_path, @@ -37,7 +42,8 @@ class NetBase(IOSExtraction): ) def _extract_net_data(self): - conn = sqlite3.connect(self.file_path) + assert self.file_path is not None + conn = self._open_sqlite_db(self.file_path) conn.row_factory = sqlite3.Row cur = conn.cursor() try: @@ -129,7 +135,7 @@ class NetBase(IOSExtraction): self.log.info("Extracted information on %d processes", len(self.results)) - def serialize(self, record: dict) -> Union[dict, list]: + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: record_data = ( f"{record['proc_name']} (Bundle ID: {record['bundle_id']}," f" ID: {record['proc_id']})" @@ -232,7 +238,8 @@ class NetBase(IOSExtraction): "been truncated in the database)" ) - self.log.warning(msg) + self.alertstore.medium(msg, proc["live_isodate"], proc) + if not proc["live_proc_id"]: self.log.info( "Found process entry in ZPROCESS but not in ZLIVEUSAGE: %s at %s", @@ -251,16 +258,20 @@ class NetBase(IOSExtraction): # Avoid duplicate warnings for same process. if result["live_proc_id"] not in missing_process_cache: missing_process_cache.add(result["live_proc_id"]) - self.log.warning( - "Found manipulated process entry %s. Entry on %s", - result["live_proc_id"], + self.alertstore.high( + f"Found manipulated process entry {result['live_proc_id']}. Entry on {result['live_isodate']}", result["live_isodate"], + result, ) # Set manipulated proc timestamp so it appears in timeline. result["first_isodate"] = result["isodate"] = result["live_isodate"] result["proc_name"] = "MANIPULATED [process record deleted]" - self.detected.append(result) + self.alertstore.high( + f"Found manipulated process entry {result['live_proc_id']}/", + result["first_isodate"], + result, + ) def find_deleted(self): """Identify process which may have been deleted from the DataUsage @@ -278,11 +289,10 @@ class NetBase(IOSExtraction): for proc_id in range(min(all_proc_id), max(all_proc_id)): if proc_id not in all_proc_id: previous_proc = results_by_proc[last_proc_id] - self.log.info( - 'Missing process %d. Previous process at "%s" (%s)', - proc_id, + self.alertstore.low( + f'Missing process {proc_id}. Previous process at "{previous_proc["first_isodate"]}" ({previous_proc["proc_name"]})', previous_proc["first_isodate"], - previous_proc["proc_name"], + previous_proc, ) missing_procs[proc_id] = { @@ -311,14 +321,11 @@ class NetBase(IOSExtraction): self.results = sorted(self.results, key=operator.itemgetter("first_isodate")) def check_indicators(self) -> None: - # Check for manipulated process records. - # TODO: Catching KeyError for live_isodate for retro-compatibility. - # This is not very good. - try: + # check_manipulated/find_deleted require "live_isodate" and + # "live_proc_id" keys which may be absent in older result formats. + if self.results and "live_isodate" in self.results[0]: self.check_manipulated() self.find_deleted() - except KeyError: - pass if not self.indicators: return @@ -333,7 +340,11 @@ class NetBase(IOSExtraction): if not result["proc_id"]: continue - ioc = self.indicators.check_process(proc_name) - if ioc: - result["matched_indicator"] = ioc - self.detected.append(result) + ioc_match = self.indicators.check_process(proc_name) + if ioc_match: + self.alertstore.critical( + ioc_match.message, + result["first_isodate"], + result, + matched_indicator=ioc_match.ioc, + ) diff --git a/src/mvt/ios/modules/sysdiagnose/__init__.py b/src/mvt/ios/modules/sysdiagnose/__init__.py new file mode 100644 index 0000000..3963ca5 --- /dev/null +++ b/src/mvt/ios/modules/sysdiagnose/__init__.py @@ -0,0 +1,6 @@ +# 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 .base import SysdiagnoseExtraction diff --git a/src/mvt/ios/modules/sysdiagnose/base.py b/src/mvt/ios/modules/sysdiagnose/base.py new file mode 100644 index 0000000..99187c9 --- /dev/null +++ b/src/mvt/ios/modules/sysdiagnose/base.py @@ -0,0 +1,105 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import fnmatch +import logging +import os +import re +import tarfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from mvt.common.module import MVTModule, ModuleResults + + +class SysdiagnoseExtraction(MVTModule): + """Base class for custom modules that analyze an iOS sysdiagnose.""" + + def __init__( + self, + file_path: Optional[str] = None, + target_path: Optional[str] = None, + results_path: Optional[str] = None, + module_options: Optional[dict] = None, + log: logging.Logger = logging.getLogger(__name__), + results: Optional[ModuleResults] = None, + ) -> None: + super().__init__( + file_path=file_path, + target_path=target_path, + results_path=results_path, + module_options=module_options, + log=log, + results=results, + ) + self.parent_path: Optional[str] = None + self.files: list[str] = [] + self.tar: Optional[tarfile.TarFile] = None + self.tar_files: list[str] = [] + self.ips_files: list[dict[str, object]] = [] + + def from_sysdiagnose_folder( + self, target_path: str, sysdiagnose_files: list[str] + ) -> None: + self.parent_path = Path(target_path).absolute().parent.as_posix() + self.files = sysdiagnose_files + + def from_sysdiagnose_tar( + self, sysdiagnose_archive: tarfile.TarFile, sysdiagnose_files: list[str] + ) -> None: + self.tar = sysdiagnose_archive + self.tar_files = sysdiagnose_files + + def _extract_timezone(self): + """Determine the sysdiagnose timezone from its diagnostic log.""" + file_paths = self._get_files_by_pattern("*/sysdiagnose.log") + if not file_paths: + self.log.info( + "Unable to determine the timezone in which the sysdiagnose was " + "generated. Assuming UTC for logs without a timezone." + ) + return timezone.utc + + content = self._get_file_content(file_paths[0]).decode( + "utf-8", errors="replace" + ) + filenames = re.findall(r"sysdiagnose_\S+?\.tar\.gz", content) + if not filenames: + self.log.info( + "Unable to determine the timezone in which the sysdiagnose was " + "generated. Assuming UTC for logs without a timezone." + ) + return timezone.utc + + timestamp = "_".join( + filenames[0].removesuffix(".tar.gz").split("_")[1:3] + ) + sysdiagnose_timezone = datetime.strptime( + timestamp, "%Y.%m.%d_%H-%M-%S%z" + ).tzinfo + self.log.info( + "Based on the sysdiagnose filename, assuming timezone %s for logs " + "without a timezone.", + sysdiagnose_timezone, + ) + return sysdiagnose_timezone + + def _get_files_by_pattern(self, pattern: str) -> list[str]: + file_names = self.tar_files if self.tar else self.files + return fnmatch.filter(file_names, pattern) + + def _get_file_content(self, file_path: str) -> bytes: + if self.tar: + handle = self.tar.extractfile(self.tar.getmember(file_path)) + else: + if self.parent_path is None: + raise RuntimeError("Sysdiagnose folder has not been initialized") + handle = open(os.path.join(self.parent_path, file_path), "rb") + + if handle is None: + return b"" + with handle: + return handle.read() diff --git a/src/mvt/ios/versions.py b/src/mvt/ios/versions.py index bae3bd0..3acbad9 100644 --- a/src/mvt/ios/versions.py +++ b/src/mvt/ios/versions.py @@ -7,10 +7,12 @@ import pkgutil from logging import Logger from typing import Dict, Optional -import packaging +from packaging import version as packaging_version -IPHONE_MODELS = json.loads(pkgutil.get_data("mvt", "ios/data/ios_models.json")) -IPHONE_IOS_VERSIONS = json.loads(pkgutil.get_data("mvt", "ios/data/ios_versions.json")) +IPHONE_MODELS = json.loads(pkgutil.get_data("mvt", "ios/data/ios_models.json") or b"[]") +IPHONE_IOS_VERSIONS = json.loads( + pkgutil.get_data("mvt", "ios/data/ios_versions.json") or b"[]" +) def get_device_desc_from_id(identifier: str, devices_list: list = IPHONE_MODELS) -> str: @@ -47,8 +49,8 @@ def is_ios_version_outdated(version: str, log: Optional[Logger] = None) -> bool: if version == "": return False - latest_parsed = packaging.version.parse(latest_ios_version()["version"]) - current_parsed = packaging.version.parse(version) + latest_parsed = packaging_version.parse(latest_ios_version()["version"]) + current_parsed = packaging_version.parse(version) if current_parsed < latest_parsed: if log: log.warning( diff --git a/tests/android/test_artifact_dumpsys_accessibility.py b/tests/android/test_artifact_dumpsys_accessibility.py index 2eca8fa..1f0b234 100644 --- a/tests/android/test_artifact_dumpsys_accessibility.py +++ b/tests/android/test_artifact_dumpsys_accessibility.py @@ -5,6 +5,7 @@ import logging from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact +from mvt.common.alerts import AlertLevel from mvt.common.indicators import Indicators from ..utils import get_artifact @@ -38,6 +39,19 @@ class TestDumpsysAccessibilityArtifact: assert da.results[0]["package_name"] == "com.malware.accessibility" assert da.results[0]["service"] == "com.malware.service.malwareservice" + def test_accessibility_service_alert(self): + da = DumpsysAccessibilityArtifact() + file = get_artifact("android_data/dumpsys_accessibility_v14_or_later.txt") + with open(file) as f: + data = f.read() + da.parse(data) + + da.check_indicators() + + assert len(da.alertstore.alerts) == 1 + assert da.alertstore.alerts[0].level == AlertLevel.MEDIUM + assert da.alertstore.alerts[0].event == da.results[0] + def test_ioc_check(self, indicator_file): da = DumpsysAccessibilityArtifact() file = get_artifact("android_data/dumpsys_accessibility.txt") @@ -49,6 +63,14 @@ class TestDumpsysAccessibilityArtifact: ind.parse_stix2(indicator_file) ind.ioc_collections[0]["app_ids"].append("com.sec.android.app.camera") da.indicators = ind - assert len(da.detected) == 0 + assert len(da.alertstore.alerts) == 0 da.check_indicators() - assert len(da.detected) == 1 + assert len(da.alertstore.alerts) == len(da.results) + assert da.alertstore.count(AlertLevel.MEDIUM) == 3 + assert da.alertstore.count(AlertLevel.CRITICAL) == 1 + critical_alert = next( + alert + for alert in da.alertstore.alerts + if alert.level == AlertLevel.CRITICAL + ) + assert critical_alert.event["package_name"] == "com.sec.android.app.camera" diff --git a/tests/android/test_artifact_dumpsys_adb.py b/tests/android/test_artifact_dumpsys_adb.py index 1d3d76b..f5574a6 100644 --- a/tests/android/test_artifact_dumpsys_adb.py +++ b/tests/android/test_artifact_dumpsys_adb.py @@ -4,6 +4,8 @@ # https://license.mvt.re/1.1/ from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact +from mvt.android.modules.bugreport.dumpsys_adb_state import DumpsysADBState +from mvt.common.alerts import AlertLevel from ..utils import get_artifact @@ -30,6 +32,66 @@ class TestDumpsysADBArtifact: ) assert user_key["user"] == "user@linux" + def test_parsing_adb_wifi(self): + da_adb = DumpsysADBArtifact() + file = get_artifact("android_data/dumpsys_adb_wifi.txt") + with open(file, "rb") as f: + data = f.read() + + da_adb.parse(data) + + assert len(da_adb.results) == 1 + adb_data = da_adb.results[0] + assert "user_keys" in adb_data + assert len(adb_data["user_keys"]) == 1 + + user_key = adb_data["user_keys"][0] + assert ( + user_key["fingerprint"] == "F0:A1:3D:8C:B3:F4:7B:09:9F:EE:8B:D8:38:2E:BD:C6" + ) + assert user_key["user"] == "user@linux" + + # The adb_wifi block following the keystore is not part of the keystore. + assert b"adb_wifi" not in adb_data["keystore"] + + def test_parsing_multiline_terminated_by_structural_line(self): + dump_data = ( + b"debugging_manager={\n" + b" keystore=ABX\x00\x0bkeyStore\x00\x02\x11\n" + b" connected_to_adb=true\n" + b" adb_wifi={\n" + b" enabled=false\n" + b" tls_port=0\n" + b" }\n" + ) + + parsed = DumpsysADBArtifact().indented_dump_parser(dump_data) + + debugging_manager = parsed["debugging_manager"] + assert debugging_manager["keystore"] == [b"ABX\x00\x0bkeyStore\x00\x02\x11"] + assert debugging_manager["connected_to_adb"] == b"true" + assert debugging_manager["adb_wifi"] == { + "enabled": b"false", + "tls_port": b"0", + } + + def test_parsing_multiline_terminated_by_closing_brace(self): + dump_data = ( + b"debugging_manager={\n" + b" keystore=ABX\x00\x0bkeyStore\x00\x02\x11\n" + b"}\n" + b"other={\n" + b" value=true\n" + b"}\n" + ) + + parsed = DumpsysADBArtifact().indented_dump_parser(dump_data) + + assert parsed["debugging_manager"]["keystore"] == [ + b"ABX\x00\x0bkeyStore\x00\x02\x11" + ] + assert parsed["other"] == {"value": b"true"} + def test_parsing_adb_xml(self): da_adb = DumpsysADBArtifact() file = get_artifact("android_data/dumpsys_adb_xml.txt") @@ -54,3 +116,119 @@ class TestDumpsysADBArtifact: assert key_store_entry["user"] == "user@laptop" assert key_store_entry["fingerprint"] == expected_fingerprint assert key_store_entry["last_connected"] == "1628501829898" + + +class TestDumpsysADBStateAlerts: + def test_no_androidqf_context_preserves_existing_behavior(self): + module = DumpsysADBState( + results=[ + { + "user_keys": [ + { + "key": b"QUJDRA==", + "user": "host@example", + "fingerprint": "fingerprint", + } + ] + } + ] + ) + + module.check_indicators() + + assert module.alertstore.alerts == [] + + def test_androidqf_trusted_keys_create_expected_alerts(self): + module = DumpsysADBState( + module_options={ + "androidqf_acquisition": { + "started": "2025-06-20T18:00:00Z", + "adb_host_public_key": "QUJDRA== acquisition@host", + } + }, + results=[ + { + "user_keys": [ + { + "key": b"QUJDRA==", + "user": "acquisition@host", + "fingerprint": "acquisition-fingerprint", + }, + { + "key": b"RUZHSA==", + "user": "other@host", + "fingerprint": "other-fingerprint", + }, + { + "key": b"not-base64", + "user": "invalid@host", + "fingerprint": "", + }, + ], + "keystore": [ + { + "key": b"QUJDRA==", + "user": "acquisition@host", + "fingerprint": "acquisition-fingerprint", + "last_connected": "1750266000000", + } + ], + } + ], + ) + + module.check_indicators() + + assert [alert.level for alert in module.alertstore.alerts] == [ + AlertLevel.INFORMATIONAL, + AlertLevel.LOW, + AlertLevel.LOW, + ] + informational, different, invalid = module.alertstore.alerts + assert "at least one day before" in informational.message + assert informational.event_time == "2025-06-18 17:00:00.000000" + assert "different from the AndroidQF acquisition host" in different.message + assert "invalid trusted ADB host key" in invalid.message + + def test_missing_androidqf_host_key_creates_low_alert(self): + trusted_key = { + "key": b"QUJDRA==", + "user": "host@example", + "fingerprint": "fingerprint", + } + module = DumpsysADBState( + module_options={"androidqf_acquisition": {}}, + results=[{"user_keys": [trusted_key]}], + ) + + module.check_indicators() + + assert len(module.alertstore.alerts) == 1 + assert module.alertstore.alerts[0].level == AlertLevel.LOW + assert "does not include its host key" in module.alertstore.alerts[0].message + + def test_recent_acquisition_host_key_does_not_create_alert(self): + module = DumpsysADBState( + module_options={ + "androidqf_acquisition": { + "started": "2025-06-20T18:00:00Z", + "adb_host_public_key": "QUJDRA== acquisition@host", + } + }, + results=[ + { + "keystore": [ + { + "key": b"QUJDRA==", + "user": "acquisition@host", + "fingerprint": "fingerprint", + "last_connected": "1750438800000", + } + ] + } + ], + ) + + module.check_indicators() + + assert module.alertstore.alerts == [] diff --git a/tests/android/test_artifact_dumpsys_appops.py b/tests/android/test_artifact_dumpsys_appops.py index 7c2edc2..862b872 100644 --- a/tests/android/test_artifact_dumpsys_appops.py +++ b/tests/android/test_artifact_dumpsys_appops.py @@ -42,22 +42,25 @@ class TestDumpsysAppopsArtifact: ind.parse_stix2(indicator_file) ind.ioc_collections[0]["app_ids"].append("com.facebook.katana") da.indicators = ind - assert len(da.detected) == 0 + assert len(da.alertstore.alerts) == 0 da.check_indicators() detected_by_ioc = [ - detected for detected in da.detected if detected.get("matched_indicator") + alert + for alert in da.alertstore.alerts + if alert.matched_indicator is not None ] detected_by_permission_heuristic = [ - detected - for detected in da.detected + alert + for alert in da.alertstore.alerts if all( [ perm["name"] == "REQUEST_INSTALL_PACKAGES" - for perm in detected["permissions"] + for perm in alert.event["permissions"] ] ) ] - assert len(da.detected) == 3 + assert len(da.alertstore.alerts) == 3 assert len(detected_by_ioc) == 1 + assert detected_by_ioc[0].matched_indicator is not None assert len(detected_by_permission_heuristic) == 2 diff --git a/tests/android/test_artifact_dumpsys_battery_daily.py b/tests/android/test_artifact_dumpsys_battery_daily.py index e93b050..dbb9c36 100644 --- a/tests/android/test_artifact_dumpsys_battery_daily.py +++ b/tests/android/test_artifact_dumpsys_battery_daily.py @@ -5,6 +5,7 @@ import logging from mvt.android.artifacts.dumpsys_battery_daily import DumpsysBatteryDailyArtifact +from mvt.common.alerts import AlertLevel from mvt.common.indicators import Indicators from ..utils import get_artifact @@ -32,6 +33,101 @@ class TestDumpsysBatteryDailyArtifact: ind.parse_stix2(indicator_file) ind.ioc_collections[0]["app_ids"].append("com.facebook.system") dba.indicators = ind - assert len(dba.detected) == 0 + assert len(dba.alertstore.alerts) == 0 dba.check_indicators() - assert len(dba.detected) == 1 + assert len(dba.alertstore.alerts) == 1 + + def test_uninstall_and_downgrade_create_medium_alerts(self): + dba = DumpsysBatteryDailyArtifact() + dba.parse( + """ + Daily from 2022-08-16-15-56-39 to 2022-08-17-01-15-45: + Update com.example.app vers=10 + Update com.example.removed vers=0 + Daily from 2022-08-17-15-56-39 to 2022-08-18-01-15-45: + Update com.example.app vers=9 +""" + ) + + assert len(dba.results) == 3 + assert len(dba.alertstore.alerts) == 2 + + uninstall_alert, downgrade_alert = dba.alertstore.alerts + assert uninstall_alert.level == AlertLevel.MEDIUM + assert uninstall_alert.message == ( + "Detected uninstall of package com.example.removed (vers 0)" + ) + assert uninstall_alert.event_time == "2022-08-16" + assert uninstall_alert.event["package_name"] == "com.example.removed" + assert uninstall_alert.event["vers"] == "0" + + assert downgrade_alert.level == AlertLevel.MEDIUM + assert downgrade_alert.message == ( + "Detected downgrade of package com.example.app from vers 10 to vers 9" + ) + assert downgrade_alert.event_time == "2022-08-17" + 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" diff --git a/tests/android/test_artifact_dumpsys_battery_history.py b/tests/android/test_artifact_dumpsys_battery_history.py index 9a09e88..dd0b120 100644 --- a/tests/android/test_artifact_dumpsys_battery_history.py +++ b/tests/android/test_artifact_dumpsys_battery_history.py @@ -39,6 +39,25 @@ class TestDumpsysBatteryHistoryArtifact: ind.parse_stix2(indicator_file) ind.ioc_collections[0]["app_ids"].append("com.samsung.android.app.reminder") dba.indicators = ind - assert len(dba.detected) == 0 + assert len(dba.alertstore.alerts) == 0 dba.check_indicators() - assert len(dba.detected) == 2 + assert len(dba.alertstore.alerts) == 2 + + def test_parsing_absolute_timestamps(self): + dba = DumpsysBatteryHistoryArtifact() + dba.parse( + """07-15 20:27:39.431 (2) 100 +job=u0a123:"com.example/.ExampleJob" +07-15 20:27:40.431 (2) 100 -job=u0a123:"com.example/.ExampleJob" +""" + ) + + assert len(dba.results) == 2 + assert dba.results[0] == { + "time_elapsed": "07-15 20:27:39.431", + "event": "start_job", + "uid": "u0a123", + "package_name": "com.example", + "service": "com.example/.ExampleJob", + } + assert dba.results[1]["event"] == "end_job" + assert dba.results[1]["uid"] == "u0a123" diff --git a/tests/android/test_artifact_dumpsys_dbinfo.py b/tests/android/test_artifact_dumpsys_dbinfo.py index 23df5f2..691d43c 100644 --- a/tests/android/test_artifact_dumpsys_dbinfo.py +++ b/tests/android/test_artifact_dumpsys_dbinfo.py @@ -37,6 +37,25 @@ class TestDumpsysDBinfoArtifact: ind.parse_stix2(indicator_file) ind.ioc_collections[0]["app_ids"].append("com.wssyncmldm") dbi.indicators = ind - assert len(dbi.detected) == 0 + assert len(dbi.alertstore.alerts) == 0 dbi.check_indicators() - assert len(dbi.detected) == 5 + assert len(dbi.alertstore.alerts) == 5 + + def test_parsing_month_day_timestamp_without_pid(self): + dbi = DumpsysDBInfoArtifact() + dbi.parse( + """ +Connection pool for /data/user/0/com.example/databases/current.db: + Most recently executed operations: + 0: [07-15 20:27:39.431] executeForCursorWindow took 1ms - succeeded, sql="SELECT 1" +""" + ) + + assert dbi.results == [ + { + "isodate": "07-15 20:27:39.431", + "action": "executeForCursorWindow", + "sql": "SELECT 1", + "path": "/data/user/0/com.example/databases/current.db", + } + ] diff --git a/tests/android/test_artifact_dumpsys_package_activities.py b/tests/android/test_artifact_dumpsys_package_activities.py index da7c0ab..5eab63d 100644 --- a/tests/android/test_artifact_dumpsys_package_activities.py +++ b/tests/android/test_artifact_dumpsys_package_activities.py @@ -39,6 +39,6 @@ class TestDumpsysPackageActivitiesArtifact: ind.parse_stix2(indicator_file) ind.ioc_collections[0]["app_ids"].append("com.google.android.gms") dpa.indicators = ind - assert len(dpa.detected) == 0 + assert len(dpa.alertstore.alerts) == 0 dpa.check_indicators() - assert len(dpa.detected) == 1 + assert len(dpa.alertstore.alerts) == 1 diff --git a/tests/android/test_artifact_dumpsys_packages.py b/tests/android/test_artifact_dumpsys_packages.py index 6300f17..bd5fb75 100644 --- a/tests/android/test_artifact_dumpsys_packages.py +++ b/tests/android/test_artifact_dumpsys_packages.py @@ -25,6 +25,25 @@ class TestDumpsysPackagesArtifact: == "com.samsung.android.provider.filterprovider" ) assert dpa.results[0]["version_name"] == "5.0.07" + assert dpa.results[0]["first_install_time"] == "2008-12-31 16:00:00" + assert dpa.results[0]["system"] is True + + def test_parsing_system_flag(self): + system_details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details( + " pkgFlags=[ SYSTEM HAS_CODE ALLOW_CLEAR_USER_DATA ]" + ) + third_party_details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details( + " pkgFlags=[ HAS_CODE ALLOW_BACKUP ]" + ) + missing_flag_details = ( + DumpsysPackagesArtifact.parse_dumpsys_package_for_details( + " versionName=1.0" + ) + ) + + assert system_details["system"] is True + assert third_party_details["system"] is False + assert missing_flag_details["system"] is False def test_ioc_check(self, indicator_file): dpa = DumpsysPackagesArtifact() @@ -37,6 +56,46 @@ class TestDumpsysPackagesArtifact: ind.parse_stix2(indicator_file) ind.ioc_collections[0]["app_ids"].append("com.sec.android.app.DataCreate") dpa.indicators = ind - assert len(dpa.detected) == 0 + assert len(dpa.alertstore.alerts) == 0 dpa.check_indicators() - assert len(dpa.detected) == 1 + assert len(dpa.alertstore.alerts) == 1 + + def test_per_user_fields_use_primary_user(self): + details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details( + """ User 0: installed=true + firstInstallTime=2024-01-10 09:19:39 + runtime permissions: + android.permission.CAMERA: granted=true + User 95: installed=false + firstInstallTime=1970-01-01 01:00:00 + runtime permissions: + android.permission.CAMERA: granted=false + android.permission.RECORD_AUDIO: granted=false +""" + ) + + assert details["first_install_time"] == "2024-01-10 09:19:39" + runtime_permissions = [ + permission + for permission in details["permissions"] + if permission["type"] == "runtime" + ] + assert runtime_permissions == [ + { + "name": "android.permission.CAMERA", + "granted": True, + "type": "runtime", + } + ] + + def test_per_user_fields_fall_back_when_user_zero_is_missing(self): + details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details( + """ User 10: installed=true + firstInstallTime=2024-02-10 09:19:39 + runtime permissions: + android.permission.CAMERA: granted=true +""" + ) + + assert details["first_install_time"] == "2024-02-10 09:19:39" + assert details["permissions"][-1]["name"] == "android.permission.CAMERA" diff --git a/tests/android/test_artifact_dumpsys_platform_compat.py b/tests/android/test_artifact_dumpsys_platform_compat.py index e2321a4..c8c59b3 100644 --- a/tests/android/test_artifact_dumpsys_platform_compat.py +++ b/tests/android/test_artifact_dumpsys_platform_compat.py @@ -35,6 +35,6 @@ class TestDumpsysPlatformCompatArtifact: ind.ioc_collections[0]["app_ids"].append("org.torproject.torbrowser") ind.ioc_collections[0]["app_ids"].append("org.article19.circulo.next") dbi.indicators = ind - assert len(dbi.detected) == 0 + assert len(dbi.alertstore.alerts) == 0 dbi.check_indicators() - assert len(dbi.detected) == 2 + assert len(dbi.alertstore.alerts) == 2 diff --git a/tests/android/test_artifact_dumpsys_receivers.py b/tests/android/test_artifact_dumpsys_receivers.py index f236aa9..7875a52 100644 --- a/tests/android/test_artifact_dumpsys_receivers.py +++ b/tests/android/test_artifact_dumpsys_receivers.py @@ -31,6 +31,44 @@ class TestDumpsysReceiversArtifact: == "com.android.storagemanager" ) + def test_parsing_misindented_action(self): + dr = DumpsysReceiversArtifact() + data = """\ +Receiver Resolver Table: + Non-Data Actions: + android.app.action.ENTER_CAR_MODE: + b5b40f6 com.google.android.projection.gearhead/.CarModeBroadcastReceiver + android.intent.action.MY_PACKAGE_REPLACED: + e7706c1 com.psycatgames.nhiegame/.ScheduledNotificationBootReceiver +""" + + dr.parse(data) + + assert ( + dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][ + "package_name" + ] + == "com.psycatgames.nhiegame" + ) + + def test_parsing_misindented_first_action(self): + dr = DumpsysReceiversArtifact() + data = """\ +Receiver Resolver Table: + Non-Data Actions: + android.intent.action.MY_PACKAGE_REPLACED: + e7706c1 com.psycatgames.nhiegame/.ScheduledNotificationBootReceiver +""" + + dr.parse(data) + + assert ( + dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][ + "package_name" + ] + == "com.psycatgames.nhiegame" + ) + def test_ioc_check(self, indicator_file): dr = DumpsysReceiversArtifact() file = get_artifact("android_data/dumpsys_packages.txt") @@ -42,6 +80,6 @@ class TestDumpsysReceiversArtifact: ind.parse_stix2(indicator_file) ind.ioc_collections[0]["app_ids"].append("com.android.storagemanager") dr.indicators = ind - assert len(dr.detected) == 0 + assert len(dr.alertstore.alerts) == 0 dr.check_indicators() - assert len(dr.detected) == 1 + assert len(dr.alertstore.alerts) == 1 diff --git a/tests/android/test_artifact_getprop.py b/tests/android/test_artifact_getprop.py index 4ae9036..d9fec6b 100644 --- a/tests/android/test_artifact_getprop.py +++ b/tests/android/test_artifact_getprop.py @@ -36,6 +36,6 @@ class TestGetPropArtifact: "dalvik.vm.appimageformat" ) gp.indicators = ind - assert len(gp.detected) == 0 + assert len(gp.alertstore.alerts) == 0 gp.check_indicators() - assert len(gp.detected) == 1 + assert len(gp.alertstore.alerts) == 1 diff --git a/tests/android/test_artifact_processes.py b/tests/android/test_artifact_processes.py index 54bc36c..2806472 100644 --- a/tests/android/test_artifact_processes.py +++ b/tests/android/test_artifact_processes.py @@ -33,6 +33,6 @@ class TestProcessesArtifact: ind.parse_stix2(indicator_file) ind.ioc_collections[0]["processes"].append("lru-add-drain") p.indicators = ind - assert len(p.detected) == 0 + assert len(p.alertstore.alerts) == 0 p.check_indicators() - assert len(p.detected) == 1 + assert len(p.alertstore.alerts) == 1 diff --git a/tests/android/test_artifact_tombstones.py b/tests/android/test_artifact_tombstones.py index d1d5682..f88a9c2 100644 --- a/tests/android/test_artifact_tombstones.py +++ b/tests/android/test_artifact_tombstones.py @@ -8,6 +8,7 @@ import datetime import pytest from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact +from mvt.android.parsers.proto.tombstone import Tombstone from ..utils import get_artifact @@ -42,6 +43,73 @@ class TestTombstoneCrashArtifact: assert len(tombstone_artifact.results) == 1 self.validate_tombstone_result(tombstone_artifact.results[0]) + def test_text_tombstone_preserves_abort_message(self): + tombstone_artifact = TombstoneCrashArtifact() + artifact_path = "android_data/bugreport/FS/data/tombstones/tombstone_00" + file = get_artifact(artifact_path) + with open(file, "rb") as f: + data = f.read() + + tombstone_artifact.parse( + os.path.basename(artifact_path), + datetime.datetime(2021, 9, 29, 17, 43, 49), + data, + ) + + assert tombstone_artifact.results[0]["abort_message"] == ( + "Check failed: payload.size() <= bytes_left " + "(payload.size()=99, bytes_left=51) " + ) + + def test_protobuf_tombstone_preserves_abort_message_and_causes(self): + tombstone_artifact = TombstoneCrashArtifact() + artifact_path = "android_data/tombstone_process.pb" + file = get_artifact(artifact_path) + with open(file, "rb") as f: + tombstone = Tombstone().parse(f.read()) + + tombstone.abort_message = "synthetic abort reason" + tombstone_artifact.parse_protobuf( + os.path.basename(artifact_path), + datetime.datetime(2023, 4, 12, 12, 32, 40, 518290), + bytes(tombstone), + ) + + result = tombstone_artifact.results[0] + assert result["abort_message"] == "synthetic abort reason" + assert result["causes"] == [ + { + "human_readable": "null pointer dereference", + "memory_error": None, + } + ] + + def test_text_tombstone_keeps_crashing_thread(self): + tombstone_artifact = TombstoneCrashArtifact() + artifact_path = "android_data/tombstone_process.txt" + file = get_artifact(artifact_path) + with open(file, "rb") as f: + data = f.read() + + data += ( + b"\npid: 25541, tid: 31896, name: worker-thread" + b" >>> /vendor/bin/other <<<\n" + ) + tombstone_artifact.parse( + os.path.basename(artifact_path), + datetime.datetime(2023, 4, 12, 12, 32, 40, 518290), + data, + ) + + result = tombstone_artifact.results[0] + assert result["pid"] == 25541 + assert result["tid"] == 21307 + assert result["process_name"] == "mtk.ape.decoder" + assert ( + result["binary_path"] + == "/vendor/bin/hw/android.hardware.media.c2@1.2-mediatek" + ) + @pytest.mark.skip(reason="Not implemented yet") def test_tombtone_kernel_parsing(self): tombstone_artifact = TombstoneCrashArtifact() diff --git a/tests/android/test_backup_parser.py b/tests/android/test_backup_parser.py index 4da9024..c0f867d 100644 --- a/tests/android/test_backup_parser.py +++ b/tests/android/test_backup_parser.py @@ -5,12 +5,35 @@ import hashlib -from mvt.android.parsers.backup import parse_backup_file, parse_tar_for_sms +import pytest + +from mvt.android.parsers.backup import ( + AndroidBackupParsingError, + 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_parse_truncated_encrypted_header(self): + with pytest.raises( + AndroidBackupParsingError, match="Invalid encrypted backup header" + ): + parse_backup_file( + b"ANDROID BACKUP\n5\n0\nAES-256\ntruncated", password="password" + ) + def test_parsing_noencryption(self): file = get_artifact("android_backup/backup.ab") with open(file, "rb") as f: @@ -60,7 +83,6 @@ class TestBackupParsing: == "33e73df2ede9798dcb3a85c06200ee41c8f52dd2f2e50ffafcceb0407bc13e3a" ) sms = parse_tar_for_sms(ddata) - print(sms) assert isinstance(sms, list) assert len(sms) == 1 assert len(sms[0]["links"]) == 1 diff --git a/tests/android/test_intrusion_logs.py b/tests/android/test_intrusion_logs.py new file mode 100644 index 0000000..2e68397 --- /dev/null +++ b/tests/android/test_intrusion_logs.py @@ -0,0 +1,333 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import 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): + path.write_text( + "\n".join(json.dumps(record) for record in records), + encoding="utf-8", + ) + + +def test_load_all_events_preserves_unknown_top_level_event(tmp_path): + _write_ndjson( + tmp_path / "intrusion.txt", + [ + { + "future_event": { + "event_time": 1_700_000_000_000, + "field": "value", + } + } + ], + ) + + module = IntrusionLogsModule(target_path=str(tmp_path)) + events = module.load_all_events(str(tmp_path)) + + assert events == { + "future_event": [ + { + "event_time": 1_700_000_000_000, + "field": "value", + } + ] + } + + +def test_check_intrusion_logs_warns_about_unknown_top_level_event_type( + tmp_path, caplog +): + _write_ndjson( + tmp_path / "intrusion.txt", + [ + { + "future_event": { + "event_time": 1_700_000_000_000, + "field": "value", + } + } + ], + ) + + with caplog.at_level(logging.WARNING): + cmd = CmdAndroidCheckIntrusionLogs(target_path=str(tmp_path)) + cmd.run() + + assert "Found unknown intrusion logging event type(s): future_event" in caplog.text + assert "Please open an issue on GitHub" in caplog.text + + +def test_check_intrusion_logs_parses_core_and_unknown_security_events( + tmp_path, caplog +): + _write_ndjson( + tmp_path / "intrusion.txt", + [ + { + "dns_event": { + "event_time": 1_700_000_000_000, + "hostname": "example.com", + "package_name": "com.example.app", + "ip_addresses": ["/1.2.3.4"], + } + }, + { + "connect_event": { + "event_time": 1_700_000_001_000, + "ip_address": "/5.6.7.8", + "port": 443, + "package_name": "com.example.app", + } + }, + { + "security_event": { + "event_time": 1_700_000_002_000_000_000, + "app_process_start": { + "process": "com.example.app", + "uid": 10_000, + "pid": 1234, + }, + } + }, + { + "security_event": { + "event_time": 1_700_000_003_000_000_000, + "future_google_event": { + "field": "value", + }, + } + }, + ], + ) + + with caplog.at_level(logging.WARNING): + cmd = CmdAndroidCheckIntrusionLogs(target_path=str(tmp_path)) + cmd.run() + + assert [module.__class__.__name__ for module in cmd.executed] == [ + "DnsEvent", + "ConnectEvent", + "SecurityEvent", + ] + assert [len(module.results) for module in cmd.executed] == [1, 1, 2] + + security_module = next( + module for module in cmd.executed if isinstance(module, SecurityEvent) + ) + assert security_module.event_type_counts["app_process_start"] == 1 + assert security_module.event_type_counts["future_google_event"] == 1 + + future_timeline_events = [ + event for event in cmd.timeline if event["event"] == "future_google_event" + ] + assert len(future_timeline_events) == 1 + assert "future_google_event" in future_timeline_events[0]["data"] + assert "field" in future_timeline_events[0]["data"] + assert ( + "Found unknown intrusion logging security event type(s): future_google_event" + in caplog.text + ) + assert "Please open an issue on GitHub" in caplog.text + + +def test_check_intrusion_logs_treats_event_id_as_security_event_metadata( + tmp_path, caplog +): + _write_ndjson( + tmp_path / "intrusion.txt", + [ + { + "security_event": { + "event_id": 191, + "event_time": 1_700_000_002_000_000_000, + "keyguard_dismiss_auth_attempt": { + "success": True, + "method_strength": 0, + }, + } + }, + { + "security_event": { + "event_id": 192, + "event_time": 1_700_000_003_000_000_000, + "keyguard_dismissed": {}, + } + }, + ], + ) + + with caplog.at_level(logging.WARNING): + cmd = CmdAndroidCheckIntrusionLogs(target_path=str(tmp_path)) + cmd.run() + + security_module = next( + module for module in cmd.executed if isinstance(module, SecurityEvent) + ) + assert security_module.event_type_counts == { + "keyguard_dismiss_auth_attempt": 1, + "keyguard_dismissed": 1, + } + assert [event["event_id"] for event in security_module.results] == [191, 192] + + keyguard_events = { + event["event"]: event + for event in cmd.timeline + if event["event"] + in {"keyguard_dismiss_auth_attempt", "keyguard_dismissed"} + } + assert "Auth attempt: Success" in keyguard_events[ + "keyguard_dismiss_auth_attempt" + ]["data"] + assert keyguard_events["keyguard_dismissed"]["data"] == "Keyguard dismissed" + assert "unknown intrusion logging security event type(s): event_id" not in caplog.text + + +def test_check_intrusion_logs_cli_lists_modules(tmp_path): + _write_ndjson(tmp_path / "intrusion.txt", []) + + result = CliRunner().invoke(check_intrusion_logs, ["--list-modules", str(tmp_path)]) + + assert result.exit_code == 0 + 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 + + +@pytest.mark.parametrize("success", [False, 0]) +def test_known_pinstorage_key_generation_failure_does_not_warn(success, caplog): + record = { + "timestamp": "2026-06-17 15:31:02.014", + "key_generated": { + "success": success, + "key_id": "PinStorage_crossReboot_key", + "uid": 1001, + }, + } + + with caplog.at_level(logging.WARNING): + _run_security_heuristics([record]) + + assert "Failed key generation detected" not in caplog.text + + timeline_event = SecurityEvent().serialize(record) + assert timeline_event["event"] == "key_generated" + assert "Key generation failed: PinStorage_crossReboot_key" in timeline_event["data"] + + +@pytest.mark.parametrize( + ("key_id", "uid"), + [ + ("PinStorage_crossReboot_key", 10_000), + ("another_key", 1001), + ], +) +def test_other_key_generation_failures_still_warn(key_id, uid, caplog): + with caplog.at_level(logging.WARNING): + _run_security_heuristics( + [ + { + "timestamp": "2026-06-17 15:31:02.014", + "key_generated": { + "success": False, + "key_id": key_id, + "uid": uid, + }, + } + ] + ) + + assert f"Failed key generation detected for key_id: {key_id}" in caplog.text + + +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) diff --git a/tests/android_androidqf/test_files.py b/tests/android_androidqf/test_files.py index c0d45b5..c854473 100644 --- a/tests/android_androidqf/test_files.py +++ b/tests/android_androidqf/test_files.py @@ -22,4 +22,4 @@ class TestAndroidqfFilesAnalysis: run_module(m) assert len(m.results) == 3 assert len(m.timeline) == 6 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 diff --git a/tests/android_androidqf/test_getprop.py b/tests/android_androidqf/test_getprop.py index 3947acd..9a938cc 100644 --- a/tests/android_androidqf/test_getprop.py +++ b/tests/android_androidqf/test_getprop.py @@ -26,7 +26,7 @@ class TestAndroidqfGetpropAnalysis: assert m.results[0]["name"] == "dalvik.vm.appimageformat" assert m.results[0]["value"] == "lz4" assert len(m.timeline) == 0 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 def test_getprop_parsing_zip(self): fpath = get_artifact("androidqf.zip") @@ -38,7 +38,7 @@ class TestAndroidqfGetpropAnalysis: assert m.results[0]["name"] == "dalvik.vm.appimageformat" assert m.results[0]["value"] == "lz4" assert len(m.timeline) == 0 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 def test_androidqf_getprop_detection(self, indicator_file): data_path = get_android_androidqf() @@ -52,5 +52,5 @@ class TestAndroidqfGetpropAnalysis: m.indicators = ind run_module(m) assert len(m.results) == 10 - assert len(m.detected) == 1 - assert m.detected[0]["name"] == "dalvik.vm.heapmaxfree" + assert len(m.alertstore.alerts) == 1 + assert m.alertstore.alerts[0].event["name"] == "dalvik.vm.heapmaxfree" diff --git a/tests/android_androidqf/test_mounts.py b/tests/android_androidqf/test_mounts.py index 95e8060..d66925d 100644 --- a/tests/android_androidqf/test_mounts.py +++ b/tests/android_androidqf/test_mounts.py @@ -6,6 +6,7 @@ import logging from pathlib import Path +from mvt.common.indicators import Indicator, IndicatorMatch from mvt.common.module import run_module from ..utils import get_android_androidqf, list_files @@ -72,6 +73,38 @@ class TestAndroidqfMountsArtifact: (("by-name/data" in s or "/data" in s) and "rw" in s) for s in concatenated ), f"No data-like tokens (data + rw) found in parsed results: {concatenated}" + def test_mount_ioc_alert_uses_indicator(self): + from mvt.android.artifacts.mounts import Mounts as MountsArtifact + + indicator = Indicator( + value="/system", + type="file_path", + name="TestMalware", + stix2_file_name="indicators.stix2", + ) + m = MountsArtifact() + m.indicators = type( + "MountIndicators", + (), + { + "check_file_path": lambda self, path: IndicatorMatch( + ioc=indicator, message="matched file path" + ) + if path == "/system" + else None + }, + )() + + m.parse("/dev/block/by-name/system on /system type ext4 (rw,seclabel)") + m.check_indicators() + + indicator_alerts = [ + alert for alert in m.alertstore.alerts if alert.matched_indicator + ] + assert len(indicator_alerts) == 1 + assert indicator_alerts[0].matched_indicator == indicator + assert indicator_alerts[0].message == "matched file path" + class TestAndroidqfMountsModule: def test_androidqf_module_no_mounts_file(self): @@ -94,4 +127,6 @@ class TestAndroidqfMountsModule: assert len(m.results) == 0, ( f"Expected no results when mounts.json is absent, got: {m.results}" ) - assert len(m.detected) == 0, f"Expected no detections, got: {m.detected}" + assert len(m.alertstore.alerts) == 0, ( + f"Expected no detections, got: {m.alertstore.alerts}" + ) diff --git a/tests/android_androidqf/test_packages.py b/tests/android_androidqf/test_packages.py index 966d8a6..a879e88 100644 --- a/tests/android_androidqf/test_packages.py +++ b/tests/android_androidqf/test_packages.py @@ -7,8 +7,11 @@ import logging from pathlib import Path import pytest +from click.testing import CliRunner +from mvt.android.cli import check_androidqf from mvt.android.modules.androidqf.aqf_packages import AQFPackages +from mvt.android.modules.androidqf import aqf_packages as aqf_packages_module from mvt.common.module import run_module from ..utils import get_android_androidqf, list_files @@ -47,38 +50,35 @@ class TestAndroidqfPackages: def test_non_appstore_warnings(self, caplog, module): run_module(module) - assert len(module.detected) == 4 + assert len(module.alertstore.alerts) == 5 # Not a super test to be searching logs for this but heuristic detections not yet formalised - assert ( - 'Found a non-system package installed via adb or another method: "com.whatsapp"' - in caplog.text - ) + adb_message = "Found a non-system package installed via adb or another method:" whatsapp_detected = [ - pkg for pkg in module.detected if pkg["name"] == "com.whatsapp" + alert + for alert in module.alertstore.alerts + if alert.event["name"] == "com.whatsapp" ] assert len(whatsapp_detected) == 1 + assert adb_message in whatsapp_detected[0].message - assert ( - 'Found a package installed via a browser (installer="com.google.android.packageinstaller"): ' - '"app.revanced.manager.flutter"' in caplog.text - ) + browser_message = 'Found a package installed via a browser (installer="com.google.android.packageinstaller"): ' revanced_detected = [ - pkg - for pkg in module.detected - if pkg["name"] == "app.revanced.manager.flutter" + alert + for alert in module.alertstore.alerts + if alert.event["name"] == "app.revanced.manager.flutter" ] assert len(revanced_detected) == 1 + assert browser_message in revanced_detected[0].message - assert ( - 'Found a package installed via a third party store (installer="org.fdroid.fdroid"): "org.nuclearfog.apollo"' - in caplog.text - ) - # We do not currently flag a third party store as a detection, we only flag the app in the logs. + third_party_message = 'Found a package installed via a third party store (installer="org.fdroid.fdroid")' appollo_detected = [ - pkg for pkg in module.detected if pkg["name"] == "org.nuclearfog.apollo" + alert + for alert in module.alertstore.alerts + if alert.event["name"] == "org.nuclearfog.apollo" ] - assert len(appollo_detected) == 0 + assert len(appollo_detected) == 1 + assert third_party_message in appollo_detected[0].message def test_packages_ioc_package_names(self, module, indicators_factory): module.indicators = indicators_factory(app_ids=["com.malware.blah"]) @@ -86,13 +86,13 @@ class TestAndroidqfPackages: run_module(module) possible_detected_app = [ - pkg for pkg in module.detected if pkg["name"] == "com.malware.blah" + alert + for alert in module.alertstore.alerts + if alert.event["name"] == "com.malware.blah" ] assert len(possible_detected_app) == 1 - assert possible_detected_app[0]["name"] == "com.malware.blah" - assert ( - possible_detected_app[0]["matched_indicator"]["value"] == "com.malware.blah" - ) + assert possible_detected_app[0].event["name"] == "com.malware.blah" + assert possible_detected_app[0].matched_indicator.value == "com.malware.blah" def test_packages_ioc_sha256(self, module, indicators_factory): module.indicators = indicators_factory( @@ -104,12 +104,14 @@ class TestAndroidqfPackages: run_module(module) possible_detected_app = [ - pkg for pkg in module.detected if pkg["name"] == "com.malware.muahaha" + alert + for alert in module.alertstore.alerts + if alert.event["name"] == "com.malware.muahaha" ] assert len(possible_detected_app) == 1 - assert possible_detected_app[0]["name"] == "com.malware.muahaha" + assert possible_detected_app[0].event["name"] == "com.malware.muahaha" assert ( - possible_detected_app[0]["matched_indicator"]["value"] + possible_detected_app[0].matched_indicator.value == "31037a27af59d4914906c01ad14a318eee2f3e31d48da8954dca62a99174e3fa" ) @@ -123,11 +125,65 @@ class TestAndroidqfPackages: run_module(module) possible_detected_app = [ - pkg for pkg in module.detected if pkg["name"] == "com.malware.muahaha" + alert + for alert in module.alertstore.alerts + if alert.event["name"] == "com.malware.muahaha" ] assert len(possible_detected_app) == 1 - assert possible_detected_app[0]["name"] == "com.malware.muahaha" + assert possible_detected_app[0].event["name"] == "com.malware.muahaha" assert ( - possible_detected_app[0]["matched_indicator"]["value"] + possible_detected_app[0].matched_indicator.value == "c7e56178748be1441370416d4c10e34817ea0c961eb636c8e9d98e0fd79bf730" ) + + def test_virustotal_delays_after_missing_result(self, monkeypatch): + lookups = [] + sleeps = [] + + def fake_virustotal_lookup(file_hash): + lookups.append(file_hash) + if file_hash == "missing_hash": + return None + return { + "attributes": { + "last_analysis_stats": {"malicious": 1}, + "last_analysis_results": {"engine": {}}, + } + } + + monkeypatch.setattr( + aqf_packages_module, "virustotal_lookup", fake_virustotal_lookup + ) + monkeypatch.setattr(aqf_packages_module.time, "sleep", sleeps.append) + + module = AQFPackages( + module_options={"virustotal": True, "virustotal_delay": 16}, + results=[ + { + "name": "org.example", + "installer": "com.android.vending", + "disabled": False, + "system": False, + "files": [ + {"path": "/data/app/missing.apk", "sha256": "missing_hash"}, + {"path": "/data/app/found.apk", "sha256": "found_hash"}, + ], + } + ], + ) + + module.check_indicators() + + assert lookups == ["missing_hash", "found_hash"] + assert sleeps == [16] + assert module.results[0]["files"][1]["virustotal"] == "1/1" + assert len(module.alertstore.alerts) == 1 + + +def test_check_androidqf_rejects_negative_virustotal_delay(data_path): + runner = CliRunner() + + result = runner.invoke(check_androidqf, ["--delay", "-1", data_path]) + + assert result.exit_code == 2 + assert "Invalid value for '--delay'" in result.output diff --git a/tests/android_androidqf/test_processes.py b/tests/android_androidqf/test_processes.py index bcd4013..da75fa5 100644 --- a/tests/android_androidqf/test_processes.py +++ b/tests/android_androidqf/test_processes.py @@ -22,4 +22,4 @@ class TestAndroidqfProcessesAnalysis: run_module(m) assert len(m.results) == 15 assert len(m.timeline) == 0 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 diff --git a/tests/android_androidqf/test_root_binaries.py b/tests/android_androidqf/test_root_binaries.py index 5d6b770..b014f58 100644 --- a/tests/android_androidqf/test_root_binaries.py +++ b/tests/android_androidqf/test_root_binaries.py @@ -42,7 +42,7 @@ class TestAndroidqfRootBinaries: # Should find 4 root binaries from the test file assert len(module.results) == 4 - assert len(module.detected) == 4 + assert len(module.alertstore.alerts) == 4 # Check that all results are detected as indicators binary_paths = [result["path"] for result in module.results] @@ -113,4 +113,4 @@ class TestAndroidqfRootBinaries: run_module(m) assert len(m.results) == 0 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 diff --git a/tests/android_androidqf/test_settings.py b/tests/android_androidqf/test_settings.py index 75527a7..ce14460 100644 --- a/tests/android_androidqf/test_settings.py +++ b/tests/android_androidqf/test_settings.py @@ -21,4 +21,5 @@ class TestSettingsModule: run_module(m) assert len(m.results) == 1 assert "random" in m.results.keys() - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 1 + assert "samsung_errorlog_agree" in m.alertstore.alerts[0].message diff --git a/tests/android_androidqf/test_sms.py b/tests/android_androidqf/test_sms.py deleted file mode 100644 index d7433cf..0000000 --- a/tests/android_androidqf/test_sms.py +++ /dev/null @@ -1,91 +0,0 @@ -# Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. -# Use of this software is governed by the MVT License 1.1 that can be found at -# https://license.mvt.re/1.1/ - -import logging -import os -from pathlib import Path - -from mvt.android.modules.androidqf.sms import SMS -from mvt.common.module import run_module - -from ..utils import get_android_androidqf, get_artifact_folder, list_files - -TEST_BACKUP_PASSWORD = "123456" - - -class TestAndroidqfSMSAnalysis: - def test_androidqf_sms(self): - data_path = get_android_androidqf() - m = SMS(target_path=data_path, log=logging) - files = list_files(data_path) - parent_path = Path(data_path).absolute().parent.as_posix() - m.from_dir(parent_path, files) - run_module(m) - assert len(m.results) == 2 - assert len(m.timeline) == 0 - assert len(m.detected) == 0 - - def test_androidqf_sms_encrypted_password_valid(self): - data_path = os.path.join(get_artifact_folder(), "androidqf_encrypted") - m = SMS( - target_path=data_path, - log=logging, - module_options={"backup_password": TEST_BACKUP_PASSWORD}, - ) - files = list_files(data_path) - parent_path = Path(data_path).absolute().parent.as_posix() - m.from_dir(parent_path, files) - run_module(m) - assert len(m.results) == 1 - - def test_androidqf_sms_encrypted_password_prompt(self, mocker): - data_path = os.path.join(get_artifact_folder(), "androidqf_encrypted") - prompt_mock = mocker.patch( - "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD - ) - m = SMS( - target_path=data_path, - log=logging, - module_options={}, - ) - files = list_files(data_path) - parent_path = Path(data_path).absolute().parent.as_posix() - m.from_dir(parent_path, files) - run_module(m) - assert prompt_mock.call_count == 1 - assert len(m.results) == 1 - - def test_androidqf_sms_encrypted_password_invalid(self, caplog): - data_path = os.path.join(get_artifact_folder(), "androidqf_encrypted") - with caplog.at_level(logging.CRITICAL): - m = SMS( - target_path=data_path, - log=logging, - module_options={"backup_password": "invalid_password"}, - ) - files = list_files(data_path) - parent_path = Path(data_path).absolute().parent.as_posix() - m.from_dir(parent_path, files) - run_module(m) - assert len(m.results) == 0 - assert "Invalid backup password" in caplog.text - - def test_androidqf_sms_encrypted_no_interactive(self, caplog): - data_path = os.path.join(get_artifact_folder(), "androidqf_encrypted") - with caplog.at_level(logging.CRITICAL): - m = SMS( - target_path=data_path, - log=logging, - module_options={"interactive": False}, - ) - files = list_files(data_path) - parent_path = Path(data_path).absolute().parent.as_posix() - m.from_dir(parent_path, files) - run_module(m) - assert len(m.results) == 0 - assert ( - "Cannot decrypt backup because interactivity was disabled and the password was not supplied" - in caplog.text - ) diff --git a/tests/android_androidqf/test_tcc.py b/tests/android_androidqf/test_tcc.py new file mode 100644 index 0000000..d1bf073 --- /dev/null +++ b/tests/android_androidqf/test_tcc.py @@ -0,0 +1,36 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import logging + +from mvt.common.indicators import Indicators +from mvt.common.module import run_module +from mvt.ios.modules.mixed.tcc import TCC + +from ..utils import get_ios_backup_folder + + +class TestTCCModule: + def test_tcc(self): + m = TCC(target_path=get_ios_backup_folder()) + run_module(m) + assert len(m.results) == 11 + assert len(m.timeline) == 11 + assert len(m.alertstore.alerts) == 0 + assert m.results[0]["service"] == "kTCCServiceUbiquity" + assert m.results[0]["client"] == "com.apple.Preferences" + assert m.results[0]["auth_value"] == "allowed" + + def test_tcc_detection(self, indicator_file): + m = TCC(target_path=get_ios_backup_folder()) + ind = Indicators(log=logging.getLogger()) + ind.parse_stix2(indicator_file) + m.indicators = ind + run_module(m) + assert len(m.results) == 11 + assert len(m.timeline) == 11 + assert len(m.alertstore.alerts) == 1 + assert m.alertstore.alerts[0].event["service"] == "kTCCServiceLiverpool" + assert m.alertstore.alerts[0].event["client"] == "Launch" diff --git a/tests/android_bugreport/test_bugreport.py b/tests/android_bugreport/test_bugreport.py index 18fa387..75df106 100644 --- a/tests/android_bugreport/test_bugreport.py +++ b/tests/android_bugreport/test_bugreport.py @@ -9,6 +9,7 @@ from pathlib import Path from mvt.android.modules.bugreport.dumpsys_appops import DumpsysAppops from mvt.android.modules.bugreport.dumpsys_getprop import DumpsysGetProp from mvt.android.modules.bugreport.dumpsys_packages import DumpsysPackages +from mvt.android.modules.bugreport.dumpsys_receivers import DumpsysReceivers from mvt.android.modules.bugreport.tombstones import Tombstones from mvt.common.module import run_module @@ -36,9 +37,13 @@ class TestBugreportAnalysis: assert len(m.timeline) == 16 detected_by_ioc = [ - detected for detected in m.detected if detected.get("matched_indicator") + detected + for detected in m.alertstore.alerts + if detected.event.get("matched_indicator") ] - assert len(m.detected) == 1 # Hueristic detection for suspicious permissions + assert ( + len(m.alertstore.alerts) == 1 + ) # Hueristic detection for suspicious permissions assert len(detected_by_ioc) == 0 def test_packages_module(self): @@ -49,6 +54,8 @@ class TestBugreportAnalysis: == "com.samsung.android.provider.filterprovider" ) assert m.results[1]["package_name"] == "com.instagram.android" + assert m.results[0]["installer"] == "" + assert m.results[1]["installer"] == "com.android.vending" assert len(m.results[0]["permissions"]) == 4 assert len(m.results[1]["permissions"]) == 32 @@ -56,6 +63,31 @@ class TestBugreportAnalysis: m = self.launch_bug_report_module(DumpsysGetProp) assert len(m.results) == 0 + def test_receivers_match_exact_package_name(self, indicators_factory): + intent = "android.intent.action.PHONE_STATE" + false_positive = { + "package_name": "com.android.phone", + "receiver": ( + "com.android.phone/" + "com.android.services.telephony.sip.SipIncomingCallReceiver" + ), + } + malicious_receiver = { + "package_name": "com.android.services", + "receiver": "com.android.services/com.example.SomeReceiver", + } + module = DumpsysReceivers( + results={intent: [false_positive, malicious_receiver]} + ) + module.indicators = indicators_factory(app_ids=["com.android.services"]) + + module.check_indicators() + + assert len(module.alertstore.alerts) == 1 + alert = module.alertstore.alerts[0] + assert alert.event == {intent: malicious_receiver} + assert alert.matched_indicator.value == "com.android.services" + def test_tombstones_modules(self): m = self.launch_bug_report_module(Tombstones) assert len(m.results) == 2 diff --git a/tests/artifacts/android_data/dumpsys_adb_wifi.txt b/tests/artifacts/android_data/dumpsys_adb_wifi.txt new file mode 100644 index 0000000..ecd7f15 Binary files /dev/null and b/tests/artifacts/android_data/dumpsys_adb_wifi.txt differ diff --git a/tests/common/test_alerts.py b/tests/common/test_alerts.py new file mode 100644 index 0000000..a90f5e1 --- /dev/null +++ b/tests/common/test_alerts.py @@ -0,0 +1,49 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2026 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +from mvt.common.alerts import Alert, AlertLevel, AlertStore + + +def test_as_json_promotes_nested_matched_indicator(): + indicator = {"value": "com.apple.weather", "type": "processes"} + alertstore = AlertStore() + alertstore.add( + Alert( + level=AlertLevel.CRITICAL, + module="datausage", + message="Matched indicator", + event_time="2026-01-01 00:00:00", + event={ + "proc_name": "WeatherWidget/com.apple.weather", + "matched_indicator": indicator, + }, + ) + ) + + alert = alertstore.as_json()[0] + + assert alert["matched_indicator"] == indicator + assert "matched_indicator" not in alert["event"] + + +def test_as_json_removes_nested_matched_indicator_when_parent_exists(): + event_indicator = {"value": "nested", "type": "processes"} + alert_indicator = {"value": "parent", "type": "processes"} + alertstore = AlertStore() + alertstore.add( + Alert( + level=AlertLevel.CRITICAL, + module="manifest", + message="Matched indicator", + event_time="2026-01-01 00:00:00", + event={"path": "/tmp/example", "matched_indicator": event_indicator}, + matched_indicator=alert_indicator, + ) + ) + + alert = alertstore.as_json()[0] + + assert alert["matched_indicator"] == alert_indicator + assert "matched_indicator" not in alert["event"] diff --git a/tests/common/test_cli_plugins.py b/tests/common/test_cli_plugins.py new file mode 100644 index 0000000..ea7c7fa --- /dev/null +++ b/tests/common/test_cli_plugins.py @@ -0,0 +1,371 @@ +from types import SimpleNamespace + +import click +from click.testing import CliRunner + +from mvt.common.cli_plugins import ( + ANDROID_CLI_PLUGIN_GROUP, + IOS_CLI_PLUGIN_GROUP, + BrokenPluginCommand, + load_cli_commands_option, + register_cli_commands_from_path, + register_cli_plugins, + register_installed_cli_commands, +) + + +COMMAND_TEMPLATE = """ +import click + + +@click.command({name!r}) +@click.pass_context +def cli(ctx): + click.echo({message!r}) + if ctx.obj: + click.echo(ctx.obj.get("marker", "")) +""" + + +def _write_command(path, name, message="command ran"): + path.write_text( + COMMAND_TEMPLATE.format(name=name, message=message), + encoding="utf-8", + ) + return path + + +def _make_group(): + @click.group() + @load_cli_commands_option + @click.pass_context + def group(ctx): + ctx.ensure_object(dict) + ctx.obj["marker"] = "parent context" + + return group + + +def _entry_point(name, value, command=None, exception=None, distribution="plugin"): + def load(): + if exception is not None: + raise exception + return command + + dist = SimpleNamespace(metadata={"Name": distribution}, version="1.0") + return SimpleNamespace(name=name, value=value, load=load, dist=dist) + + +def test_load_command_option_registers_command_before_resolution(tmp_path): + command_path = _write_command(tmp_path / "hello.py", "hello") + group = _make_group() + + result = CliRunner().invoke( + group, + ["--load-command", str(command_path), "hello"], + ) + + assert result.exit_code == 0 + assert "command ran" in result.output + assert "parent context" in result.output + + +def test_load_command_option_supports_folders_and_repeated_paths(tmp_path): + folder = tmp_path / "commands" + folder.mkdir() + _write_command(folder / "b.py", "second") + _write_command(folder / "a.py", "first") + _write_command(folder / ".hidden.py", "hidden") + _write_command(folder / "__init__.py", "init") + other = _write_command(tmp_path / "third.py", "third") + group = _make_group() + + result = CliRunner().invoke( + group, + [ + "--load-command", + str(folder), + "--load-command", + str(other), + "--load-command", + str(other), + "--help", + ], + ) + + assert result.exit_code == 0 + assert "first" in result.output + assert "second" in result.output + assert "third" in result.output + assert "hidden" not in result.output + assert "init" not in result.output + + +def test_loaded_command_participates_in_shell_completion(tmp_path): + command_path = _write_command(tmp_path / "hello.py", "hello") + group = _make_group() + words = f"group --load-command {command_path} he" + + result = CliRunner().invoke( + group, + [], + env={ + "_GROUP_COMPLETE": "bash_complete", + "COMP_WORDS": words, + "COMP_CWORD": "3", + }, + ) + + assert result.exit_code == 0 + assert "plain,hello" in result.output + + +def test_explicit_command_import_and_contract_failures_are_usage_errors(tmp_path): + broken_path = tmp_path / "broken.py" + broken_path.write_text("raise RuntimeError('broken import')", encoding="utf-8") + missing_cli_path = tmp_path / "missing_cli.py" + missing_cli_path.write_text("value = 1", encoding="utf-8") + + broken_result = CliRunner().invoke( + _make_group(), + ["--load-command", str(broken_path), "broken"], + ) + missing_cli_result = CliRunner().invoke( + _make_group(), + ["--load-command", str(missing_cli_path), "missing-cli"], + ) + + assert broken_result.exit_code == 2 + assert "broken import" in broken_result.output + assert missing_cli_result.exit_code == 2 + assert "must export a Click command or group named 'cli'" in ( + missing_cli_result.output + ) + + +def test_environment_command_failure_gets_broken_placeholder(tmp_path): + command_path = tmp_path / "broken_command.py" + command_path.write_text("raise RuntimeError('broken import')", encoding="utf-8") + group = click.Group() + + registered = register_cli_commands_from_path(group, command_path) + + assert registered == ["broken-command"] + assert isinstance(group.commands["broken-command"], BrokenPluginCommand) + result = CliRunner().invoke(group, ["broken-command"]) + assert result.exit_code == 1 + assert "broken import" in result.output + assert str(command_path) in result.output + + +def test_environment_command_system_exit_gets_broken_placeholder(tmp_path): + command_path = tmp_path / "exiting_command.py" + command_path.write_text("raise SystemExit(7)", encoding="utf-8") + group = click.Group() + + registered = register_cli_commands_from_path(group, command_path) + + assert registered == ["exiting-command"] + assert isinstance(group.commands["exiting-command"], BrokenPluginCommand) + result = CliRunner().invoke(group, ["exiting-command"]) + assert result.exit_code == 1 + assert "Unable to import custom command" in result.output + assert result.output.rstrip().endswith(": 7") + + +def test_installed_entry_point_name_is_the_command_name(monkeypatch): + @click.command("internal-name") + def command(): + click.echo("installed command ran") + + entry_point = _entry_point( + "external-name", + "example_plugin:cli", + command=command, + ) + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + lambda **kwargs: [entry_point], + ) + group = click.Group() + + registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP) + + assert registered == ["external-name"] + assert "internal-name" not in group.commands + result = CliRunner().invoke(group, ["external-name"]) + assert result.exit_code == 0 + assert result.output == "installed command ran\n" + + +def test_broken_installed_plugin_does_not_break_cli(monkeypatch): + broken = _entry_point( + "broken", + "broken_plugin:cli", + exception=RuntimeError("missing dependency"), + distribution="broken-plugin", + ) + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + lambda **kwargs: [broken], + ) + group = click.Group() + + register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP) + + help_result = CliRunner().invoke(group, ["--help"]) + assert help_result.exit_code == 0 + assert "Warning: external command could not be loaded." in help_result.output + + result = CliRunner().invoke(group, ["broken"]) + assert result.exit_code == 1 + assert "broken-plugin 1.0 (broken_plugin:cli)" in result.output + assert "RuntimeError: missing dependency" in result.output + + +def test_installed_plugin_system_exit_does_not_break_cli(monkeypatch): + exiting = _entry_point( + "exiting", + "exiting_plugin:cli", + exception=SystemExit(7), + distribution="exiting-plugin", + ) + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + lambda **kwargs: [exiting], + ) + group = click.Group() + + register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP) + + help_result = CliRunner().invoke(group, ["--help"]) + assert help_result.exit_code == 0 + result = CliRunner().invoke(group, ["exiting"]) + assert result.exit_code == 1 + assert "SystemExit: 7" in result.output + + +def test_non_click_entry_point_gets_broken_placeholder(monkeypatch): + invalid = _entry_point("invalid", "plugin:value", command=object()) + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + lambda **kwargs: [invalid], + ) + group = click.Group() + + register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP) + + assert isinstance(group.commands["invalid"], BrokenPluginCommand) + result = CliRunner().invoke(group, ["invalid"]) + assert result.exit_code == 1 + assert "must resolve to a Click command or group" in result.output + + +def test_entry_point_discovery_failure_does_not_break_group(monkeypatch, caplog): + def fail_discovery(**kwargs): + raise RuntimeError("invalid package metadata") + + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + fail_discovery, + ) + group = click.Group() + + registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP) + + assert registered == [] + assert not group.commands + assert "Unable to discover external commands" in caplog.text + assert "invalid package metadata" in caplog.text + + +def test_builtin_and_first_external_command_win_collisions(monkeypatch, caplog): + @click.command("version") + def core_version(): + pass + + @click.command() + def first(): + pass + + @click.command() + def second(): + pass + + entry_points = [ + _entry_point("duplicate", "z_plugin:cli", command=second, distribution="z"), + _entry_point("version", "plugin:version", command=first), + _entry_point("duplicate", "a_plugin:cli", command=first, distribution="a"), + ] + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + lambda **kwargs: entry_points, + ) + group = click.Group(commands={"version": core_version}) + + registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP) + + assert registered == ["duplicate"] + assert group.commands["version"] is core_version + assert group.commands["duplicate"] is first + assert "the command name is already registered" in caplog.text + + +def test_explicit_command_cannot_replace_existing_command(tmp_path): + command_path = _write_command(tmp_path / "version.py", "version") + group = _make_group() + + @group.command("version") + def core_version(): + pass + + result = CliRunner().invoke( + group, + ["--load-command", str(command_path), "version"], + ) + + assert result.exit_code == 2 + assert "the command name is already registered" in result.output + + +def test_platform_entry_point_groups_and_environment_paths_are_separate( + tmp_path, monkeypatch +): + ios_path = _write_command(tmp_path / "ios.py", "ios-file") + android_path = _write_command(tmp_path / "android.py", "android-file") + + @click.command() + def ios_package(): + pass + + @click.command() + def android_package(): + pass + + def entry_points(*, group): + if group == IOS_CLI_PLUGIN_GROUP: + return [_entry_point("ios-package", "ios_plugin:cli", ios_package)] + return [_entry_point("android-package", "android_plugin:cli", android_package)] + + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + entry_points, + ) + monkeypatch.setenv("TEST_IOS_COMMANDS", str(ios_path)) + monkeypatch.setenv("TEST_ANDROID_COMMANDS", str(android_path)) + ios_group = click.Group() + android_group = click.Group() + + register_cli_plugins( + ios_group, + entry_point_group=IOS_CLI_PLUGIN_GROUP, + environment_variable="TEST_IOS_COMMANDS", + ) + register_cli_plugins( + android_group, + entry_point_group=ANDROID_CLI_PLUGIN_GROUP, + environment_variable="TEST_ANDROID_COMMANDS", + ) + + assert set(ios_group.commands) == {"ios-file", "ios-package"} + assert set(android_group.commands) == {"android-file", "android-package"} diff --git a/tests/common/test_command.py b/tests/common/test_command.py new file mode 100644 index 0000000..4dbfe1a --- /dev/null +++ b/tests/common/test_command.py @@ -0,0 +1,222 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import json +import logging + +from mvt.common.command import Command +from mvt.common.module import MVTModule + + +class RecordingModule(MVTModule): + run_order: list[str] = [] + + def run(self): + self.run_order.append(self.__class__.__name__) + + def check_indicators(self): + pass + + +class FirstModule(RecordingModule): + def run(self): + super().run() + self.results = ["first"] + + +class SecondModule(RecordingModule): + dependencies = (FirstModule,) + + def run(self): + super().run() + self.results = self.get_dependency_results(FirstModule) + ["second"] + + +class ThirdModule(RecordingModule): + dependencies = (SecondModule,) + + +class IndependentModule(RecordingModule): + pass + + +class URLRecordingModule(RecordingModule): + def collect_url_results(self): + self.add_url_result( + "https://example.org/message", + "2026-07-29 12:00:00.000000", + "test-chat", + ) + + +class CustomIOSBackupModule(RecordingModule): + supported_commands = (("ios", "check-backup"),) + + +class CustomIOSFSModule(RecordingModule): + supported_commands = (("ios", "check-fs"),) + + +class UnscopedCustomModule(RecordingModule): + pass + + +class CustomDependsOnBuiltin(RecordingModule): + supported_commands = (("ios", "check-backup"),) + dependencies = (FirstModule,) + + +class RecordingCommand(Command): + def init(self): + self.initialized = True + + def module_init(self, module): + pass + + def finish(self): + pass + + +class TestCommand: + def setup_method(self): + RecordingModule.run_order = [] + + def test_store_alerts_handles_bytes(self, tmp_path): + cmd = Command(results_path=str(tmp_path)) + cmd.alertstore.medium( + "bytes event", + "", + {"payload": b"\xa8\xa9"}, + ) + + cmd._store_alerts() + + alerts = json.loads((tmp_path / "alerts.json").read_text()) + assert alerts[0]["event"]["payload"] == "\\xa8\\xa9" + + def test_stores_collected_urls(self, tmp_path): + cmd = RecordingCommand(results_path=str(tmp_path)) + cmd.modules = [URLRecordingModule] + + cmd.run() + + assert json.loads((tmp_path / "urls.json").read_text()) == [ + { + "url": "https://example.org/message", + "expanded_url": None, + "timestamp": "2026-07-29 12:00:00.000000", + "source": "test-chat", + } + ] + + def test_modules_run_in_stable_topological_order(self): + cmd = RecordingCommand() + cmd.modules = [ThirdModule, IndependentModule, SecondModule, FirstModule] + + cmd.run() + + assert RecordingModule.run_order == [ + "IndependentModule", + "FirstModule", + "SecondModule", + "ThirdModule", + ] + second = next(module for module in cmd.executed if isinstance(module, SecondModule)) + assert second.results == ["first", "second"] + + def test_selected_module_runs_transitive_dependencies(self): + cmd = RecordingCommand(module_name="ThirdModule") + cmd.modules = [ThirdModule, SecondModule, FirstModule, IndependentModule] + + cmd.run() + + assert RecordingModule.run_order == [ + "FirstModule", + "SecondModule", + "ThirdModule", + ] + + def test_circular_dependency_warns_and_stops(self, caplog): + class CircularOne(RecordingModule): + pass + + class CircularTwo(RecordingModule): + dependencies = (CircularOne,) + + CircularOne.dependencies = (CircularTwo,) + + cmd = RecordingCommand() + cmd.modules = [CircularOne, CircularTwo] + + with caplog.at_level(logging.WARNING): + cmd.run() + + assert RecordingModule.run_order == [] + assert not hasattr(cmd, "initialized") + assert "Circular module dependency detected" in caplog.text + + def test_unavailable_dependency_warns_and_stops(self, caplog): + class UnavailableModule(RecordingModule): + pass + + class DependentModule(RecordingModule): + dependencies = (UnavailableModule,) + + cmd = RecordingCommand() + cmd.modules = [DependentModule] + + with caplog.at_level(logging.WARNING): + cmd.run() + + 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, + UnscopedCustomModule, + ] + + 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"] diff --git a/tests/common/test_indicators.py b/tests/common/test_indicators.py index 00c7276..ac50f82 100644 --- a/tests/common/test_indicators.py +++ b/tests/common/test_indicators.py @@ -5,7 +5,9 @@ import logging import os +import threading +import requests from mvt.common.config import settings from mvt.common.indicators import Indicators @@ -80,6 +82,136 @@ class TestIndicators: assert ind.check_url("https://198.51.100.1:8080/") assert ind.check_url("https://1.1.1.1/") is None + def test_google_maps_short_url_is_not_resolved(self, indicator_file, mocker): + head_request = mocker.patch("mvt.common.url.requests.head") + ind = Indicators(log=logging) + ind.load_indicators_files([indicator_file], load_default=False) + + assert ind.check_url("https://goo.gl/maps/example") is None + head_request.assert_not_called() + + def test_check_url_batches_preserves_order(self, indicator_file): + ind = Indicators(log=logging) + ind.load_indicators_files([indicator_file], load_default=False) + + matches = ind.check_url_batches( + [ + [ + "https://github.com", + "http://example.com/thisisbad", + "https://www.example.org/foobar", + ], + ["https://github.com", "https://www.example.org/foobar"], + [], + None, + ] + ) + + assert matches[0] + assert matches[0].ioc.value == "http://example.com/thisisbad" + assert matches[1] + assert matches[1].ioc.value == "example.org" + assert matches[2] is None + assert matches[3] is None + + def test_check_url_batches_deduplicates_and_limits_workers( + self, indicator_file, mocker + ): + ind = Indicators(log=logging) + ind.load_indicators_files([indicator_file], load_default=False) + mocker.patch("mvt.common.indicators.URL_CHECK_MAX_WORKERS", 2) + + barrier = threading.Barrier(2) + lock = threading.Lock() + calls = [] + active = 0 + max_active = 0 + + def head_request(url, timeout): + nonlocal active, max_active + with lock: + calls.append(url) + active += 1 + max_active = max(max_active, active) + call_number = len(calls) + + try: + if call_number <= 2: + barrier.wait(timeout=5) + return mocker.Mock(status_code=200, headers={}) + finally: + with lock: + active -= 1 + + mocker.patch("mvt.common.url.requests.head", side_effect=head_request) + urls = [ + "https://bit.ly/one", + "https://tinyurl.com/two", + "https://t.co/three", + ] + + assert ind.check_url_batches([urls, [urls[0]]]) == [None, None] + assert sorted(calls) == sorted(urls) + assert max_active == 2 + + def test_check_url_batches_respects_disabled_network( + self, indicator_file, mocker + ): + ind = Indicators(log=logging) + ind.load_indicators_files([indicator_file], load_default=False) + mocker.patch("mvt.common.indicators.settings.NETWORK_ACCESS_ALLOWED", False) + head_request = mocker.patch("mvt.common.url.requests.head") + + assert ind.check_url_batches([["https://bit.ly/example"]]) == [None] + head_request.assert_not_called() + + def test_check_url_batches_handles_nested_redirects_and_request_failures( + self, indicator_file, mocker + ): + ind = Indicators(log=logging) + ind.load_indicators_files([indicator_file], load_default=False) + + def head_request(url, timeout): + if url == "https://bit.ly/failure": + raise requests.Timeout() + if url == "https://tinyurl.com/nested": + return mocker.Mock( + status_code=301, + headers={"Location": "https://t.co/nested"}, + ) + if url == "https://t.co/nested": + return mocker.Mock( + status_code=302, + headers={"Location": "https://www.example.org/landing"}, + ) + raise AssertionError(f"Unexpected URL: {url}") + + head = mocker.patch( + "mvt.common.url.requests.head", side_effect=head_request + ) + + matches = ind.check_url_batches( + [["https://bit.ly/failure"], ["https://tinyurl.com/nested"]] + ) + + assert matches[0] is None + assert matches[1] + assert matches[1].ioc.value == "example.org" + assert ( + ind.get_expanded_url("https://tinyurl.com/nested") + == "https://www.example.org/landing" + ) + assert ( + ind.get_expanded_url("https://t.co/nested") + == "https://www.example.org/landing" + ) + assert ind.get_expanded_url("https://bit.ly/failure") is None + assert {call.args[0] for call in head.call_args_list} == { + "https://bit.ly/failure", + "https://tinyurl.com/nested", + "https://t.co/nested", + } + def test_check_file_hash(self, indicator_file): ind = Indicators(log=logging) ind.load_indicators_files([indicator_file], load_default=False) diff --git a/tests/common/test_module_loader.py b/tests/common/test_module_loader.py new file mode 100644 index 0000000..15b6052 --- /dev/null +++ b/tests/common/test_module_loader.py @@ -0,0 +1,146 @@ +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_requires_explicit_declaration(tmp_path, caplog): + module_path = _write_module(tmp_path / "custom.py", "DefaultModule") + module = load_custom_modules_from_path(str(module_path))[0] + + assert not module_supports_command(module, "ios", "check-backup") + assert not module_supports_command(module, "android", "check-bugreport") + assert "DefaultModule has no supported_commands" in caplog.text + + +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") diff --git a/tests/common/test_password.py b/tests/common/test_password.py new file mode 100644 index 0000000..32845fb --- /dev/null +++ b/tests/common/test_password.py @@ -0,0 +1,28 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2026 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +from io import StringIO + +from mvt.common.password import _readline_with_asterisks + + +def test_readline_with_asterisks(): + output = StringIO() + + password = _readline_with_asterisks( + output, StringIO("pass\x7fword\n"), "Enter backup password: " + ) + + assert password == "pasword" + assert output.getvalue() == "Enter backup password: ****\b \b****" + + +def test_readline_with_asterisks_ignores_nul_and_handles_eof(): + output = StringIO() + + password = _readline_with_asterisks(output, StringIO("a\x00b\x04\x04"), "") + + assert password == "ab" + assert output.getvalue() == "**" diff --git a/tests/common/test_url.py b/tests/common/test_url.py new file mode 100644 index 0000000..ce0fb63 --- /dev/null +++ b/tests/common/test_url.py @@ -0,0 +1,24 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import pytest + +from mvt.common.url import URL + + +@pytest.mark.parametrize( + "url", + [ + "https://goo.gl/maps/example", + "http://goo.gl/maps/example?entry=message", + "goo.gl/maps/example", + ], +) +def test_google_maps_url_is_not_shortened(url): + assert URL(url).check_if_shortened() is False + + +def test_other_google_short_url_is_shortened(): + assert URL("https://goo.gl/example").check_if_shortened() is True diff --git a/tests/conftest.py b/tests/conftest.py index fb5cabf..c89f629 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,7 @@ def indicators_factory(indicator_file): domains=[], emails=[], file_names=[], + file_paths=[], processes=[], app_ids=[], app_cert_hashes=[], @@ -47,6 +48,7 @@ def indicators_factory(indicator_file): ind.ioc_collections[0]["domains"].extend(domains) ind.ioc_collections[0]["emails"].extend(emails) ind.ioc_collections[0]["file_names"].extend(file_names) + ind.ioc_collections[0]["file_paths"].extend(file_paths) ind.ioc_collections[0]["processes"].extend(processes) ind.ioc_collections[0]["app_ids"].extend(app_ids) ind.ioc_collections[0]["android_property_names"].extend(android_property_names) diff --git a/tests/ios_backup/test_calendar.py b/tests/ios_backup/test_calendar.py index de3f32c..5f1035d 100644 --- a/tests/ios_backup/test_calendar.py +++ b/tests/ios_backup/test_calendar.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ import logging +import os from mvt.common.indicators import Indicators from mvt.common.module import run_module @@ -18,7 +19,19 @@ class TestCalendarModule: run_module(m) assert len(m.results) == 1 assert len(m.timeline) == 4 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 + assert m.results[0]["summary"] == "Super interesting meeting" + + def test_calendar_with_explicit_file_path(self): + backup_path = get_ios_backup_folder() + database_path = os.path.join( + backup_path, "20", "2041457d5fe04d39d0ab481178355df6781e6858" + ) + m = Calendar(file_path=database_path) + + run_module(m) + + assert len(m.results) == 1 assert m.results[0]["summary"] == "Super interesting meeting" def test_calendar_detection(self, indicator_file): @@ -30,4 +43,4 @@ class TestCalendarModule: run_module(m) assert len(m.results) == 1 assert len(m.timeline) == 4 - assert len(m.detected) == 1 + assert len(m.alertstore.alerts) == 1 diff --git a/tests/ios_backup/test_datausage.py b/tests/ios_backup/test_datausage.py index 5e12727..17b0762 100644 --- a/tests/ios_backup/test_datausage.py +++ b/tests/ios_backup/test_datausage.py @@ -7,6 +7,7 @@ import logging from mvt.common.indicators import Indicators from mvt.common.module import run_module +from mvt.common.alerts import AlertLevel from mvt.ios.modules.mixed.net_datausage import Datausage from ..utils import get_ios_backup_folder @@ -19,7 +20,9 @@ class TestDatausageModule: assert m.results[0]["isodate"][0:19] == "2019-08-27 15:08:09" assert len(m.results) == 42 assert len(m.timeline) == 60 - assert len(m.detected) == 0 + assert ( + len(m.alertstore.alerts) == 1 + ) # We now have a detection for missing processes. def test_detection(self, indicator_file): m = Datausage(target_path=get_ios_backup_folder()) @@ -29,4 +32,19 @@ class TestDatausageModule: ind.ioc_collections[0]["processes"].append("CumulativeUsageTracker") m.indicators = ind run_module(m) - assert len(m.detected) == 2 + critical_alerts = [ + alert for alert in m.alertstore.alerts if alert.level == AlertLevel.CRITICAL + ] + assert len(critical_alerts) == 2 + assert all( + "matched_indicator" not in alert.event for alert in critical_alerts + ) + serialized_alerts = [ + alert + for alert in m.alertstore.as_json() + if alert["matched_indicator"] is not None + ] + assert len(serialized_alerts) == 2 + assert all( + "matched_indicator" not in alert["event"] for alert in serialized_alerts + ) diff --git a/tests/ios_backup/test_decrypt.py b/tests/ios_backup/test_decrypt.py new file mode 100644 index 0000000..28eeb83 --- /dev/null +++ b/tests/ios_backup/test_decrypt.py @@ -0,0 +1,125 @@ +# 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 + +from Crypto.Cipher import AES + +from mvt.ios.decrypt import DecryptBackup, MVTEncryptedBackup + + +def _encrypted_file(backup_path, file_id, key, plaintext): + padding_length = AES.block_size - (len(plaintext) % AES.block_size) + padded = plaintext + bytes([padding_length]) * padding_length + encrypted = AES.new(key, AES.MODE_CBC, iv=b"\x00" * AES.block_size).encrypt( + padded + ) + source_path = backup_path / file_id[:2] / file_id + source_path.parent.mkdir(parents=True) + source_path.write_bytes(encrypted) + + +def test_extract_file_by_id_preserves_bytes_with_wrong_manifest_size( + mocker, tmp_path +): + file_id = "ab" + "1" * 38 + plaintext = b"complete decrypted content" + inner_key = b"k" * 32 + _encrypted_file(tmp_path, file_id, inner_key, plaintext) + + file_plist = mocker.Mock( + encryption_key=b"wrapped-key", + protection_class=1, + filesize=1, + mtime=None, + ) + mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist) + + backup = MVTEncryptedBackup( + backup_directory=str(tmp_path), derived_key=b"d" * 32 + ) + mocker.patch.object(backup, "_read_and_unlock_keybag", return_value=True) + backup._keybag = mocker.Mock() + backup._keybag.unwrapKeyForClass.return_value = inner_key + streaming_decrypt = mocker.spy(backup, "_decrypt_file_to_disk") + output_path = tmp_path / "output" + + backup.extract_file_by_id( + file_id=file_id, + file_bplist=b"plist", + output_filename=str(output_path), + ) + + assert output_path.read_bytes() == plaintext + streaming_decrypt.assert_called_once() + + +def test_extract_file_by_id_copies_unencrypted_files(mocker, tmp_path): + file_id = "cd" + "2" * 38 + source_path = tmp_path / file_id[:2] / file_id + source_path.parent.mkdir(parents=True) + source_path.write_bytes(b"plain content") + + file_plist = mocker.Mock(encryption_key=None) + mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist) + backup = MVTEncryptedBackup( + backup_directory=str(tmp_path), derived_key=b"d" * 32 + ) + mocker.patch.object(backup, "_read_and_unlock_keybag", return_value=True) + output_path = tmp_path / "output" + + backup.extract_file_by_id( + file_id=file_id, + file_bplist=b"plist", + output_filename=str(output_path), + ) + + assert output_path.read_bytes() == b"plain content" + + +def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_path): + backup_path = tmp_path / "backup" + destination = tmp_path / "destination" + outside = tmp_path / "outside" + backup_path.mkdir() + destination.mkdir() + outside.mkdir() + + safe_file_id = "ef" + "3" * 38 + unsafe_file_id = "../../outside-file" + symlink_file_id = "ab" + "4" * 38 + for file_id in (safe_file_id, symlink_file_id): + source_path = backup_path / file_id[:2] / file_id + source_path.parent.mkdir(parents=True, exist_ok=True) + source_path.write_bytes(b"encrypted") + (destination / "ab").symlink_to(outside, target_is_directory=True) + + cursor = mocker.MagicMock() + cursor.__iter__.return_value = iter( + [ + (safe_file_id, "Domain", "safe", b"plist"), + (unsafe_file_id, "Domain", "unsafe", b"plist"), + (symlink_file_id, "Domain", "symlink", b"plist"), + ] + ) + cursor_context = mocker.MagicMock() + cursor_context.__enter__.return_value = cursor + + backup = mocker.MagicMock() + backup.manifest_db_cursor.return_value = cursor_context + + def extract_file_by_id(*, output_filename, **kwargs): + Path(output_filename).write_bytes(b"decrypted") + + backup.extract_file_by_id.side_effect = extract_file_by_id + decryptor = DecryptBackup(str(backup_path), str(destination)) + decryptor._backup = backup + + decryptor.process_backup() + + assert (destination / safe_file_id[:2] / safe_file_id).read_bytes() == b"decrypted" + assert not (outside / symlink_file_id).exists() + backup.extract_file_by_id.assert_called_once() + assert backup.extract_file_by_id.call_args.kwargs["file_id"] == safe_file_id diff --git a/tests/ios_backup/test_global_preferences.py b/tests/ios_backup/test_global_preferences.py index 705ca8d..7c2f766 100644 --- a/tests/ios_backup/test_global_preferences.py +++ b/tests/ios_backup/test_global_preferences.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ from mvt.common.module import run_module +from mvt.common.alerts import AlertLevel from mvt.ios.modules.mixed.global_preferences import GlobalPreferences from ..utils import get_ios_backup_folder @@ -15,6 +16,11 @@ class TestGlobalPreferencesModule: run_module(m) assert len(m.results) == 16 assert len(m.timeline) == 0 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 1 + + lockdown_mode_alert = m.alertstore.alerts[0] + assert lockdown_mode_alert.message == "Lockdown mode enabled" + assert lockdown_mode_alert.level == AlertLevel.INFORMATIONAL + assert m.results[0]["entry"] == "WebKitShowLinkPreviews" assert m.results[0]["value"] is False diff --git a/tests/ios_backup/test_manifest.py b/tests/ios_backup/test_manifest.py index 9b9882f..aaa747d 100644 --- a/tests/ios_backup/test_manifest.py +++ b/tests/ios_backup/test_manifest.py @@ -3,22 +3,43 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import gc import logging +import warnings from mvt.common.indicators import Indicators from mvt.common.module import run_module +from mvt.ios.modules.base import IOSExtraction from mvt.ios.modules.backup.manifest import Manifest from ..utils import get_ios_backup_folder +class TestIOSExtraction: + def test_get_backup_files_from_manifest_closes_connection(self): + m = IOSExtraction(target_path=get_ios_backup_folder()) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", ResourceWarning) + files = list(m._get_backup_files_from_manifest(domain="CameraRollDomain")) + gc.collect() + + assert files + assert not [ + warning + for warning in caught + if issubclass(warning.category, ResourceWarning) + and "unclosed database" in str(warning.message) + ] + + class TestManifestModule: def test_manifest(self): m = Manifest(target_path=get_ios_backup_folder()) run_module(m) assert len(m.results) == 3721 assert len(m.timeline) == 5881 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 def test_detection(self, indicator_file): m = Manifest(target_path=get_ios_backup_folder()) @@ -27,4 +48,4 @@ class TestManifestModule: ind.ioc_collections[0]["file_names"].append("com.apple.CoreBrightness.plist") m.indicators = ind run_module(m) - assert len(m.detected) == 1 + assert len(m.alertstore.alerts) == 1 diff --git a/tests/ios_backup/test_safari_browserstate.py b/tests/ios_backup/test_safari_browserstate.py index e877ded..fa2149d 100644 --- a/tests/ios_backup/test_safari_browserstate.py +++ b/tests/ios_backup/test_safari_browserstate.py @@ -4,12 +4,44 @@ # https://license.mvt.re/1.1/ import logging +import shutil + +import pytest from mvt.common.indicators import Indicators from mvt.common.module import run_module from mvt.ios.modules.mixed.safari_browserstate import SafariBrowserState -from ..utils import get_ios_backup_folder +from ..utils import add_backup_manifest_entry, get_ios_backup_folder + +# fileID of HomeDomain::Library/Safari/BrowserState.db in the test backup. +DEFAULT_BROWSER_STATE_FILE_ID = "3a47b0981ed7c10f3e2800aa66bac96a3b5db28e" +PROFILE_UUID = "00000000-0000-4000-A000-000000000001" +PROFILE_BROWSER_STATE_FILE_ID = "bb00000000000000000000000000000000000001" + + +@pytest.fixture +def backup_with_safari_profile(tmp_path): + """An iTunes backup where a Safari profile has its own browser state.""" + backup_path = tmp_path / "backup" + shutil.copytree(get_ios_backup_folder(), backup_path) + + profile_db = ( + backup_path / PROFILE_BROWSER_STATE_FILE_ID[:2] / PROFILE_BROWSER_STATE_FILE_ID + ) + profile_db.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile( + backup_path / DEFAULT_BROWSER_STATE_FILE_ID[:2] / DEFAULT_BROWSER_STATE_FILE_ID, + profile_db, + ) + add_backup_manifest_entry( + backup_path, + PROFILE_BROWSER_STATE_FILE_ID, + "AppDomain-com.apple.mobilesafari", + f"Library/Safari/Profiles/{PROFILE_UUID}/BrowserState.db", + ) + + return str(backup_path) class TestSafariBrowserStateModule: @@ -19,7 +51,16 @@ class TestSafariBrowserStateModule: run_module(m) assert len(m.results) == 1 assert len(m.timeline) == 1 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 + + def test_parsing_backup_with_profile(self, backup_with_safari_profile): + m = SafariBrowserState(target_path=backup_with_safari_profile) + m.is_backup = True + run_module(m) + + # Both the default profile and the named profile are extracted. + assert len(m.results) == 2 + assert len({result["safari_browser_state_db"] for result in m.results}) == 2 def test_detection(self, indicator_file): m = SafariBrowserState(target_path=get_ios_backup_folder()) @@ -30,6 +71,6 @@ class TestSafariBrowserStateModule: ind.ioc_collections[0]["domains"].append("en.wikipedia.org") m.indicators = ind run_module(m) - assert len(m.detected) == 1 + assert len(m.alertstore.alerts) == 1 assert len(m.results) == 1 assert m.results[0]["tab_url"] == "https://en.wikipedia.org/wiki/NSO_Group" diff --git a/tests/ios_backup/test_safari_history.py b/tests/ios_backup/test_safari_history.py new file mode 100644 index 0000000..258b352 --- /dev/null +++ b/tests/ios_backup/test_safari_history.py @@ -0,0 +1,165 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import logging +import shutil +import sqlite3 +from pathlib import Path + +import pytest + +from mvt.common.indicators import Indicators +from mvt.common.module import run_module +from mvt.ios.modules.mixed.safari_history import SafariHistory + +from ..utils import add_backup_manifest_entry, get_ios_backup_folder + +# fileID of HomeDomain::Library/Safari/History.db in the test backup. +DEFAULT_HISTORY_FILE_ID = "1a0e7afc19d307da602ccdcece51af33afe92c53" +PROFILE_UUID = "00000000-0000-4000-A000-000000000001" +PROFILE_HISTORY_FILE_ID = "aa00000000000000000000000000000000000001" + +# example.org is already a test indicator, so use domains that do not match. +DEFAULT_URL = "https://default.example.net/visited-page" +PROFILE_URL = "https://profile.example.net/visited-page" + + +def create_history_db(path, url): + """Create a minimal Safari History.db holding a single visit.""" + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE history_items (id INTEGER PRIMARY KEY, url TEXT); + CREATE TABLE history_visits ( + id INTEGER PRIMARY KEY, + history_item INTEGER, + visit_time REAL, + redirect_source INTEGER, + redirect_destination INTEGER + ); + """ + ) + conn.execute("INSERT INTO history_items VALUES (1, ?);", (url,)) + conn.execute("INSERT INTO history_visits VALUES (1, 1, 726100000.0, NULL, NULL);") + conn.commit() + conn.close() + + +@pytest.fixture +def backup_with_safari_profile(tmp_path): + """An iTunes backup where Safari has both a default and a named profile.""" + backup_path = tmp_path / "backup" + shutil.copytree(get_ios_backup_folder(), backup_path) + + # The default profile's database ships empty, so give it a visit to make + # sure the profile lookup does not replace the pre-existing one. + create_history_db( + backup_path / DEFAULT_HISTORY_FILE_ID[:2] / DEFAULT_HISTORY_FILE_ID, + DEFAULT_URL, + ) + + create_history_db( + backup_path / PROFILE_HISTORY_FILE_ID[:2] / PROFILE_HISTORY_FILE_ID, + PROFILE_URL, + ) + add_backup_manifest_entry( + backup_path, + PROFILE_HISTORY_FILE_ID, + "AppDomain-com.apple.mobilesafari", + f"Library/Safari/Profiles/{PROFILE_UUID}/History.db", + ) + + return str(backup_path) + + +@pytest.fixture +def fs_dump_with_safari_profile(tmp_path): + """A filesystem dump where Safari has both a default and a named profile.""" + safari_path = tmp_path / "private" / "var" / "mobile" / "Library" / "Safari" + profile_path = safari_path / "Profiles" / PROFILE_UUID + profile_path.mkdir(parents=True) + + create_history_db(safari_path / "History.db", DEFAULT_URL) + create_history_db(profile_path / "History.db", PROFILE_URL) + + return str(tmp_path) + + +class TestSafariHistoryModule: + def test_parsing(self): + m = SafariHistory(target_path=get_ios_backup_folder()) + m.is_backup = True + run_module(m) + assert len(m.results) == 0 + assert len(m.alertstore.alerts) == 0 + + def test_parsing_backup_with_profile(self, backup_with_safari_profile): + m = SafariHistory(target_path=backup_with_safari_profile) + m.is_backup = True + run_module(m) + + # Both the default profile and the named profile are extracted. + assert len(m.results) == 2 + assert {result["url"] for result in m.results} == {DEFAULT_URL, PROFILE_URL} + assert len({result["safari_history_db"] for result in m.results}) == 2 + + def test_parsing_fs_dump_with_profile(self, fs_dump_with_safari_profile): + m = SafariHistory(target_path=fs_dump_with_safari_profile) + m.is_fs_dump = True + run_module(m) + + assert len(m.results) == 2 + assert {result["url"] for result in m.results} == {DEFAULT_URL, PROFILE_URL} + + def test_redirect_ids_are_scoped_to_database(self, fs_dump_with_safari_profile): + safari_path = ( + Path(fs_dump_with_safari_profile) + / "private" + / "var" + / "mobile" + / "Library" + / "Safari" + ) + with sqlite3.connect(safari_path / "History.db") as conn: + conn.execute( + "UPDATE history_items SET url = ? WHERE id = 1;", + ("http://safe.example.com/start",), + ) + conn.execute( + "INSERT INTO history_items VALUES (2, ?);", + ("https://safe.example.com/end",), + ) + conn.execute( + "UPDATE history_visits SET redirect_destination = 2 WHERE id = 1;" + ) + conn.execute( + "INSERT INTO history_visits VALUES (2, 2, 726100000.1, 1, NULL);" + ) + + profile_db = safari_path / "Profiles" / PROFILE_UUID / "History.db" + with sqlite3.connect(profile_db) as conn: + # Visit IDs are local to each database and commonly overlap. + conn.execute("UPDATE history_visits SET id = 2 WHERE id = 1;") + + m = SafariHistory(target_path=fs_dump_with_safari_profile) + m.is_fs_dump = True + run_module(m) + + assert len(m.results) == 3 + assert len(m.alertstore.alerts) == 0 + + def test_detection_in_profile(self, backup_with_safari_profile, indicator_file): + """An indicator only visited inside a Safari profile still alerts.""" + m = SafariHistory(target_path=backup_with_safari_profile) + m.is_backup = True + ind = Indicators(log=logging.getLogger()) + ind.parse_stix2(indicator_file) + ind.ioc_collections[0]["domains"].append("profile.example.net") + m.indicators = ind + run_module(m) + + assert len(m.alertstore.alerts) == 1 + assert m.alertstore.alerts[0].event["url"] == PROFILE_URL diff --git a/tests/ios_backup/test_sms.py b/tests/ios_backup/test_sms.py index 243f630..64646f4 100644 --- a/tests/ios_backup/test_sms.py +++ b/tests/ios_backup/test_sms.py @@ -18,7 +18,15 @@ class TestSMSModule: run_module(m) assert len(m.results) == 1 assert len(m.timeline) == 2 - assert len(m.detected) == 0 + assert m.url_results == [ + { + "url": "https://badbadbad.example.org/", + "expanded_url": None, + "timestamp": "2019-08-29 23:13:30.000000", + "source": "sms", + } + ] + assert len(m.alertstore.alerts) == 0 def test_detection(self, indicator_file): m = SMS(target_path=get_ios_backup_folder()) @@ -28,4 +36,32 @@ class TestSMSModule: ind.ioc_collections[0]["domains"].append("badbadbad.example.org") m.indicators = ind run_module(m) - assert len(m.detected) == 1 + assert len(m.alertstore.alerts) == 1 + + def test_detection_batches_urls_and_preserves_event(self, indicator_file, mocker): + results = [ + { + "text": "first", + "links": ["http://example.com/thisisbad"], + }, + { + "text": "second", + "links": ["https://github.com"], + }, + ] + m = SMS(results=results) + ind = Indicators(log=logging.getLogger()) + ind.parse_stix2(indicator_file) + batch_check = mocker.spy(ind, "check_url_batches") + m.indicators = ind + + m.check_indicators() + + batch_check.assert_called_once_with( + [ + ["http://example.com/thisisbad"], + ["https://github.com"], + ] + ) + assert len(m.alertstore.alerts) == 1 + assert m.alertstore.alerts[0].event is results[0] diff --git a/tests/ios_backup/test_sqlite_handling.py b/tests/ios_backup/test_sqlite_handling.py new file mode 100644 index 0000000..f780640 --- /dev/null +++ b/tests/ios_backup/test_sqlite_handling.py @@ -0,0 +1,85 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import hashlib +import os +import plistlib +import shutil +import sqlite3 + +from mvt.ios.modules.base import IOSExtraction +from mvt.ios.modules.fs.analytics import Analytics + + +def _sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_open_sqlite_reads_wal_without_modifying_evidence(tmp_path): + live_path = tmp_path / "live.db" + evidence_path = tmp_path / "evidence.db" + conn = sqlite3.connect(live_path) + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA wal_autocheckpoint=0;") + conn.execute("CREATE TABLE records (value TEXT);") + conn.commit() + conn.execute("INSERT INTO records VALUES ('from wal');") + conn.commit() + + shutil.copy2(live_path, evidence_path) + shutil.copy2(str(live_path) + "-wal", str(evidence_path) + "-wal") + conn.close() + + evidence_hash = _sha256(evidence_path) + wal_hash = _sha256(tmp_path / "evidence.db-wal") + module = IOSExtraction(file_path=str(evidence_path)) + read_conn = module._open_sqlite_db(str(evidence_path)) + rows = read_conn.execute("SELECT value FROM records;").fetchall() + read_conn.close() + + assert rows == [("from wal",)] + assert _sha256(evidence_path) == evidence_hash + assert _sha256(tmp_path / "evidence.db-wal") == wal_hash + assert not os.path.exists(str(evidence_path) + "-shm") + + +def test_recovery_preserves_source_database(tmp_path): + database_path = tmp_path / "source.db" + conn = sqlite3.connect(database_path) + conn.execute("CREATE TABLE records (value TEXT);") + conn.execute("INSERT INTO records VALUES ('preserved');") + conn.commit() + conn.close() + source_hash = _sha256(database_path) + + module = IOSExtraction(file_path=str(database_path)) + module._recover_sqlite_db_if_needed(str(database_path), forced=True) + recovered_conn = module._open_sqlite_db(str(database_path)) + rows = recovered_conn.execute("SELECT value FROM records;").fetchall() + recovered_conn.close() + + assert rows == [("preserved",)] + assert _sha256(database_path) == source_hash + assert not os.path.exists(str(database_path) + ".bak") + + +def test_analytics_skips_empty_rows_and_continues(tmp_path): + database_path = tmp_path / "analytics.db" + conn = sqlite3.connect(database_path) + for table in ("hard_failures", "soft_failures", "all_events"): + conn.execute(f"CREATE TABLE {table} (timestamp REAL, data BLOB);") + conn.execute("INSERT INTO hard_failures VALUES (NULL, NULL);") + conn.execute( + "INSERT INTO soft_failures VALUES (?, ?);", + (1.0, plistlib.dumps({"event": "valid"})), + ) + conn.commit() + conn.close() + + module = Analytics(file_path=str(database_path)) + module._extract_analytics_data() + + assert len(module.results) == 1 + assert module.results[0]["event"] == "valid" diff --git a/tests/ios_backup/test_tcc.py b/tests/ios_backup/test_tcc.py index 23a1ab7..d1bf073 100644 --- a/tests/ios_backup/test_tcc.py +++ b/tests/ios_backup/test_tcc.py @@ -18,7 +18,7 @@ class TestTCCModule: run_module(m) assert len(m.results) == 11 assert len(m.timeline) == 11 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 assert m.results[0]["service"] == "kTCCServiceUbiquity" assert m.results[0]["client"] == "com.apple.Preferences" assert m.results[0]["auth_value"] == "allowed" @@ -31,6 +31,6 @@ class TestTCCModule: run_module(m) assert len(m.results) == 11 assert len(m.timeline) == 11 - assert len(m.detected) == 1 - assert m.detected[0]["service"] == "kTCCServiceLiverpool" - assert m.detected[0]["client"] == "Launch" + assert len(m.alertstore.alerts) == 1 + assert m.alertstore.alerts[0].event["service"] == "kTCCServiceLiverpool" + assert m.alertstore.alerts[0].event["client"] == "Launch" diff --git a/tests/ios_backup/test_webkit_resource_load_statistics.py b/tests/ios_backup/test_webkit_resource_load_statistics.py index 0e59ebb..5e30cfe 100644 --- a/tests/ios_backup/test_webkit_resource_load_statistics.py +++ b/tests/ios_backup/test_webkit_resource_load_statistics.py @@ -3,6 +3,8 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import sqlite3 + from mvt.common.module import run_module from mvt.ios.modules.mixed.webkit_resource_load_statistics import ( WebkitResourceLoadStatistics, @@ -18,4 +20,51 @@ class TestWebkitResourceLoadStatisticsModule: run_module(m) assert len(m.results) == 2 assert len(m.timeline) == 2 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 + + results = {result["registrable_domain"]: result for result in m.results} + assert results["google.com"]["most_recent_user_interaction_time"] > 0 + assert "most_recent_user_interaction_time_isodate" in results["google.com"] + assert results["gstatic.com"]["most_recent_user_interaction_time"] == -1.0 + assert ( + "most_recent_user_interaction_time_isodate" + not in results["gstatic.com"] + ) + assert all( + "most_recent_web_push_interaction_time" not in result + for result in m.results + ) + + def test_webkit_full_timestamp_schema(self, tmp_path): + db_path = tmp_path / "observations.db" + conn = sqlite3.connect(db_path) + conn.execute( + """ + CREATE TABLE ObservedDomains ( + domainID INTEGER PRIMARY KEY, + registrableDomain TEXT NOT NULL, + lastSeen REAL NOT NULL, + hadUserInteraction INTEGER NOT NULL, + mostRecentUserInteractionTime REAL NOT NULL, + mostRecentWebPushInteractionTime REAL NOT NULL + ); + """ + ) + conn.execute( + """ + INSERT INTO ObservedDomains VALUES (?, ?, ?, ?, ?, ?); + """, + (1, "example.com", 1634560250.0, 1, 1634560030.0, -1.0), + ) + conn.commit() + conn.close() + + m = WebkitResourceLoadStatistics(target_path=str(tmp_path)) + m._process_observations_db(str(db_path), "", "observations.db") + + assert len(m.results) == 1 + result = m.results[0] + assert result["most_recent_user_interaction_time"] == 1634560030.0 + assert "most_recent_user_interaction_time_isodate" in result + assert result["most_recent_web_push_interaction_time"] == -1.0 + assert "most_recent_web_push_interaction_time_isodate" not in result diff --git a/tests/ios_backup/test_whatsapp.py b/tests/ios_backup/test_whatsapp.py new file mode 100644 index 0000000..864fb84 --- /dev/null +++ b/tests/ios_backup/test_whatsapp.py @@ -0,0 +1,35 @@ +# 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 logging + +from mvt.common.indicators import Indicators +from mvt.ios.modules.mixed.whatsapp import Whatsapp + + +def test_collect_url_results_includes_expansion(): + module = Whatsapp( + results=[ + { + "links": ["https://bit.ly/message"], + "isodate": "2026-07-29 12:00:00.000000", + } + ] + ) + module.indicators = Indicators(log=logging.getLogger()) + module.indicators.resolved_urls["https://bit.ly/message"] = ( + "https://example.org/landing" + ) + + module.collect_url_results() + + assert module.url_results == [ + { + "url": "https://bit.ly/message", + "expanded_url": "https://example.org/landing", + "timestamp": "2026-07-29 12:00:00.000000", + "source": "whatsapp", + } + ] diff --git a/tests/ios_fs/test_cache_files.py b/tests/ios_fs/test_cache_files.py new file mode 100644 index 0000000..08af2d9 --- /dev/null +++ b/tests/ios_fs/test_cache_files.py @@ -0,0 +1,37 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ +import logging + +from mvt.common.indicators import Indicators +from mvt.ios.modules.fs.cache_files import CacheFiles + + +class TestCacheFiles: + def test_detection(self, indicator_file): + m = CacheFiles( + results={ + "Library/Caches/example/Cache.db": [ + { + "entry_id": 1, + "version": 1, + "hash_value": 123, + "storage_policy": 0, + "url": "http://example.com/thisisbad", + "isodate": "2026-01-01 00:00:00.000000", + } + ] + } + ) + ind = Indicators(log=logging.getLogger()) + ind.parse_stix2(indicator_file) + m.indicators = ind + + m.check_indicators() + + assert len(m.alertstore.alerts) == 1 + alert = m.alertstore.alerts[0] + assert alert.event["cache_file"] == "Library/Caches/example/Cache.db" + assert alert.event["url"] == "http://example.com/thisisbad" + assert alert.matched_indicator is not None diff --git a/tests/ios_fs/test_filesystem.py b/tests/ios_fs/test_filesystem.py index 062713f..9fa664f 100644 --- a/tests/ios_fs/test_filesystem.py +++ b/tests/ios_fs/test_filesystem.py @@ -17,7 +17,7 @@ class TestFilesystem: run_module(m) assert len(m.results) == 15 assert len(m.timeline) == 15 - assert len(m.detected) == 0 + assert len(m.alertstore.alerts) == 0 def test_detection(self, indicator_file): m = Filesystem(target_path=get_ios_backup_folder()) @@ -31,4 +31,4 @@ class TestFilesystem: run_module(m) assert len(m.results) == 15 assert len(m.timeline) == 15 - assert len(m.detected) == 1 + assert len(m.alertstore.alerts) == 1 diff --git a/tests/ios_fs/test_shutdownlog.py b/tests/ios_fs/test_shutdownlog.py new file mode 100644 index 0000000..e3a5016 --- /dev/null +++ b/tests/ios_fs/test_shutdownlog.py @@ -0,0 +1,59 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +from mvt.common.module import run_module +from mvt.ios.modules.fs.shutdownlog import ShutdownLog + + +def _shutdown_log_entry(pid: int, client: str, timestamp: int) -> str: + return ( + f"remaining client pid: {pid} ({client})\n" + f"SIGTERM: [{timestamp}]\n" + ) + + +class TestShutdownLog: + def test_discovers_rotated_shutdown_logs(self, tmp_path): + diagnostics_path = tmp_path / "private/var/db/diagnostics" + diagnostics_path.mkdir(parents=True) + (diagnostics_path / "shutdown.log").write_text( + _shutdown_log_entry(100, "/usr/libexec/first", 1_700_000_000), + encoding="utf-8", + ) + (diagnostics_path / "shutdown.0.log").write_text( + _shutdown_log_entry(200, "/usr/libexec/second", 1_700_000_001), + encoding="utf-8", + ) + + module = ShutdownLog(target_path=str(tmp_path)) + run_module(module) + + assert {result["client"] for result in module.results} == { + "/usr/libexec/first", + "/usr/libexec/second", + } + + def test_file_path_indicator_matches_client_with_trailing_uuid( + self, indicators_factory + ): + executable_path = "/usr/sbin/filecoordinationd" + client = f"{executable_path}/123e4567-e89b-12d3-a456-426614174000" + module = ShutdownLog( + results=[ + { + "isodate": "2023-11-14 22:13:20.000000", + "pid": "100", + "client": client, + "delay": 0.0, + "times_delayed": 0, + } + ] + ) + module.indicators = indicators_factory(file_paths=[executable_path]) + + module.check_indicators() + + assert len(module.alertstore.alerts) == 1 + assert module.alertstore.alerts[0].matched_indicator.value == executable_path diff --git a/tests/test_check_android_adb_removed.py b/tests/test_check_android_adb_removed.py new file mode 100644 index 0000000..51984e6 --- /dev/null +++ b/tests/test_check_android_adb_removed.py @@ -0,0 +1,16 @@ +# 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 check_adb + + +class TestCheckAndroidADBRemovedCommand: + def test_check_adb_exits_nonzero(self): + runner = CliRunner() + result = runner.invoke(check_adb) + + assert result.exit_code == 1 diff --git a/tests/test_check_android_androidqf.py b/tests/test_check_android_androidqf.py index c6e4221..2253a50 100644 --- a/tests/test_check_android_androidqf.py +++ b/tests/test_check_android_androidqf.py @@ -3,11 +3,19 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import json +import logging import os +import shutil +import tempfile +import zipfile from click.testing import CliRunner from mvt.android.cli import check_androidqf +from mvt.android.cmd_check_androidqf import CmdAndroidCheckAndroidQF +from mvt.android.modules.androidqf import ANDROIDQF_MODULES +from mvt.android.modules.androidqf.aqf_log_timestamps import AQFLogTimestamps from mvt.common.config import settings from .utils import get_artifact_folder @@ -16,30 +24,97 @@ TEST_BACKUP_PASSWORD = "123456" class TestCheckAndroidqfCommand: + def test_log_timestamps_module_is_registered(self): + assert AQFLogTimestamps in ANDROIDQF_MODULES + def test_check(self): runner = CliRunner() path = os.path.join(get_artifact_folder(), "androidqf") result = runner.invoke(check_androidqf, [path]) assert result.exit_code == 0 + def test_check_stores_nested_sms_urls(self, tmp_path): + runner = CliRunner() + path = os.path.join(get_artifact_folder(), "androidqf") + + result = runner.invoke(check_androidqf, ["--output", str(tmp_path), path]) + + assert result.exit_code == 0 + urls = json.loads((tmp_path / "urls.json").read_text()) + assert {entry["url"] for entry in urls} == { + "http://google.com", + "https://google.com/", + } + assert all( + set(entry) == {"url", "expanded_url", "timestamp", "source"} + for entry in urls + ) + assert all(entry["source"] == "sms" for entry in urls) + + def test_acquisition_context_is_passed_to_bugreport(self, tmp_path, mocker): + data_path = tmp_path / "androidqf" + data_path.mkdir() + (data_path / "acquisition.json").write_text( + json.dumps( + { + "started": "2025-06-20T18:00:00Z", + "adb_host_public_key": "QUJDRA== acquisition@host", + } + ) + ) + with zipfile.ZipFile(data_path / "bugreport.zip", "w"): + pass + + nested_command = mocker.patch( + "mvt.android.cmd_check_androidqf.CmdAndroidCheckBugreport" + ) + nested_command.return_value.timeline = [] + nested_command.return_value.alertstore.alerts = [] + command = CmdAndroidCheckAndroidQF(target_path=str(data_path)) + command.init() + + assert command.run_bugreport_cmd() is True + + assert nested_command.call_args.kwargs["module_options"][ + "androidqf_acquisition" + ] == { + "started": "2025-06-20T18:00:00Z", + "adb_host_public_key": "QUJDRA== acquisition@host", + } + + def test_acquisition_context_falls_back_to_public_key_file(self, tmp_path): + data_path = tmp_path / "androidqf" + data_path.mkdir() + (data_path / "adb_host_key.pub").write_text("QUJDRA== acquisition@host\n") + command = CmdAndroidCheckAndroidQF(target_path=str(data_path)) + + command.init() + + assert command.module_options["androidqf_acquisition"] == { + "adb_host_public_key": "QUJDRA== acquisition@host\n" + } + def test_check_encrypted_backup_prompt_valid(self, mocker): """Prompt for password on CLI""" prompt_mock = mocker.patch( - "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD + "mvt.android.modules.backup.helpers.prompt_password", + return_value=TEST_BACKUP_PASSWORD, ) runner = CliRunner() path = os.path.join(get_artifact_folder(), "androidqf_encrypted") result = runner.invoke(check_androidqf, [path]) - # Called twice, once in AnroidQF SMS module and once in Backup SMS module - assert prompt_mock.call_count == 2 + # The password entered for the AndroidQF SMS module is reused by the + # nested backup command. + assert prompt_mock.call_count == 1 assert result.exit_code == 0 def test_check_encrypted_backup_cli(self, mocker): """Provide password as CLI argument""" prompt_mock = mocker.patch( - "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD + "mvt.android.modules.backup.helpers.prompt_password", + return_value=TEST_BACKUP_PASSWORD, ) runner = CliRunner() @@ -54,7 +129,8 @@ class TestCheckAndroidqfCommand: def test_check_encrypted_backup_env(self, mocker): """Provide password as environment variable""" prompt_mock = mocker.patch( - "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD + "mvt.android.modules.backup.helpers.prompt_password", + return_value=TEST_BACKUP_PASSWORD, ) os.environ["MVT_ANDROID_BACKUP_PASSWORD"] = TEST_BACKUP_PASSWORD @@ -68,3 +144,40 @@ 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 + ) + + def test_intrusion_log_zip_rejects_path_traversal(self, tmp_path, mocker, caplog): + escaped_name = f"mvt-escaped-{tmp_path.name}.txt" + escaped_path = os.path.join(tempfile.gettempdir(), escaped_name) + archive_path = tmp_path / "androidqf.zip" + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr(f"intrusion_logs/../{escaped_name}", "unsafe") + archive.writestr("intrusion_logs/safe.txt", "safe") + + nested_command = mocker.patch( + "mvt.android.cmd_check_androidqf.CmdAndroidCheckIntrusionLogs" + ) + nested_command.return_value.timeline = [] + nested_command.return_value.alertstore.alerts = [] + command = CmdAndroidCheckAndroidQF(target_path=str(archive_path)) + command.init() + + with caplog.at_level(logging.WARNING): + assert command.run_intrusion_logs_cmd() is True + + assert not os.path.exists(escaped_path) + assert "Skipping unsafe intrusion log archive entry" in caplog.text diff --git a/tests/test_check_android_backup.py b/tests/test_check_android_backup.py index 71c0586..ac0680f 100644 --- a/tests/test_check_android_backup.py +++ b/tests/test_check_android_backup.py @@ -20,7 +20,8 @@ class TestCheckAndroidBackupCommand: def test_check_encrypted_backup_prompt_valid(self, mocker): """Prompt for password on CLI""" prompt_mock = mocker.patch( - "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD + "mvt.android.modules.backup.helpers.prompt_password", + return_value=TEST_BACKUP_PASSWORD, ) runner = CliRunner() path = os.path.join(get_artifact_folder(), "androidqf_encrypted/backup.ab") @@ -32,7 +33,8 @@ class TestCheckAndroidBackupCommand: def test_check_encrypted_backup_cli(self, mocker): """Provide password as CLI argument""" prompt_mock = mocker.patch( - "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD + "mvt.android.modules.backup.helpers.prompt_password", + return_value=TEST_BACKUP_PASSWORD, ) runner = CliRunner() @@ -60,7 +62,8 @@ class TestCheckAndroidBackupCommand: def test_check_encrypted_backup_env(self, mocker): """Provide password as environment variable""" prompt_mock = mocker.patch( - "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD + "mvt.android.modules.backup.helpers.prompt_password", + return_value=TEST_BACKUP_PASSWORD, ) os.environ["MVT_ANDROID_BACKUP_PASSWORD"] = TEST_BACKUP_PASSWORD diff --git a/tests/test_check_android_bugreport.py b/tests/test_check_android_bugreport.py index b13c38d..bff4709 100644 --- a/tests/test_check_android_bugreport.py +++ b/tests/test_check_android_bugreport.py @@ -18,3 +18,13 @@ class TestCheckBugreportCommand: path = os.path.join(get_artifact_folder(), "android_data/bugreport/") result = runner.invoke(check_bugreport, [path]) assert result.exit_code == 0 + + def test_invalid_zip_reports_clean_error(self, tmp_path): + path = tmp_path / "invalid.zip" + path.write_bytes(b"not a zip archive") + + result = CliRunner().invoke(check_bugreport, [str(path)]) + + assert result.exit_code == 1 + assert "Invalid bugreport archive" in result.output + assert "Traceback" not in result.output diff --git a/tests/test_check_ios_backup.py b/tests/test_check_ios_backup.py index a8ad6b2..eb10b13 100644 --- a/tests/test_check_ios_backup.py +++ b/tests/test_check_ios_backup.py @@ -3,6 +3,8 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import shutil + from click.testing import CliRunner from mvt.ios.cli import check_backup @@ -16,3 +18,18 @@ class TestCheckBackupCommand: path = get_ios_backup_folder() result = runner.invoke(check_backup, [path]) assert result.exit_code == 0 + + def test_check_finds_backup_in_subfolder(self, tmp_path, caplog): + runner = CliRunner() + backup_path = tmp_path / "MobileSync" / "Backup" / "device-id" + shutil.copytree(get_ios_backup_folder(), backup_path) + + result = runner.invoke(check_backup, [str(backup_path.parent)]) + assert result.exit_code == 0 + assert f"Found iTunes backup in subfolder: {backup_path}" in caplog.text + + def test_check_rejects_non_backup_folder(self, tmp_path, caplog): + runner = CliRunner() + result = runner.invoke(check_backup, [str(tmp_path)]) + assert result.exit_code == 1 + assert "does not appear to be an iTunes backup folder" in caplog.text diff --git a/tests/test_check_ios_sysdiagnose.py b/tests/test_check_ios_sysdiagnose.py new file mode 100644 index 0000000..d73c0ff --- /dev/null +++ b/tests/test_check_ios_sysdiagnose.py @@ -0,0 +1,57 @@ +from click.testing import CliRunner + +from mvt.ios.cli import check_sysdiagnose + + +CUSTOM_MODULE = """ +from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction + + +class CustomSysdiagnoseModule(SysdiagnoseExtraction): + supported_commands = (("ios", "check-sysdiagnose"),) + slug = "custom_sysdiagnose_module" + + def run(self): + file_path = self._get_files_by_pattern("*/artifact.txt")[0] + self.results = [{"content": self._get_file_content(file_path).decode("utf-8")}] + + def check_indicators(self): + pass + + def serialize(self, result): + return None +""" + + +def _create_sysdiagnose_folder(tmp_path): + folder = tmp_path / "sysdiagnose" + folder.mkdir() + (folder / "artifact.txt").write_text("artifact", encoding="utf-8") + return folder + + +def test_check_sysdiagnose_runs_explicitly_scoped_custom_module(tmp_path): + module_path = tmp_path / "custom_sysdiagnose.py" + module_path.write_text(CUSTOM_MODULE, encoding="utf-8") + output_path = tmp_path / "output" + + result = CliRunner().invoke( + check_sysdiagnose, + [ + "--load-module", + str(module_path), + "--output", + str(output_path), + str(_create_sysdiagnose_folder(tmp_path)), + ], + ) + + assert result.exit_code == 0 + assert (output_path / "custom_sysdiagnose_module.json").exists() + + +def test_check_sysdiagnose_requires_an_explicitly_scoped_module(tmp_path): + result = CliRunner().invoke(check_sysdiagnose, [str(_create_sysdiagnose_folder(tmp_path))]) + + assert result.exit_code != 0 + assert "No custom modules support mvt-ios check-sysdiagnose" in result.output diff --git a/tests/test_cmd_check_sysdiagnose.py b/tests/test_cmd_check_sysdiagnose.py new file mode 100644 index 0000000..67f126b --- /dev/null +++ b/tests/test_cmd_check_sysdiagnose.py @@ -0,0 +1,117 @@ +import io +import tarfile +from datetime import timedelta +from pathlib import Path + +from mvt.ios.cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose +from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction + + +class SysdiagnoseTestModule(SysdiagnoseExtraction): + supported_commands = (("ios", "check-sysdiagnose"),) + + def run(self): + file_path = self._get_files_by_pattern("*/artifact.txt")[0] + self.results = [ + { + "content": self._get_file_content(file_path).decode("utf-8"), + "timezone_offset": self._extract_timezone().utcoffset(None).seconds, + } + ] + + def check_indicators(self): + pass + + def serialize(self, result): + return None + + +def _create_sysdiagnose_folder(tmp_path): + folder = tmp_path / "sysdiagnose" + folder.mkdir() + (folder / "artifact.txt").write_text("artifact", encoding="utf-8") + (folder / "sysdiagnose.log").write_text( + "sysdiagnose_2024.01.02_03-04-05+0200.tar.gz", encoding="utf-8" + ) + (folder / "report.ips").write_text('{"bug_type": 210}\nbody', encoding="utf-8") + return folder + + +def _create_sysdiagnose_archive(tmp_path, folder): + archive_path = tmp_path / "sysdiagnose.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + for path in folder.iterdir(): + archive.add(path, arcname=f"sysdiagnose/{path.name}") + return archive_path + + +def _run_command(path): + command = CmdIOSCheckSysdiagnose( + target_path=str(path), custom_modules=[SysdiagnoseTestModule] + ) + command.run() + return command + + +def test_check_sysdiagnose_from_folder(tmp_path): + command = _run_command(_create_sysdiagnose_folder(tmp_path)) + + assert command.executed[0].results == [ + {"content": "artifact", "timezone_offset": timedelta(hours=2).seconds} + ] + assert command.executed[0].ips_files == [ + {"file_path": str(tmp_path / "sysdiagnose" / "report.ips"), "bug_type": 210} + ] + + +def test_check_sysdiagnose_from_archive_closes_archive(tmp_path): + folder = _create_sysdiagnose_folder(tmp_path) + command = _run_command(_create_sysdiagnose_archive(tmp_path, folder)) + + assert command.executed[0].results == [ + {"content": "artifact", "timezone_offset": timedelta(hours=2).seconds} + ] + assert command.executed[0].ips_files == [ + { + "file_path": str( + Path(command.extracted_sysdiagnose_path) / "report.ips" + ), + "bug_type": 210, + } + ] + assert command.sysdiagnose_archive is None + + +def test_archive_is_extracted_once_and_unsafe_members_are_skipped(tmp_path): + archive_path = tmp_path / "sysdiagnose.tar.gz" + escaped_path = tmp_path / "escaped.txt" + content = b"test content" + member = tarfile.TarInfo("sysdiagnose/artifact.txt") + member.size = len(content) + + with tarfile.open(archive_path, "w:gz") as archive: + archive.addfile(member, io.BytesIO(content)) + escaped = tarfile.TarInfo(f"sysdiagnose/../../{escaped_path.name}") + escaped.size = len(content) + archive.addfile(escaped, io.BytesIO(content)) + link = tarfile.TarInfo("sysdiagnose/link") + link.type = tarfile.SYMTYPE + link.linkname = "/etc/hostname" + archive.addfile(link) + + command = CmdIOSCheckSysdiagnose(target_path=str(archive_path)) + try: + command.init() + extracted_path = Path(command.extracted_sysdiagnose_path) + assert (extracted_path / "artifact.txt").read_bytes() == content + assert not escaped_path.exists() + assert not (extracted_path / "link").exists() + + module = SysdiagnoseExtraction() + command.module_init(module) + assert module.tar is None + assert module.parent_path == str(extracted_path.parent) + finally: + command.finish() + + assert not extracted_path.exists() diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 0000000..48c0177 --- /dev/null +++ b/tests/test_completion.py @@ -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() diff --git a/tests/test_custom_modules.py b/tests/test_custom_modules.py new file mode 100644 index 0000000..a00faea --- /dev/null +++ b/tests/test_custom_modules.py @@ -0,0 +1,195 @@ +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): + (tmp_path / "Manifest.db").touch() + (tmp_path / "Info.plist").touch() + 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"], + } diff --git a/tests/utils.py b/tests/utils.py index b29e069..d84dc2f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ import os +import sqlite3 from pathlib import Path @@ -37,6 +38,22 @@ def get_indicator_file(): print("PYTEST env", os.getenv("PYTEST_CURRENT_TEST")) +def add_backup_manifest_entry(backup_path, file_id, domain, relative_path): + """ + Register an extra file in a test backup's Manifest.db + """ + conn = sqlite3.connect(os.path.join(backup_path, "Manifest.db")) + conn.execute( + "INSERT INTO Files (fileID, domain, relativePath, flags, file) " + "VALUES (?, ?, ?, 1, ?);", + (file_id, domain, relative_path, b""), + ) + conn.commit() + # Checkpoint the test change so the fixture does not retain SQLite sidecars. + conn.execute("PRAGMA journal_mode=DELETE;") + conn.close() + + def delete_tmp_db_files(file_path): """ Remove Sqlite temporary files that appear on some platforms diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a1d370e --- /dev/null +++ b/uv.lock @@ -0,0 +1,2006 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "adb-shell" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/d246034db6f3e374dad9a35ee3f61345a6b239d4febd2a41ab69df9936fe/adb_shell-0.4.4.tar.gz", hash = "sha256:04c305f30a2ca25d5c54b3cd6ce9bb64c36e5f07967b23b3fb6aaecc851b90b6", size = 61822, upload-time = "2023-09-01T03:48:40.348Z" } + +[package.optional-dependencies] +usb = [ + { name = "libusb1" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/5f/2cdf6f7aca3b20d3f316e9f505292e1f256a32089bd702034c29ebde6242/antlr4_python3_runtime-4.13.2.tar.gz", hash = "sha256:909b647e1d2fc2b70180ac586df3933e38919c85f98ccc656a96cd3f25ef3916", size = 117467, upload-time = "2024-08-03T19:00:12.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/03/a851e84fcbb85214dc637b6378121ef9a0dd61b4c65264675d8a5c9b1ae7/antlr4_python3_runtime-4.13.2-py3-none-any.whl", hash = "sha256:fe3835eb8d33daece0e799090eda89719dbccee7aa39ef94eed3818cafa5a7e8", size = 144462, upload-time = "2024-08-03T19:00:11.134Z" }, +] + +[[package]] +name = "appdirs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" }, +] + +[[package]] +name = "betterproto2" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/88/7249571b6d525968afbc70f4fc7774cee5cdd55ecebe084a0d49f75513cb/betterproto2-0.10.0.tar.gz", hash = "sha256:fa0bcc3b87b6fd9151cd0058c153e480830f55fddb5afe3fef92e842490be266", size = 16997, upload-time = "2026-05-10T13:02:19.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/e8/596fde3196b253cee2c7a94df2d434b043d99c5a565fb3110468501b2562/betterproto2-0.10.0-py3-none-any.whl", hash = "sha256:be80a0cb1aee34f1b5e424db43884bde659ce1dbc323676aa0d47930e70a898b", size = 19904, upload-time = "2026-05-10T13:02:17.757Z" }, +] + +[[package]] +name = "betterproto2-compiler" +version = "0.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "betterproto2" }, + { name = "jinja2" }, + { name = "ruff" }, + { name = "strenum", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/ae/66cd7614711c73ef8654e018e8f4bc8344fbf8e1dd80241e1ee61cb91db4/betterproto2_compiler-0.10.1.tar.gz", hash = "sha256:0a6bf4673befac9916e5822598231e5885f2f570e6cb61285f6cd47085f88e81", size = 54675, upload-time = "2026-05-13T17:05:28.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9b/4a396309d0b5e31ad7f73d74139477b518fadddbdba7c4f54bbd0fdc5350/betterproto2_compiler-0.10.1-py3-none-any.whl", hash = "sha256:0c4666d40a9fe31331b5ab4cfd0367d70f4bf04d273127e14cd23b221e93575d", size = 66888, upload-time = "2026-05-13T17:05:27.301Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264", size = 222245, upload-time = "2026-08-06T13:46:55.253Z" }, + { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d", size = 222762, upload-time = "2026-08-06T13:46:57.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150", size = 249498, upload-time = "2026-08-06T13:46:59.012Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15", size = 251328, upload-time = "2026-08-06T13:47:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0", size = 253194, upload-time = "2026-08-06T13:47:01.976Z" }, + { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d", size = 255106, upload-time = "2026-08-06T13:47:03.281Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4", size = 250177, upload-time = "2026-08-06T13:47:04.713Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54", size = 251234, upload-time = "2026-08-06T13:47:06.104Z" }, + { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20", size = 249237, upload-time = "2026-08-06T13:47:07.467Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325", size = 253050, upload-time = "2026-08-06T13:47:08.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88", size = 249508, upload-time = "2026-08-06T13:47:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04", size = 250110, upload-time = "2026-08-06T13:47:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", hash = "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338", size = 224294, upload-time = "2026-08-06T13:47:12.753Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360", size = 224919, upload-time = "2026-08-06T13:47:14.17Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "iphone-backup-decrypt" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycryptodome" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/e7/bcdacdec21d628122ba240e7f742ab2175149e58672be63af55ff37a0f28/iphone_backup_decrypt-0.9.0.tar.gz", hash = "sha256:13b18fef3c8e3af627914f8c1a429bbc5555dfb0505239ba49efe99984cc0c96", size = 16125, upload-time = "2024-09-18T15:50:12.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/94/64a31be93f72e0a254bde68e4cf7d24aef37a0a985754a197fa1b028a665/iphone_backup_decrypt-0.9.0-py3-none-any.whl", hash = "sha256:55b5adfafac757f58aa6444b83a4cc2c20cdd699c6ff1d2f4b549936a5dad92c", size = 15767, upload-time = "2024-09-18T15:50:10.537Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "libusb1" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/35/f9d2a990d092d647b47540cd229e1d68432c0f51183484ca189612a4824c/libusb1-3.4.0.tar.gz", hash = "sha256:9cf5638506d54f21bf36550d97ea63189111a23c4d8078f630103a2052135f45", size = 91206, upload-time = "2026-05-16T20:59:19.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/64/d4b59444e4d3b6979aa5eb58840634465a24b41a9ab03dcf8434c9b89551/libusb1-3.4.0-py3-none-any.whl", hash = "sha256:e83d034e44c3efe1c4599c6281d34bca50a38c12cab3b7b6217d583161a01ffd", size = 67373, upload-time = "2026-05-16T20:59:13.142Z" }, + { url = "https://files.pythonhosted.org/packages/7e/55/a838a4278fac4ee49bf670ddc59d621999a1cb2a3188fb13a23609ea4f06/libusb1-3.4.0-py3-none-win32.whl", hash = "sha256:0a1aa1416034690eb9dc9a895eda0fef44d853bca1053eb1de50a5906684846d", size = 129704, upload-time = "2026-05-16T20:59:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/5f/08/02aecf6dad627534a5835244ece14d7f187ef430354d9b8551199934d059/libusb1-3.4.0-py3-none-win_amd64.whl", hash = "sha256:b7dcc1f324a895af6aac708bc5513a17373f97349cc2ab8a277519c788bd18ca", size = 141212, upload-time = "2026-05-16T20:59:17.286Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, +] + +[[package]] +name = "mvt" +source = { editable = "." } +dependencies = [ + { name = "adb-shell", extra = ["usb"] }, + { name = "appdirs" }, + { name = "betterproto2" }, + { name = "click" }, + { name = "cryptography" }, + { name = "iphone-backup-decrypt" }, + { name = "libusb1" }, + { name = "nskeyedunarchiver" }, + { name = "packaging" }, + { name = "pyahocorasick" }, + { name = "pycryptodome" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "simplejson" }, + { name = "tld" }, + { name = "tzdata" }, +] + +[package.dev-dependencies] +dev = [ + { name = "betterproto2-compiler" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-github-actions-annotate-failures" }, + { name = "pytest-mock" }, + { name = "requests" }, + { name = "ruff" }, + { name = "stix2" }, + { name = "types-python-dateutil" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocs-material" }, + { name = "mkdocs-material-extensions" }, + { name = "mkdocstrings" }, +] + +[package.metadata] +requires-dist = [ + { name = "adb-shell", extras = ["usb"], specifier = "==0.4.4" }, + { name = "appdirs", specifier = "==1.4.4" }, + { name = "betterproto2", specifier = "==0.10.0" }, + { name = "click", specifier = "==8.4.2" }, + { name = "cryptography", specifier = "==50.0.0" }, + { name = "iphone-backup-decrypt", specifier = "==0.9.0" }, + { name = "libusb1", specifier = "==3.4.0" }, + { name = "nskeyedunarchiver", specifier = "==1.5.2" }, + { name = "packaging", specifier = "==26.3" }, + { name = "pyahocorasick", specifier = "==2.3.1" }, + { name = "pycryptodome", specifier = ">=3.20.0" }, + { name = "pydantic", specifier = "==2.13.4" }, + { name = "pydantic-settings", specifier = "==2.15.0" }, + { name = "python-dateutil", specifier = "==2.9.0.post0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "requests", specifier = "==2.34.2" }, + { name = "rich", specifier = "==15.0.0" }, + { name = "simplejson", specifier = "==4.1.1" }, + { name = "tld", specifier = "==0.13.2" }, + { name = "tzdata", specifier = "==2026.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "betterproto2-compiler" }, + { name = "mypy", specifier = ">=1.7.1" }, + { name = "pytest", specifier = ">=7.4.3" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-github-actions-annotate-failures", specifier = ">=0.2.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "ruff", specifier = ">=0.1.6" }, + { name = "stix2", specifier = ">=3.0.1" }, + { name = "types-python-dateutil", specifier = ">=2.9.0.20250822" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250516" }, + { name = "types-requests", specifier = ">=2.32.4.20250913" }, +] +docs = [ + { name = "mkdocs", specifier = "==1.6.1" }, + { name = "mkdocs-autorefs", specifier = "==1.4.4" }, + { name = "mkdocs-material", specifier = "==9.7.7" }, + { name = "mkdocs-material-extensions", specifier = "==1.3.1" }, + { name = "mkdocstrings", specifier = "==1.0.6" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, + { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nskeyedunarchiver" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/68/8e48609f2c3554917d3c305e5ec9ba8f3d1ddcadba221d52c1f63b713ded/nskeyedunarchiver-1.5.2.tar.gz", hash = "sha256:d9a2d5d48ea9e2c78d31bfbfc4a97c02794192f3b4548342d727d54bdd20beba", size = 15835, upload-time = "2025-06-11T16:01:57.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c9/5d3c2d9a308310118dca63a0b763d09a12006a6f5d762dbe5e385aeaa318/nskeyedunarchiver-1.5.2-py3-none-any.whl", hash = "sha256:0e52044d137e01e15ab2b18a8f76ed14b2c3555da61190f4679e52892a37cfa0", size = 15926, upload-time = "2025-06-11T16:01:56.355Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/98/0bf930c4f97d0266b58a89e36c015f56232c52b5d2f207215d48cca9e8f7/platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d", size = 32716, upload-time = "2026-08-10T15:48:06.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e2/4e6eee633809c376c024821b91ade709cbfd040ec53939ffbcc292aa7eee/platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4", size = 23361, upload-time = "2026-08-10T15:48:04.855Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyahocorasick" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-github-actions-annotate-failures" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/a0/bdb91581b03c41016c78e16b8ec36c34d8508206fcb30f1951c9cdff2e97/pytest_github_actions_annotate_failures-0.4.2.tar.gz", hash = "sha256:5dd18304512361788bc7b5c5c805db853f03f4950c6be09b088de6bab8e2e6c9", size = 12158, upload-time = "2026-06-19T15:59:17.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/09/c44e658f3a27c588c2017d858c8b0fa962612af9b74326beabbf010c839c/pytest_github_actions_annotate_failures-0.4.2-py3-none-any.whl", hash = "sha256:02911cd3b55f235328a334f8ca6037a89944398b8e8b028c82de97111eff4071", size = 6151, upload-time = "2026-06-19T15:59:16.486Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pytz" +version = "2026.3.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] + +[[package]] +name = "simplejson" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/da/3ba5e87e917094961e7b51b541c88f735f1ca37d580ac78a9302b468f64e/simplejson-4.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7f61eefab86235c800e7f4e37d977080ec424bb2bf0b74e95a2d17ecb48eac0a", size = 111675, upload-time = "2026-04-24T19:22:30.344Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8a/d0c08f4b8934b64469a63d461a68a01d5cc32faf313400dda2bdc1075a29/simplejson-4.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4484960512db9c8124bfa91e0d8a9f9c302338f1c5454e74c21d7d022df10f46", size = 90544, upload-time = "2026-04-24T19:22:32.095Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2d/7832ed91cf4900f86c783d589bfac53358abfccb278f1c8b55eec167b395/simplejson-4.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b75c7ef874dbb350f41827cdf3cee23f5257bdcb0df46d4c01b34badb62dcfe8", size = 90895, upload-time = "2026-04-24T19:22:34.412Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d6/a2a7a482fa43aaeaefc001491d381960f5e685ee4645343e0e037cebb57c/simplejson-4.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c7494c75b95171194f965ea609e97081837a26494d91dcc046ad27dd9c3503e2", size = 168660, upload-time = "2026-04-24T19:22:35.717Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/7a6482f336338dbdb6ca6d3099b2fdc1c74c47eea3c6511975751e9198df/simplejson-4.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1778e09a6e4bb4ef304627915dc4a838569d9e6b737c787925b4e98244bbbc16", size = 167264, upload-time = "2026-04-24T19:22:37.415Z" }, + { url = "https://files.pythonhosted.org/packages/c9/43/039982e956b06c6b019d48bdf9d4ec06f298adf6136552ad1979b94be0fd/simplejson-4.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:67e43e7c0555e10de6d83e1408035652fad28c983516e38c4e3a9a748c9af129", size = 176909, upload-time = "2026-04-24T19:22:38.872Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f5/e3ad592d089922abce2c2ea377548953ac55ffcbe061d600f01b9db2e6b6/simplejson-4.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:93bf6653420258372444de90194dab8de8ff13d74b5d4263a5fefbbe8b8d2060", size = 165930, upload-time = "2026-04-24T19:22:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/f830b648ae04601e6813306535d8e0a4c178d6453cec539b85dafdac80ed/simplejson-4.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0662cfe0482c9796bd097213b27f006815bfdc9b671264c3c0b7fc0e72b71d00", size = 174710, upload-time = "2026-04-24T19:22:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3e/82c8997c4ef2ef6c832fbfc3bb2ed14a212616a284100af03b552ea7e072/simplejson-4.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a9ab55d2459f6d0fdf9984a7a0fb0280dae12979f4fcc3171f5096a4fcf5fafe", size = 167685, upload-time = "2026-04-24T19:22:44.023Z" }, + { url = "https://files.pythonhosted.org/packages/4d/03/80e67a6c63fe812094c681917a5c5d403e34904d200570416863fe2e8328/simplejson-4.1.1-cp310-cp310-win32.whl", hash = "sha256:dfb84ace97acbdf1916c5a675387493fc5a7f67c2e15d4a7687143f8c73024d4", size = 88317, upload-time = "2026-04-24T19:22:45.547Z" }, + { url = "https://files.pythonhosted.org/packages/f4/05/d4fa2c024d566bddff732a2aa437faa4cbee15ee277e2a855faf91a9d906/simplejson-4.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8eb821ef27f688f59ed4a93b17a666a7ebacf8dd65fecaa2b3c531a3aea62eaf", size = 90461, upload-time = "2026-04-24T19:22:47.447Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/39013ffe279d90093ec1c848565b3683c586906c10fa55d9000ec29d046b/simplejson-4.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2867c64d92abd1992c15666fae198203093f593e43d6b81adf176bae530d493a", size = 111538, upload-time = "2026-04-24T19:22:49.051Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ae/2c272971c8a87e2539c54a98eb6ff037bee1e2e93943c3986cf7500a4f3a/simplejson-4.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c47c46e16c8ea9e4850061e6ed5aa2b9cd2074cb2274bfd9c138cba15ce7453", size = 90594, upload-time = "2026-04-24T19:22:50.408Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a2/6eebfb99dedc139f549200f61ade6d1890ac5707c5d427bdfa6fe39c9313/simplejson-4.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e294e33dbf316a9bbdd4030d46503c9b0f19470ae7ad6af5bae6c426bc2e869f", size = 90718, upload-time = "2026-04-24T19:22:51.694Z" }, + { url = "https://files.pythonhosted.org/packages/80/7e/c9e6c0c4ad8415e64dad0c47f619b556b02680a41631b4dbc281d55dc54d/simplejson-4.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7ce252b28fddbdd83db5bd7d93dad2a8a591d7ada098afec9c1b23d6b722a7a4", size = 180901, upload-time = "2026-04-24T19:22:53.025Z" }, + { url = "https://files.pythonhosted.org/packages/34/09/69e331e3994b1ed9be6ce9ace4ade704e7ed503edf869929ca7bb404eda8/simplejson-4.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c44ef6b02a4eb67ed17a72342341792149b3ff46f15426c26e970e49addf327", size = 178133, upload-time = "2026-04-24T19:22:54.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/40/ed806f24afef295c1032448f5ff6f6f2979392d5645ddb9f4fed7f38194d/simplejson-4.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82bfca2b85a34178c25829c703f0a9e9f113a5af7539285bd3efb583a0bf1ba3", size = 188155, upload-time = "2026-04-24T19:22:56.044Z" }, + { url = "https://files.pythonhosted.org/packages/38/94/8d6f515b827b0f7881a49c8c1ac6920b7ae9428939ef04238c973278b42a/simplejson-4.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e4b23f71dd781f8830f1663dc01a4944d3dbf87a1f93d78fba1cf64722d0ccf", size = 176225, upload-time = "2026-04-24T19:22:57.981Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fd/6dffb4956563d48bbe46b91ff341adae34920e94008fd6b8d728072abfc7/simplejson-4.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:82fee635d7b73ad801030b05a75fbd34a098da0c2ecf600667a03636d09e1e42", size = 185535, upload-time = "2026-04-24T19:22:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/a509ee37763e79aec75d68f8521db1440306edeba3b8b4064ab4ee8bf1d9/simplejson-4.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:68e62eda21192c5ea9bb92d571ca46a4477fef48762f50d433de2b4253051551", size = 179302, upload-time = "2026-04-24T19:23:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/5b343bfd2a79d3b6818e4db3586c405a001a090d4c89d336e31273ce7177/simplejson-4.1.1-cp311-cp311-win32.whl", hash = "sha256:ffd3d82294b47f5ec64050021ace95fd62628a0c1cc8bbf4d06d2d1fb697e055", size = 88408, upload-time = "2026-04-24T19:23:02.808Z" }, + { url = "https://files.pythonhosted.org/packages/38/04/df9b37aedbd524dca20840d25ebe01d6ae486b89792aeff5d15b9c4114f7/simplejson-4.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:78a3fe0995be42bed62a26aa78e0e0b4d87c6545785346b9cc898f3389569a35", size = 90526, upload-time = "2026-04-24T19:23:04.408Z" }, + { url = "https://files.pythonhosted.org/packages/60/25/e90998fe8e480eb43b966c09e835379887d427567ebd496563d3b1e16b19/simplejson-4.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:19040a17154dc03d289bab68d73ce0a6a0be01de30c584bbdd93490bead14b22", size = 112414, upload-time = "2026-04-24T19:23:06.084Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a0/abd4785f36c3400f1fbb21f517be39295a750a714f04b7ee175adf6ef580/simplejson-4.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a94ebaecdbaa80d9551a3ec6bf0c9302fc8b53ab6c1b2bfd498a1df4cb28158d", size = 91120, upload-time = "2026-04-24T19:23:07.877Z" }, + { url = "https://files.pythonhosted.org/packages/b8/78/fc060d2e3b13c6ec59288574b8efac64075e316b2afba4396a56b2422f78/simplejson-4.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:67341c95c0a168ab4a6d1e807e50463f1c8da932c3286d81e201266c427061fa", size = 91055, upload-time = "2026-04-24T19:23:09.264Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b6/156a8de1e1b47694f0e7de6675866936608d45dc68388fd017d36f8693be/simplejson-4.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45ec18e337fec538b7e902d489505c450b2454653d1290f3f50385e6fd8aa607", size = 190297, upload-time = "2026-04-24T19:23:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/86/1c/e4d0eab695be3eb21d0f46bce820752031f03e7113f9c80a9b3c73ee7157/simplejson-4.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:820c69a4710400e9b248d5670647d60be58824369282d3925e516b3ff1a7cd82", size = 187002, upload-time = "2026-04-24T19:23:12.982Z" }, + { url = "https://files.pythonhosted.org/packages/76/0e/7f5a59d29426b062d5928fb88b403c3f797129d53be7102f955dbe51aa44/simplejson-4.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e708d373a10e4378ef2d59f8361850c7150fd907ed49efe49bc5492160476d1", size = 195146, upload-time = "2026-04-24T19:23:14.517Z" }, + { url = "https://files.pythonhosted.org/packages/78/18/9943db224dd4d5fa3c090c3e56a94c37b254338c83995ec5680285111c40/simplejson-4.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:980fc33353f81fd12d8c49d44f8c2760d1dc8192285e627c5180d141035b228a", size = 183931, upload-time = "2026-04-24T19:23:16.742Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/9a690da9a766161c06c627d805362cf159f1abe480969372b2897649b955/simplejson-4.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de2ed102fff88dacf543699f53ee3a533cc11539a39baa176b7e09dd783069d6", size = 192228, upload-time = "2026-04-24T19:23:18.33Z" }, + { url = "https://files.pythonhosted.org/packages/05/88/bd8aad36b451ffb0e0a3f721d695a88befa6d1ac7d1e02ae788ca7ff4029/simplejson-4.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785ff8edc0e28bf773a32543a6bbed46351453c997b3f6709c744e3c2f7eabb", size = 187808, upload-time = "2026-04-24T19:23:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/04/ee/14f91db0d1f481533b651dafbf8cd0da088d9817f7af30c68f7f19f9c847/simplejson-4.1.1-cp312-cp312-win32.whl", hash = "sha256:2e0d5ead6d14610467ec356ec1f6b5d8a56aa216abaad8d41c8b873b16cf313f", size = 88512, upload-time = "2026-04-24T19:23:22.764Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c4/90de06b2d8737c68c05ff9274113f854dbf6a5f28b7a955212111672cb57/simplejson-4.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:63a5451f557d6be48a231bae932458655c620902b868170b2f1c8afed496f6b4", size = 90748, upload-time = "2026-04-24T19:23:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/47b445eeb559c9593453a0648e0fd6d08e8adff64dd5e5ced66726da8a09/simplejson-4.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dff52fc7af272e84fc21cc5a06c927c823ca6ae00af14f3b0d7707b42775ed98", size = 113160, upload-time = "2026-04-24T19:23:26.033Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/cb72db31523c164dea5dc55b02dad065a40c478856bc7534b279d2b51906/simplejson-4.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:971aed0647ad6e840a3943bec812fcda5f2d26a5497a4981d1fb49aa4f9a396c", size = 91521, upload-time = "2026-04-24T19:23:27.572Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e5/54cb7c50ad5fdc1e0a86b7df4b135c2cbd5c4623605aa94466659098e8da/simplejson-4.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:249e2e220aa6d9b9d936bde84eb7bf79d5b6c5a8273c6e411f8b1635a9073f2d", size = 91407, upload-time = "2026-04-24T19:23:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/21a3ede87f0bf82d6c7bcb90480d50a6490eb974c6ab20881188e440957c/simplejson-4.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e5cdd6a5d52299f345c15ab5678cc4249e24f383f361d986afbc3c7072a6b6b", size = 192451, upload-time = "2026-04-24T19:23:30.56Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/9903edd3102bf0b5984edfcb90c88612330996efa3b4fbf8a971d6e17839/simplejson-4.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642cec364e0676e2d5a73fa4d31d0c7c55886997caa2fde24e8292ca44d32728", size = 189015, upload-time = "2026-04-24T19:23:32.647Z" }, + { url = "https://files.pythonhosted.org/packages/98/cd/33230927a780e1398b857e3944abb914556994d252b1d765ae40d112cb25/simplejson-4.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76fe296ca1df23d290033f10aaacf534fd1b3e3007e7f9ff8aa68b21413aaa78", size = 196658, upload-time = "2026-04-24T19:23:34.563Z" }, + { url = "https://files.pythonhosted.org/packages/cd/84/2c5a7444eb53e9a86d3738299bffddd9f53aeed799ded2f45368221fdb19/simplejson-4.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f0ad25b7dc4e0fb23858355819f2e994f1a5badcdcde8737eac7921c2f1ed2a", size = 185967, upload-time = "2026-04-24T19:23:36.191Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/454378e06d059cd412a7ed5d87fb6d29fd5b60f13a4d89fc1f764ff434df/simplejson-4.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a59ebd0533f03fd06ff0c42ba0f02d93cbcdd7944922bf3b93911327a95b901f", size = 193940, upload-time = "2026-04-24T19:23:38.151Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d5/a15bf915f623a2c5a079d6e3be8256fdb8ef06f110669493a09b9d6933e0/simplejson-4.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bccbf4419676b517939852e5aeff2af6aee4dc046881c67a1581fa6f1cb01abd", size = 189795, upload-time = "2026-04-24T19:23:40.139Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c9/37212ae7dc4b607f0978c408e8633f05c810884e054c33113184c6c2c8a2/simplejson-4.1.1-cp313-cp313-win32.whl", hash = "sha256:6c845363eb5fd166fb7c72243da38f4fcfde666ede7fdf2cc6fd7762894626f7", size = 88773, upload-time = "2026-04-24T19:23:41.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c7a0a47883a9015b54c9d8a4b62f2aba17bd4335b1787b9b8a0fc2fa6d52/simplejson-4.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:104d8324c34f25b4b90800bc5fa363780cbc3d8496aef061cba7ce1af9162270", size = 90888, upload-time = "2026-04-24T19:23:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/4a118a6a92eb33bb08c8e2fe7ec85cb96f0673491bb2b829930831ee4fbe/simplejson-4.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ed7473602b6625de793b6acba49aa949f144a475f538792067e4cf2fda2071f5", size = 110492, upload-time = "2026-04-24T19:23:44.957Z" }, + { url = "https://files.pythonhosted.org/packages/07/f4/84d160e9fa8cada1e0a9381cae4fa81eecd573577a5b34366d8ced59bdf7/simplejson-4.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:225c9caa324c5b554d009fb9cac22aee7711e71bd96f487938c659af467e828e", size = 90152, upload-time = "2026-04-24T19:23:46.355Z" }, + { url = "https://files.pythonhosted.org/packages/68/31/9a5432c433a7671107182cdc9a20ea78a70f99c4e5334aa54b6d4d0d79ed/simplejson-4.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:95407269340c7f22f09776ea7b717a52cf56cfcf119b5e45f66faa4a26445bea", size = 90115, upload-time = "2026-04-24T19:23:47.743Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/3635cdb13318cb0a328abaa69e2b91251caad39d6779aa308098f341f6cb/simplejson-4.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3851658d642c1184d2023f0e6c9ce44a21eb1629e74e7c84ef956b128841fe12", size = 184036, upload-time = "2026-04-24T19:23:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/149b6ec5393f6849d98c59cadba888b710a8ef4b805ab91e11a566960d40/simplejson-4.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95a3bb0f78e85f4937f99092239f2011ce06f0f2d803df5c299cc05abbeae008", size = 180543, upload-time = "2026-04-24T19:23:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/df/7c/a5d968d0b527a748b667e62bea94309ccbcb1e2b108e8f0cf8547efaa12b/simplejson-4.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbfdaa7c0603f75b7b14b211b7f2be44696d4e26833ad2d91d5c87bf5fb9a920", size = 188725, upload-time = "2026-04-24T19:23:52.995Z" }, + { url = "https://files.pythonhosted.org/packages/db/e3/6a8d11181d587ef00e2db9112357e6832111e56dd56b01b5c11758a1965d/simplejson-4.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e3c584071dced8c21b4689f0254303521daeb9b5bc1f4289755d71fa3cb0d3", size = 177492, upload-time = "2026-04-24T19:23:54.581Z" }, + { url = "https://files.pythonhosted.org/packages/67/e3/8b0eb8b06e8198cfbd1270487da163d0093df05cc4f557350cd65e2f7e79/simplejson-4.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:036a27bd0469b9d79557cbddb392969f876cd7f278cfbd0fba81534927a06575", size = 185281, upload-time = "2026-04-24T19:23:56.13Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5f/64990f07ec9e2cb1a814c674e2e21b5693207f74ac70eb72151b847ea4e6/simplejson-4.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b70bfd2f67f3351baba08aa3ae9233c83f21fd95ae5e6b3d0ecb8c647929112f", size = 181848, upload-time = "2026-04-24T19:23:57.92Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/bbc1bc0447f339f79f99ab8c37f7f037cb2f1f93af75d6a4d553096bb0c3/simplejson-4.1.1-cp314-cp314-win32.whl", hash = "sha256:37233c72ce88d06acb92747347742b3c07871eba6789f060c179c9302dde8efe", size = 88761, upload-time = "2026-04-24T19:23:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/18/72/ec1b5cbdcb140c132e6c7bdf99bd73e4f675439e77126c88f472fcffa09c/simplejson-4.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:cc0442dea71cd9cbf30a0b8b9929ab5aa6c02c0443a3d977351e6ec5bada4388", size = 91018, upload-time = "2026-04-24T19:24:00.85Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/4fa437f68ff72219bac3bf3d050de9c6265691f3a170e16954bd69d7cddd/simplejson-4.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c996a4d38290c515af347740659ce095b425449c164a5c9fa3977caa6eff5dbe", size = 113919, upload-time = "2026-04-24T19:24:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/59de041d09eb4a9577f7015d7263c32095dfb7fde49717dff62145d89809/simplejson-4.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c65c763fb20d7ca113c1c14dce2fc04a0fc3a57aceff533d6fdac707c7bffb40", size = 91904, upload-time = "2026-04-24T19:24:03.812Z" }, + { url = "https://files.pythonhosted.org/packages/03/8e/46bb345d540f6eb31427d984a4e518cdb182d0621814fee4fee045e8815b/simplejson-4.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0da5c9f57206ee7ef280ff7f1d924937b0a64f9a271a5ef371a2ecdbebba7421", size = 91752, upload-time = "2026-04-24T19:24:05.622Z" }, + { url = "https://files.pythonhosted.org/packages/83/e2/1b2ce97f068835eb3d253c116a4df7a3f436b7bf2fb5ff1ba29287e8b0ec/simplejson-4.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ea3426e786425d10e9e82f8a6eda74a7d6eb10d99165ac3d0d3bbcb65c0ea343", size = 214021, upload-time = "2026-04-24T19:24:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/48/70/d93e556df6a0786298644a7c08304fcbeddc248325f23f38acbebeb21165/simplejson-4.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d75cea7a1025edd7e439b2966b3d977c45b5b899e2adaf422811b3ac702ed9fb", size = 213530, upload-time = "2026-04-24T19:24:09.289Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/c93bf305b9f00d7259e09e713d60e75bd0f7f53da970f716ab90491770e7/simplejson-4.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63c2ada8e58f266491f19eed2eeeb7c25c6141e52f8f9e820f6bb94156cf8dbc", size = 218282, upload-time = "2026-04-24T19:24:10.991Z" }, + { url = "https://files.pythonhosted.org/packages/0c/20/a9b5d2e27ec44b069ee251bd55544fc76929a067107b1050001566ba86f3/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d1fffb56305c5b475ee746cf9e04f97423ba5aaacd292dc1255bd75b1d3b124b", size = 209249, upload-time = "2026-04-24T19:24:12.662Z" }, + { url = "https://files.pythonhosted.org/packages/97/e4/e06ee682ed5df67592181f5ecb062e35878967e27f5b6e087237d4548d95/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a6525ec733f43d0541206cffa64fd2aad5a7ae3eb76566aff49cd4db6382209a", size = 213963, upload-time = "2026-04-24T19:24:14.302Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9f/1e160e4cd8cdbf062bf6a454cdf814dc7a48eb47e566fdb8f80ccb202605/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:861e393260508efa64d8805a8e49c416c3484907e3f146ce966c69552b49b9a3", size = 210474, upload-time = "2026-04-24T19:24:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e6/cecd913df322df5bbe7ebb8ba39e0708e505a165553900da8a7761026d6f/simplejson-4.1.1-cp314-cp314t-win32.whl", hash = "sha256:d083b89d30948a751d3d97476c2ed91e4caaa24a1a1459bdbadb8876242c71fe", size = 91134, upload-time = "2026-04-24T19:24:17.635Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/f540dde99cc1d393bd062ab3b5735b777561a5d8f8a5f2e241164444d77a/simplejson-4.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4cbb299d0528ec0447fe366d8c9641860e28f997a62730690fef905f1f41046e", size = 94467, upload-time = "2026-04-24T19:24:19.109Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "stix2" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, + { name = "requests" }, + { name = "simplejson" }, + { name = "stix2-patterns" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/c8/103631824008f5a5259ff35f91442dccbb9ecad9a67f146b19d555679273/stix2-3.0.2.tar.gz", hash = "sha256:5bdaf3b7bd956a35b629c62b2c64fde8b2ce6f329b43ce09e12f672956507645", size = 141614, upload-time = "2026-02-12T08:44:50.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ae/d8d4c5bf65293543d0cbf2dd019b0169de3c7a37ae93ae4e2300d446805f/stix2-3.0.2-py2.py3-none-any.whl", hash = "sha256:f4814d29ebc332c92694fc8ddc96a4a5bfe2eac08494c0dec219719e7e654eb3", size = 161013, upload-time = "2026-02-12T08:44:48.771Z" }, +] + +[[package]] +name = "stix2-patterns" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/c1/adca6c1a5226cc3aa594d03b8562955563c6d7ed855aef071ad0e2feb2b8/stix2_patterns-2.1.2.tar.gz", hash = "sha256:b2059d36c1fd87740f3facc22a4147cde1e2b0acb8d5e4c08fbf04b1dc553185", size = 78811, upload-time = "2026-02-11T16:48:16.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/99/765db17c197ae1a971daae21a7080c83124c9a8ee2fc0f378145e2201b60/stix2_patterns-2.1.2-py2.py3-none-any.whl", hash = "sha256:e164e162936303c2e141760130e54f0d247d6272ebf4e1153b8e99e8a42c82ca", size = 81822, upload-time = "2026-02-11T16:48:15.172Z" }, +] + +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384, upload-time = "2023-06-29T22:02:58.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, +] + +[[package]] +name = "tld" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20260807" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/4e/b3fa538f9cb38dfece0d6ccf6d3d0d925bdedb144fb9c8129dfc007cd003/types_python_dateutil-2.9.0.20260807.tar.gz", hash = "sha256:e0b8a90d464c8684c66b7b8e4556d9074afdddcc56ca45323f0987134f9e7034", size = 17618, upload-time = "2026-08-07T04:17:13.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/5e/3715867caea2f4cea56ccb04c851cde23ed063449c3b004c7a047f20dd48/types_python_dateutil-2.9.0.20260807-py3-none-any.whl", hash = "sha256:54aa3707350ed7a9cc0776fd2f6739679d6967d11b40150985e81edcb86df4db", size = 18486, upload-time = "2026-08-07T04:17:12.504Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260724" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260712" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/bc/4eae18cd40c65798a16267572ba346c11f599d44b01603dbd843342042bc/typing_inspection-0.4.3.tar.gz", hash = "sha256:c5f9ec1530b5c1e2c9bc34a84d9a3466ed1b2f3f2fa9f901368d9c5596210e4d", size = 76711, upload-time = "2026-08-10T09:39:18.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl", hash = "sha256:5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd", size = 14693, upload-time = "2026-08-10T09:39:16.693Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +]