From 39a26d0f0be9d2696050a745b5becbc539e24d8a Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sun, 12 Apr 2026 10:51:56 +0200 Subject: [PATCH 001/109] Replace iOSbackup with iphone_backup_decrypt Replace the unmaintained iOSbackup dependency with iphone_backup_decrypt (MIT licensed, actively maintained). This fixes file corruption caused by iOSbackup truncating files to inaccurate sizes from backup metadata. The extract-key command and --key-file option are preserved via an MVTEncryptedBackup subclass that patches the keybag unlock to capture/reuse the derived PBKDF2 key. Closes #669 --- pyproject.toml | 3 +- src/mvt/ios/decrypt.py | 238 +++++++++++++++++++++++++++++++---------- 2 files changed, 182 insertions(+), 59 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fedfff9a..e8ef917e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,8 @@ dependencies = [ "simplejson==3.20.2", "packaging==26.0", "appdirs==1.4.4", - "iOSbackup==0.9.925", + "iphone_backup_decrypt==0.9.0", + "pycryptodome>=3.18", "adb-shell[usb]==0.4.4", "libusb1==3.3.1", "cryptography==46.0.6", diff --git a/src/mvt/ios/decrypt.py b/src/mvt/ios/decrypt.py index ffb2cb75..d615d051 100644 --- a/src/mvt/ios/decrypt.py +++ b/src/mvt/ios/decrypt.py @@ -6,17 +6,146 @@ import binascii import glob import logging -import multiprocessing import os import os.path +import plistlib import shutil import sqlite3 +import tempfile from typing import Optional -from iOSbackup import iOSbackup +from iphone_backup_decrypt import EncryptedBackup +from iphone_backup_decrypt import google_iphone_dataprotection log = logging.getLogger(__name__) +# Import pbkdf2_hmac from the same source iphone_backup_decrypt uses internally, +# so our key derivation is consistent with theirs. +try: + from fastpbkdf2 import pbkdf2_hmac +except ImportError: + import Crypto.Hash.SHA1 + import Crypto.Hash.SHA256 + import Crypto.Protocol.KDF + + _HASH_FNS = {"sha1": Crypto.Hash.SHA1, "sha256": Crypto.Hash.SHA256} + + def pbkdf2_hmac(hash_name, password, salt, iterations, dklen=None): + return Crypto.Protocol.KDF.PBKDF2( + password, salt, dklen, iterations, hmac_hash_module=_HASH_FNS[hash_name] + ) + + +class MVTEncryptedBackup(EncryptedBackup): + """Extends EncryptedBackup with derived key export/import. + + NOTE: This subclass relies on internal APIs of iphone_backup_decrypt + (specifically _read_and_unlock_keybag, _keybag, and the Keybag class + internals). Pinned to iphone_backup_decrypt==0.9.0. + """ + + def __init__(self, *, backup_directory, passphrase=None, derived_key=None): + if passphrase: + super().__init__(backup_directory=backup_directory, passphrase=passphrase) + self._derived_key = None # Will be set after keybag unlock + elif derived_key: + self._init_without_passphrase(backup_directory, derived_key) + else: + raise ValueError("Either passphrase or derived_key must be provided") + + def _init_without_passphrase(self, backup_directory, derived_key): + """Replicate parent __init__ state without requiring a passphrase.""" + self.decrypted = False + self._backup_directory = os.path.expandvars(backup_directory) + self._passphrase = None + self._manifest_plist_path = os.path.join( + self._backup_directory, "Manifest.plist" + ) + self._manifest_plist = None + self._manifest_db_path = os.path.join(self._backup_directory, "Manifest.db") + self._keybag = None + self._unlocked = False + self._temporary_folder = tempfile.mkdtemp() + self._temp_decrypted_manifest_db_path = os.path.join( + self._temporary_folder, "Manifest.db" + ) + self._temp_manifest_db_conn = None + self._derived_key = derived_key # 32 raw bytes + + def _read_and_unlock_keybag(self): + """Override to capture derived key on password unlock, or use + a pre-derived key to skip PBKDF2.""" + if self._unlocked: + return self._unlocked + + with open(self._manifest_plist_path, "rb") as infile: + self._manifest_plist = plistlib.load(infile) + self._keybag = google_iphone_dataprotection.Keybag( + self._manifest_plist["BackupKeyBag"] + ) + + if self._derived_key: + # Skip PBKDF2, unwrap class keys directly with pre-derived key + self._unlocked = _unlock_keybag_with_derived_key( + self._keybag, self._derived_key + ) + else: + # Normal path: full PBKDF2 derivation, capturing the intermediate key + self._unlocked, self._derived_key = _unlock_keybag_and_capture_key( + self._keybag, self._passphrase + ) + self._passphrase = None + + if not self._unlocked: + raise ValueError("Failed to decrypt keys: incorrect passphrase?") + return True + + def get_decryption_key(self): + """Return derived key as hex string (64 chars / 32 bytes).""" + if self._derived_key is None: + raise ValueError("No derived key available") + return self._derived_key.hex() + + +def _unlock_keybag_with_derived_key(keybag, passphrase_key): + """Unlock keybag class keys using a pre-derived passphrase_key, + skipping the expensive PBKDF2 rounds.""" + WRAP_PASSPHRASE = 2 + for classkey in keybag.classKeys.values(): + if b"WPKY" not in classkey: + continue + if classkey[b"WRAP"] & WRAP_PASSPHRASE: + k = google_iphone_dataprotection._AESUnwrap( + passphrase_key, classkey[b"WPKY"] + ) + if not k: + return False + classkey[b"KEY"] = k + return True + + +def _unlock_keybag_and_capture_key(keybag, passphrase): + """Run full PBKDF2 key derivation and AES unwrap, returning + (success, passphrase_key) so the derived key can be exported.""" + passphrase_round1 = pbkdf2_hmac( + "sha256", passphrase, keybag.attrs[b"DPSL"], keybag.attrs[b"DPIC"], 32 + ) + passphrase_key = pbkdf2_hmac( + "sha1", passphrase_round1, keybag.attrs[b"SALT"], keybag.attrs[b"ITER"], 32 + ) + WRAP_PASSPHRASE = 2 + for classkey in keybag.classKeys.values(): + if b"WPKY" not in classkey: + continue + if classkey[b"WRAP"] & WRAP_PASSPHRASE: + k = google_iphone_dataprotection._AESUnwrap( + passphrase_key, classkey[b"WPKY"] + ) + if not k: + return False, None + classkey[b"KEY"] = k + return True, passphrase_key + class DecryptBackup: """This class provides functions to decrypt an encrypted iTunes backup @@ -55,41 +184,27 @@ class DecryptBackup: log.critical("The backup does not seem encrypted!") return False - def _process_file( - self, relative_path: str, domain: str, item, file_id: str, item_folder: str - ) -> None: - self._backup.getFileDecryptedCopy( - manifestEntry=item, targetName=file_id, targetFolder=item_folder - ) - log.info( - "Decrypted file %s [%s] to %s/%s", - relative_path, - domain, - item_folder, - file_id, - ) - def process_backup(self) -> None: if not os.path.exists(self.dest_path): os.makedirs(self.dest_path) manifest_path = os.path.join(self.dest_path, "Manifest.db") - # We extract a decrypted Manifest.db. - self._backup.getManifestDB() - # We store it to the destination folder. - shutil.copy(self._backup.manifestDB, manifest_path) - - pool = multiprocessing.Pool(multiprocessing.cpu_count()) - - for item in self._backup.getBackupFilesList(): - try: - file_id = item["backupFile"] - relative_path = item["relativePath"] - domain = item["domain"] + # Extract a decrypted Manifest.db to the destination folder. + self._backup.save_manifest_file(output_filename=manifest_path) + # Iterate over all files in the backup and decrypt them, + # preserving the XX/file_id directory structure that downstream + # modules expect. + with self._backup.manifest_db_cursor() as cur: + cur.execute( + "SELECT fileID, domain, relativePath, file FROM Files WHERE flags=1" + ) + 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[0:2], file_id) + source_file_path = os.path.join( + self.backup_path, file_id[:2], file_id + ) if not os.path.exists(source_file_path): log.debug( "Skipping file %s. File not found in encrypted backup directory.", @@ -97,24 +212,26 @@ class DecryptBackup: ) continue - item_folder = os.path.join(self.dest_path, file_id[0:2]) - if not os.path.exists(item_folder): - os.makedirs(item_folder) + item_folder = os.path.join(self.dest_path, file_id[:2]) + os.makedirs(item_folder, exist_ok=True) - # iOSBackup getFileDecryptedCopy() claims to read a "file" - # parameter but the code actually is reading the "manifest" key. - # Add manifest plist to both keys to handle this. - item["manifest"] = item["file"] - - pool.apply_async( - self._process_file, - args=(relative_path, domain, item, file_id, item_folder), - ) - except Exception as exc: - log.error("Failed to decrypt file %s: %s", relative_path, exc) - - pool.close() - pool.join() + try: + decrypted = self._backup._decrypt_inner_file( + file_id=file_id, file_bplist=file_bplist + ) + 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, + file_id, + ) + except Exception as exc: + log.error("Failed to decrypt file %s: %s", relative_path, exc) # Copying over the root plist files as well. for file_name in os.listdir(self.backup_path): @@ -155,20 +272,23 @@ class DecryptBackup: return try: - self._backup = iOSbackup( - udid=os.path.basename(self.backup_path), - cleartextpassword=password, - backuproot=os.path.dirname(self.backup_path), + self._backup = MVTEncryptedBackup( + backup_directory=self.backup_path, + passphrase=password, ) + # Eagerly trigger keybag unlock so wrong-password errors + # surface here rather than later during process_backup(). + self._backup.test_decryption() except Exception as exc: + self._backup = None if ( - isinstance(exc, KeyError) - and len(exc.args) > 0 - and exc.args[0] == b"KEY" + isinstance(exc, ValueError) + and "passphrase" in str(exc).lower() ): log.critical("Failed to decrypt backup. Password is probably wrong.") elif ( isinstance(exc, FileNotFoundError) + and hasattr(exc, "filename") and os.path.basename(exc.filename) == "Manifest.plist" ): log.critical( @@ -211,12 +331,14 @@ class DecryptBackup: try: key_bytes_raw = binascii.unhexlify(key_bytes) - self._backup = iOSbackup( - udid=os.path.basename(self.backup_path), - derivedkey=key_bytes_raw, - backuproot=os.path.dirname(self.backup_path), + self._backup = MVTEncryptedBackup( + backup_directory=self.backup_path, + derived_key=key_bytes_raw, ) + # Eagerly trigger keybag unlock so wrong-key errors surface here. + self._backup.test_decryption() except Exception as exc: + self._backup = None log.exception(exc) log.critical( "Failed to decrypt backup. Did you provide the correct key file?" @@ -227,7 +349,7 @@ class DecryptBackup: if not self._backup: return - self._decryption_key = self._backup.getDecryptionKey() + self._decryption_key = self._backup.get_decryption_key() log.info( 'Derived decryption key for backup at path %s is: "%s"', self.backup_path, From 5ed8b3c1a5b0aaca925d590552852e756a22c65a Mon Sep 17 00:00:00 2001 From: Felix <24938145+Nxtmaster10@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:25:47 +0200 Subject: [PATCH 002/109] fix: terminate dumpsys adb multiline values at structural lines (#842) * fix: terminate dumpsys adb multiline values at structural lines * fix dumpsys ADB multiline boundaries --------- Co-authored-by: Janik Besendorf --- src/mvt/android/artifacts/dumpsys_adb.py | 21 +++++- tests/android/test_artifact_dumpsys_adb.py | 60 ++++++++++++++++++ .../android_data/dumpsys_adb_wifi.txt | Bin 0 -> 2037 bytes 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 tests/artifacts/android_data/dumpsys_adb_wifi.txt diff --git a/src/mvt/android/artifacts/dumpsys_adb.py b/src/mvt/android/artifacts/dumpsys_adb.py index d2a33fbb..e5438c9b 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() diff --git a/tests/android/test_artifact_dumpsys_adb.py b/tests/android/test_artifact_dumpsys_adb.py index 1d3d76be..47dc3dfc 100644 --- a/tests/android/test_artifact_dumpsys_adb.py +++ b/tests/android/test_artifact_dumpsys_adb.py @@ -30,6 +30,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") 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 0000000000000000000000000000000000000000..ecd7f159c378d80eb525055a5dc35632d44033ac GIT binary patch literal 2037 zcmeH|*^c8x6oxZ{1Sl5>-oTe2)5L3HTZ%N^5+_dL@t#CN!Ap6IV<+C-s2>gSNW210 zx*6b}D>S82sq~*kRnqzOJw5%WYb==CkC3u}bWyWbya+kI%ZyopgSPCBU5Xl{8>}Hh z-~09DTK45V{sS}qbqqm1aO*IPv+#6rGA9JZ{H{WfmuDI9D&U{0{4}J`tD**vNa~c; z=?wZ)0Xz^lz@IM=hD8}%U?`s9aArX;Gmb%MhF&nD$WR<3Pz+8nn8Jk+9 zF6CThhxO6qN-IvJp(T)US2XI{&&U<5!aoIKbjY@+_`;`C}B zkwF+``ihT=5HY)*l6ZW#J&?KMR%B|g4n(wuW3-8H-VpcJ3%0R|37ufAcsec!hv08Fm2Q;e+v9Ot|v9?!2u4a zimGOs)6kJKJwCUdS)243mfD-hXO`p}=eAHAzv{BEjupA?cd%9@OAbDo%GB`$u+$0; z!-|GYFf}$NslP*7cON?~h7G@==_MNb#4g^g+r*IjRKl|wH@l_>jagwOrn(o2NJ!qW zQl_9xogno?eql%jGPl!E4>_0)HJ(3_R(hlCZqJvET~nu*|Q7 zQP}3Kg%{mNeaj>Mes7uHwz=wAj6~fgWT})~t_{ShDIw|i%6@$fRXQV5Kakih)$hC6 z&alquF-}>Zbz7t8x3!*%JI^w5bY+=ZiNjYsZIsLGZ0e$z5w?KSm$23vN+l0djf^&d zBr7zLOH-+FLx7OvRo{dJMcYh^)Ik-#){p2m>i=pQIaQ|$N&-VBq*yDAohjaAJ9U%NG zULF>BH>kgKhoAA$dwewd?%|`s3#T>?;x`&W5Xc-i9as}MY5B?_2+Ba!=EeEsmSybE zA9a6=|J360G~lbe)9kJ0Uwx{yJYDmmnj?=MFT?pVei?s4G=%(wAZQ7-P6<^Jfc&~} ns^RHS9t`(SME!Aiq(6`paw-PG5R7<2@Fxs~P?SMt42Jy)NcfnA literal 0 HcmV?d00001 From 123c9081eda5cb70cc0b8d7a1b70b12962ff1540 Mon Sep 17 00:00:00 2001 From: besendorf Date: Sun, 19 Jul 2026 17:13:57 +0200 Subject: [PATCH 003/109] Skip resolving Google Maps short URLs (#843) --- src/mvt/common/url.py | 5 +++++ tests/common/test_indicators.py | 8 ++++++++ tests/common/test_url.py | 24 ++++++++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 tests/common/test_url.py diff --git a/src/mvt/common/url.py b/src/mvt/common/url.py index 95de5c7d..426d64bd 100644 --- a/src/mvt/common/url.py +++ b/src/mvt/common/url.py @@ -5,6 +5,7 @@ import logging from typing import Optional +from urllib.parse import urlparse import requests from tld import get_tld @@ -373,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 diff --git a/tests/common/test_indicators.py b/tests/common/test_indicators.py index 00c72761..df6a28e1 100644 --- a/tests/common/test_indicators.py +++ b/tests/common/test_indicators.py @@ -80,6 +80,14 @@ 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_file_hash(self, indicator_file): ind = Indicators(log=logging) ind.load_indicators_files([indicator_file], load_default=False) diff --git a/tests/common/test_url.py b/tests/common/test_url.py new file mode 100644 index 00000000..ce0fb635 --- /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 From a6c3a805d82886dafdb413d235a455e14be63db4 Mon Sep 17 00:00:00 2001 From: besendorf Date: Mon, 27 Jul 2026 15:03:02 +0200 Subject: [PATCH 004/109] Parallelize URL indicator checks (#844) --- docs/iocs.md | 7 +- src/mvt/android/modules/backup/sms.py | 10 +- src/mvt/common/indicators.py | 44 ++++++- src/mvt/ios/modules/mixed/shortcuts.py | 6 +- src/mvt/ios/modules/mixed/sms.py | 7 +- .../mixed/webkit_session_resource_log.py | 56 +++++---- src/mvt/ios/modules/mixed/whatsapp.py | 6 +- tests/common/test_indicators.py | 115 ++++++++++++++++++ tests/ios_backup/test_sms.py | 28 +++++ 9 files changed, 239 insertions(+), 40 deletions(-) diff --git a/docs/iocs.md b/docs/iocs.md index be9ca4e9..c7586dea 100644 --- a/docs/iocs.md +++ b/docs/iocs.md @@ -37,8 +37,10 @@ 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. The following environment variables control these -requests: +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 @@ -73,4 +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/src/mvt/android/modules/backup/sms.py b/src/mvt/android/modules/backup/sms.py index 6f8306a5..c42e9a46 100644 --- a/src/mvt/android/modules/backup/sms.py +++ b/src/mvt/android/modules/backup/sms.py @@ -36,6 +36,8 @@ class SMS(BackupModule): if not self.indicators: return + messages = [] + url_batches = [] for message in self.results: if "body" not in message: continue @@ -44,12 +46,16 @@ class SMS(BackupModule): if message_links == []: message_links = check_for_links(message.get("text", "")) - ioc_match = self.indicators.check_urls(message_links) + 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 ) - continue def run(self) -> None: sms_path = "apps/com.android.providers.telephony/d_f/*_sms_backup" diff --git a/src/mvt/common/indicators.py b/src/mvt/common/indicators.py index 215c1ef6..7186ae36 100644 --- a/src/mvt/common/indicators.py +++ b/src/mvt/common/indicators.py @@ -7,9 +7,10 @@ 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 +from typing import Any, Dict, Iterator, List, Optional, Sequence import ahocorasick from appdirs import user_data_dir @@ -22,6 +23,8 @@ MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators") logger = logging.getLogger(__name__) +URL_CHECK_MAX_WORKERS = 20 + @dataclass class Indicator: @@ -490,12 +493,41 @@ 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. + + 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 diff --git a/src/mvt/ios/modules/mixed/shortcuts.py b/src/mvt/ios/modules/mixed/shortcuts.py index f8899a42..eb749ee8 100644 --- a/src/mvt/ios/modules/mixed/shortcuts.py +++ b/src/mvt/ios/modules/mixed/shortcuts.py @@ -76,8 +76,10 @@ class Shortcuts(IOSExtraction): if not self.indicators: return - for result in self.results: - ioc_match = self.indicators.check_urls(result["action_urls"]) + 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 diff --git a/src/mvt/ios/modules/mixed/sms.py b/src/mvt/ios/modules/mixed/sms.py index 76e00487..596ba471 100644 --- a/src/mvt/ios/modules/mixed/sms.py +++ b/src/mvt/ios/modules/mixed/sms.py @@ -85,12 +85,17 @@ class SMS(IOSExtraction): 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_match = self.indicators.check_urls(message_links) + 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 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 d8b9a85c..eb343115 100644 --- a/src/mvt/ios/modules/mixed/webkit_session_resource_log.py +++ b/src/mvt/ios/modules/mixed/webkit_session_resource_log.py @@ -70,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"]) @@ -94,36 +96,42 @@ class WebkitSessionResourceLog(IOSExtraction): ) ) - ioc_match = self.indicators.check_urls(all_origins) - if ioc_match: - self.alertstore.critical( - ioc_match.message, "", entry, matched_indicator=ioc_match.ioc - ) + records.append((entry, source_domains, destination_domains)) + url_batches.append(all_origins) - redirect_path = "" - if len(source_domains) > 0: - redirect_path += "SOURCE: " - for idx, item in enumerate(source_domains): - source_domains[idx] = f'"{item}"' + 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 += ", ".join(source_domains) - redirect_path += " -> " + redirect_path = "" + if len(source_domains) > 0: + redirect_path += "SOURCE: " + for idx, item in enumerate(source_domains): + source_domains[idx] = f'"{item}"' - redirect_path += f'ORIGIN: "{entry["origin"]}"' + redirect_path += ", ".join(source_domains) + redirect_path += " -> " - if len(destination_domains) > 0: - redirect_path += " -> " - redirect_path += "DESTINATION: " - for idx, item in enumerate(destination_domains): - destination_domains[idx] = f'"{item}"' + redirect_path += f'ORIGIN: "{entry["origin"]}"' - redirect_path += ", ".join(destination_domains) + if len(destination_domains) > 0: + redirect_path += " -> " + redirect_path += "DESTINATION: " + for idx, item in enumerate(destination_domains): + destination_domains[idx] = f'"{item}"' - self.alertstore.high( - f"Found HTTP redirect between suspicious domains: {redirect_path}", - "", - entry, - ) + 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 = [] diff --git a/src/mvt/ios/modules/mixed/whatsapp.py b/src/mvt/ios/modules/mixed/whatsapp.py index 2a72c6f4..cf3a49b4 100644 --- a/src/mvt/ios/modules/mixed/whatsapp.py +++ b/src/mvt/ios/modules/mixed/whatsapp.py @@ -61,8 +61,10 @@ class Whatsapp(IOSExtraction): if not self.indicators: return - for result in self.results: - ioc_match = self.indicators.check_urls(result.get("links", [])) + 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 diff --git a/tests/common/test_indicators.py b/tests/common/test_indicators.py index df6a28e1..bbb55b2b 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 @@ -88,6 +90,119 @@ class TestIndicators: 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 {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/ios_backup/test_sms.py b/tests/ios_backup/test_sms.py index 03e4606b..5081a67e 100644 --- a/tests/ios_backup/test_sms.py +++ b/tests/ios_backup/test_sms.py @@ -29,3 +29,31 @@ class TestSMSModule: m.indicators = ind run_module(m) 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] From 84df51c518d987852700ccc0c080de6db4ac55f4 Mon Sep 17 00:00:00 2001 From: Volodymyr Styran Date: Mon, 27 Jul 2026 16:06:44 +0300 Subject: [PATCH 005/109] Scan Safari profile databases for history and browser state (#846) * fix(ios): scan Safari profile databases for history and browser state Safari profiles (iOS 17 and later) keep their own databases under Library/Safari/Profiles//, but SafariHistory and SafariBrowserState only ever looked at the default profile's Library/Safari/History.db and Library/Safari/BrowserState.db. On a device where browsing happens inside a profile, MVT silently skipped that history and still reported no detections, so an indicator only ever visited within a profile went unnoticed. Both modules now also match Library/Safari/Profiles/*/ in backups and in filesystem dumps. No helper changes were needed: the Manifest.db lookup already translates "*" into a SQL LIKE wildcard, and the filesystem lookup already globs. Found while examining an encrypted iOS 26.5.2 backup that contained 14 per-profile History.db files under AppDomain-com.apple.mobilesafari::Library/Safari/Profiles//. Co-Authored-By: Claude Opus 5 * fix(ios): scope Safari redirects to history database --------- Co-authored-by: Claude Opus 5 Co-authored-by: Janik Besendorf --- .../ios/modules/mixed/safari_browserstate.py | 35 ++-- src/mvt/ios/modules/mixed/safari_history.py | 33 +++- tests/ios_backup/test_safari_browserstate.py | 43 ++++- tests/ios_backup/test_safari_history.py | 165 ++++++++++++++++++ tests/utils.py | 18 ++ 5 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 tests/ios_backup/test_safari_history.py diff --git a/src/mvt/ios/modules/mixed/safari_browserstate.py b/src/mvt/ios/modules/mixed/safari_browserstate.py index 48998f1a..e67b7d37 100644 --- a/src/mvt/ios/modules/mixed/safari_browserstate.py +++ b/src/mvt/ios/modules/mixed/safari_browserstate.py @@ -19,10 +19,17 @@ 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", ] @@ -174,20 +181,22 @@ class SafariBrowserState(IOSExtraction): 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 9ba32ad5..4a50f022 100644 --- a/src/mvt/ios/modules/mixed/safari_history.py +++ b/src/mvt/ios/modules/mixed/safari_history.py @@ -17,10 +17,17 @@ 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", ] @@ -75,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 @@ -159,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/tests/ios_backup/test_safari_browserstate.py b/tests/ios_backup/test_safari_browserstate.py index fd7e62ea..fa2149d0 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: @@ -21,6 +53,15 @@ class TestSafariBrowserStateModule: assert len(m.timeline) == 1 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()) m.is_backup = True diff --git a/tests/ios_backup/test_safari_history.py b/tests/ios_backup/test_safari_history.py new file mode 100644 index 00000000..258b3526 --- /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/utils.py b/tests/utils.py index b29e069c..9ae433d7 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,23 @@ 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() + # MVT opens backup databases with immutable=1, which ignores any -wal file, + # so the new row has to be checkpointed into Manifest.db itself. + conn.execute("PRAGMA journal_mode=DELETE;") + conn.close() + + def delete_tmp_db_files(file_path): """ Remove Sqlite temporary files that appear on some platforms From 797411e1e50f53e1f4ad2e55ec1e2451dd354381 Mon Sep 17 00:00:00 2001 From: besendorf Date: Mon, 27 Jul 2026 17:58:34 +0200 Subject: [PATCH 006/109] Fix text tombstone crashing thread parsing (#848) --- .../android/artifacts/tombstone_crashes.py | 5 ++++ tests/android/test_artifact_tombstones.py | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/mvt/android/artifacts/tombstone_crashes.py b/src/mvt/android/artifacts/tombstone_crashes.py index 9a878aa1..d4888efc 100644 --- a/src/mvt/android/artifacts/tombstone_crashes.py +++ b/src/mvt/android/artifacts/tombstone_crashes.py @@ -206,6 +206,11 @@ class TombstoneCrashArtifact(AndroidArtifact): 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] diff --git a/tests/android/test_artifact_tombstones.py b/tests/android/test_artifact_tombstones.py index d1d5682f..e4e51cfa 100644 --- a/tests/android/test_artifact_tombstones.py +++ b/tests/android/test_artifact_tombstones.py @@ -42,6 +42,32 @@ class TestTombstoneCrashArtifact: assert len(tombstone_artifact.results) == 1 self.validate_tombstone_result(tombstone_artifact.results[0]) + 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() From 3eff0c550de2cc5a9e016eaf207d3589e8d9c05b Mon Sep 17 00:00:00 2001 From: besendorf Date: Tue, 28 Jul 2026 18:34:13 +0200 Subject: [PATCH 007/109] Handle mis-indented dumpsys receiver actions (#852) --- .../android/artifacts/dumpsys_receivers.py | 14 ++++++- .../test_artifact_dumpsys_receivers.py | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/mvt/android/artifacts/dumpsys_receivers.py b/src/mvt/android/artifacts/dumpsys_receivers.py index c7130ed2..b4379309 100644 --- a/src/mvt/android/artifacts/dumpsys_receivers.py +++ b/src/mvt/android/artifacts/dumpsys_receivers.py @@ -96,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 @@ -109,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/tests/android/test_artifact_dumpsys_receivers.py b/tests/android/test_artifact_dumpsys_receivers.py index e4bed62c..7875a52e 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") From 2dfe3cbcb19e40bf49ffa3b836e1696f9212327c Mon Sep 17 00:00:00 2001 From: besendorf Date: Tue, 28 Jul 2026 18:58:46 +0200 Subject: [PATCH 008/109] Fix module audit findings (#850) * Fix module audit findings * Always parse paired tombstones --- .../artifacts/dumpsys_battery_history.py | 29 +++++-- src/mvt/android/artifacts/dumpsys_dbinfo.py | 44 ++++------ src/mvt/android/cli.py | 6 +- src/mvt/android/cmd_check_androidqf.py | 13 ++- src/mvt/android/modules/androidqf/__init__.py | 2 + src/mvt/android/parsers/backup.py | 39 +++++---- src/mvt/ios/modules/base.py | 83 +++++++++++++----- src/mvt/ios/modules/fs/analytics.py | 2 + src/mvt/ios/modules/net_base.py | 7 +- .../test_artifact_dumpsys_battery_history.py | 19 +++++ tests/android/test_artifact_dumpsys_dbinfo.py | 19 +++++ tests/android/test_backup_parser.py | 11 +++ tests/ios_backup/test_calendar.py | 13 +++ tests/ios_backup/test_sqlite_handling.py | 85 +++++++++++++++++++ tests/test_check_android_androidqf.py | 30 +++++++ tests/test_check_android_bugreport.py | 10 +++ tests/utils.py | 3 +- 17 files changed, 333 insertions(+), 82 deletions(-) create mode 100644 tests/ios_backup/test_sqlite_handling.py diff --git a/src/mvt/android/artifacts/dumpsys_battery_history.py b/src/mvt/android/artifacts/dumpsys_battery_history.py index fb429030..77d9d3c1 100644 --- a/src/mvt/android/artifacts/dumpsys_battery_history.py +++ b/src/mvt/android/artifacts/dumpsys_battery_history.py @@ -31,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 a3a4eb1f..050582df 100644 --- a/src/mvt/android/artifacts/dumpsys_dbinfo.py +++ b/src/mvt/android/artifacts/dumpsys_dbinfo.py @@ -29,10 +29,9 @@ class DumpsysDBInfoArtifact(AndroidArtifact): 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 @@ -56,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/cli.py b/src/mvt/android/cli.py index 1123742a..2830c98f 100644 --- a/src/mvt/android/cli.py +++ b/src/mvt/android/cli.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ import logging +from zipfile import BadZipFile import click @@ -212,7 +213,10 @@ def check_bugreport( log.info("Checking Android bug report at path: %s", bugreport_path) - cmd.run() + 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() diff --git a/src/mvt/android/cmd_check_androidqf.py b/src/mvt/android/cmd_check_androidqf.py index 41ad802a..319b6149 100644 --- a/src/mvt/android/cmd_check_androidqf.py +++ b/src/mvt/android/cmd_check_androidqf.py @@ -296,6 +296,7 @@ class CmdAndroidCheckAndroidQF(Command): 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/") @@ -303,9 +304,15 @@ class CmdAndroidCheckAndroidQF(Command): if not relative or relative.endswith("/"): continue - target = os.path.join(temp_dir, relative) - os.makedirs(os.path.dirname(target), exist_ok=True) - with self.__zip.open(entry) as src, open(target, "wb") as dst: + 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 diff --git a/src/mvt/android/modules/androidqf/__init__.py b/src/mvt/android/modules/androidqf/__init__.py index 1803949a..1d8d6195 100644 --- a/src/mvt/android/modules/androidqf/__init__.py +++ b/src/mvt/android/modules/androidqf/__init__.py @@ -5,6 +5,7 @@ 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 @@ -17,6 +18,7 @@ ANDROIDQF_MODULES = [ AQFGetProp, AQFSettings, AQFFiles, + AQFLogTimestamps, RootBinaries, Mounts, ] diff --git a/src/mvt/android/parsers/backup.py b/src/mvt/android/parsers/backup.py index 8c9bb5c8..1f8da9b9 100644 --- a/src/mvt/android/parsers/backup.py +++ b/src/mvt/android/parsers/backup.py @@ -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( @@ -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( diff --git a/src/mvt/ios/modules/base.py b/src/mvt/ios/modules/base.py index 121bad50..a5c3354b 100644 --- a/src/mvt/ios/modules/base.py +++ b/src/mvt/ios/modules/base.py @@ -9,6 +9,7 @@ import os import shutil import sqlite3 import subprocess +import tempfile from pathlib import Path from typing import Iterator, Optional, Union @@ -20,6 +21,20 @@ from mvt.common.module import ( ) +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): """This class provides a base for all iOS filesystem/backup extraction modules.""" @@ -44,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 @@ -53,17 +70,12 @@ class IOSExtraction(MVTModule): :param file_path: Path to the malformed database file. """ - # SQLite's immutable mode cannot open databases with active WAL files. 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): @@ -82,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 @@ -166,7 +205,11 @@ class IOSExtraction(MVTModule): 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()): + 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 @@ -197,9 +240,9 @@ class IOSExtraction(MVTModule): :param backup_ids: Default value = None) """ - file_path: Optional[str] = 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 diff --git a/src/mvt/ios/modules/fs/analytics.py b/src/mvt/ios/modules/fs/analytics.py index 0a6e50cd..2bf29e46 100644 --- a/src/mvt/ios/modules/fs/analytics.py +++ b/src/mvt/ios/modules/fs/analytics.py @@ -134,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/net_base.py b/src/mvt/ios/modules/net_base.py index 99a851d9..eb13fdf3 100644 --- a/src/mvt/ios/modules/net_base.py +++ b/src/mvt/ios/modules/net_base.py @@ -42,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: @@ -237,9 +238,7 @@ class NetBase(IOSExtraction): "been truncated in the database)" ) - self.alertstore.medium( - msg, proc["live_isodate"], proc - ) + self.alertstore.medium(msg, proc["live_isodate"], proc) if not proc["live_proc_id"]: self.log.info( diff --git a/tests/android/test_artifact_dumpsys_battery_history.py b/tests/android/test_artifact_dumpsys_battery_history.py index 03d7d2ef..dd0b1201 100644 --- a/tests/android/test_artifact_dumpsys_battery_history.py +++ b/tests/android/test_artifact_dumpsys_battery_history.py @@ -42,3 +42,22 @@ class TestDumpsysBatteryHistoryArtifact: assert len(dba.alertstore.alerts) == 0 dba.check_indicators() 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 2becf650..691d43c4 100644 --- a/tests/android/test_artifact_dumpsys_dbinfo.py +++ b/tests/android/test_artifact_dumpsys_dbinfo.py @@ -40,3 +40,22 @@ class TestDumpsysDBinfoArtifact: assert len(dbi.alertstore.alerts) == 0 dbi.check_indicators() 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_backup_parser.py b/tests/android/test_backup_parser.py index 6e5be725..c0f867dd 100644 --- a/tests/android/test_backup_parser.py +++ b/tests/android/test_backup_parser.py @@ -5,7 +5,10 @@ import hashlib +import pytest + from mvt.android.parsers.backup import ( + AndroidBackupParsingError, parse_ab_header, parse_backup_file, parse_tar_for_sms, @@ -23,6 +26,14 @@ class TestBackupParsing: "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: diff --git a/tests/ios_backup/test_calendar.py b/tests/ios_backup/test_calendar.py index bf931f44..5f1035d6 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 @@ -21,6 +22,18 @@ class TestCalendarModule: 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): m = Calendar(target_path=get_ios_backup_folder()) ind = Indicators(log=logging.getLogger()) diff --git a/tests/ios_backup/test_sqlite_handling.py b/tests/ios_backup/test_sqlite_handling.py new file mode 100644 index 00000000..f7806401 --- /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/test_check_android_androidqf.py b/tests/test_check_android_androidqf.py index a2ed9527..9b271bed 100644 --- a/tests/test_check_android_androidqf.py +++ b/tests/test_check_android_androidqf.py @@ -6,10 +6,15 @@ 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 @@ -18,6 +23,9 @@ 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") @@ -89,3 +97,25 @@ class TestCheckAndroidqfCommand: 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_bugreport.py b/tests/test_check_android_bugreport.py index b13c38d2..bff47090 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/utils.py b/tests/utils.py index 9ae433d7..d84dc2fe 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -49,8 +49,7 @@ def add_backup_manifest_entry(backup_path, file_id, domain, relative_path): (file_id, domain, relative_path, b""), ) conn.commit() - # MVT opens backup databases with immutable=1, which ignores any -wal file, - # so the new row has to be checkpointed into Manifest.db itself. + # Checkpoint the test change so the fixture does not retain SQLite sidecars. conn.execute("PRAGMA journal_mode=DELETE;") conn.close() From f483223e235619d7ca346da328331e0de49166ea Mon Sep 17 00:00:00 2001 From: besendorf Date: Tue, 28 Jul 2026 18:59:58 +0200 Subject: [PATCH 009/109] Add iOS sysdiagnose checking (#832) * Add iOS sysdiagnose checking * Clarify documentation navigation --- docs/development.md | 8 +- docs/ios/sysdiagnose.md | 50 ++++++++ mkdocs.yml | 4 + src/mvt/common/help.py | 1 + src/mvt/common/module_loader.py | 9 +- src/mvt/ios/cli.py | 73 ++++++++++++ src/mvt/ios/cmd_check_sysdiagnose.py | 119 ++++++++++++++++++++ src/mvt/ios/modules/sysdiagnose/__init__.py | 6 + src/mvt/ios/modules/sysdiagnose/base.py | 105 +++++++++++++++++ tests/common/test_command.py | 10 +- tests/common/test_module_loader.py | 7 +- tests/test_check_ios_sysdiagnose.py | 57 ++++++++++ tests/test_cmd_check_sysdiagnose.py | 75 ++++++++++++ 13 files changed, 516 insertions(+), 8 deletions(-) create mode 100644 docs/ios/sysdiagnose.md create mode 100644 src/mvt/ios/cmd_check_sysdiagnose.py create mode 100644 src/mvt/ios/modules/sysdiagnose/__init__.py create mode 100644 src/mvt/ios/modules/sysdiagnose/base.py create mode 100644 tests/test_check_ios_sysdiagnose.py create mode 100644 tests/test_cmd_check_sysdiagnose.py diff --git a/docs/development.md b/docs/development.md index 93a612f2..ffe1265c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -82,14 +82,16 @@ class ExampleCustomModule(MVTModule): return None ``` -Use `supported_commands` to restrict a module to specific platform/command -pairs. Missing or empty `supported_commands` means the module is available to -all commands, which keeps older modules compatible. Supported pairs are: +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") diff --git a/docs/ios/sysdiagnose.md b/docs/ios/sysdiagnose.md new file mode 100644 index 00000000..6488bc24 --- /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/mkdocs.yml b/mkdocs.yml index d34c3fac..bf63bc02 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,7 @@ nav: - Welcome: "index.md" - Introduction: "introduction.md" - Installation: "install.md" + - Command Completion: "command_completion.md" - Using Docker: "docker.md" - MVT for iOS: - iOS Forensic Methodology: "ios/methodology.md" @@ -39,6 +42,7 @@ 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" diff --git a/src/mvt/common/help.py b/src/mvt/common/help.py index 90e71ca9..5101f939 100644 --- a/src/mvt/common/help.py +++ b/src/mvt/common/help.py @@ -38,6 +38,7 @@ 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_ANDROID_BACKUP_PASSWORD = "The backup password to use for an Android backup" diff --git a/src/mvt/common/module_loader.py b/src/mvt/common/module_loader.py index 890ed0fe..da948265 100644 --- a/src/mvt/common/module_loader.py +++ b/src/mvt/common/module_loader.py @@ -6,6 +6,7 @@ import hashlib import importlib.util import inspect +import logging import os import sys from pathlib import Path @@ -15,6 +16,7 @@ from typing import Iterable, Optional from .module import MVTModule MVT_CUSTOM_MODULES_ENV = "MVT_CUSTOM_MODULES" +log = logging.getLogger(__name__) class CustomModuleLoadError(Exception): @@ -122,6 +124,11 @@ def module_supports_command( ) -> bool: supported_commands = getattr(module_class, "supported_commands", None) if not supported_commands: - return True + 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/ios/cli.py b/src/mvt/ios/cli.py index 3f4beef3..0a9d1eaa 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -42,6 +42,7 @@ from mvt.common.help import ( 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, @@ -50,6 +51,7 @@ 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 @@ -398,6 +400,77 @@ def check_fs( cmd.show_support_message() +# ============================================================================== +# 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 # ============================================================================== diff --git a/src/mvt/ios/cmd_check_sysdiagnose.py b/src/mvt/ios/cmd_check_sysdiagnose.py new file mode 100644 index 00000000..6ceea6f0 --- /dev/null +++ b/src/mvt/ios/cmd_check_sysdiagnose.py @@ -0,0 +1,119 @@ +# 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 tarfile +from pathlib import Path +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]] = [] + + @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") + for member in self.sysdiagnose_archive: + self.sysdiagnose_files.append(member.name) + if member.isfile() and member.name.endswith(".ips"): + archive_handle = self.sysdiagnose_archive.extractfile(member) + if archive_handle is not None: + with archive_handle: + self._add_ips_file(member.name, archive_handle.read()) + + def module_init(self, module) -> None: + module.ips_files = self.ips_files + if self.sysdiagnose_format == "tar": + if self.sysdiagnose_archive is None: + raise RuntimeError("Sysdiagnose archive has not been initialized") + module.from_sysdiagnose_tar( + self.sysdiagnose_archive, 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 diff --git a/src/mvt/ios/modules/sysdiagnose/__init__.py b/src/mvt/ios/modules/sysdiagnose/__init__.py new file mode 100644 index 00000000..3963ca54 --- /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 00000000..99187c9f --- /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/tests/common/test_command.py b/tests/common/test_command.py index 865f9f16..fa90a3eb 100644 --- a/tests/common/test_command.py +++ b/tests/common/test_command.py @@ -50,6 +50,10 @@ class CustomIOSFSModule(RecordingModule): supported_commands = (("ios", "check-fs"),) +class UnscopedCustomModule(RecordingModule): + pass + + class CustomDependsOnBuiltin(RecordingModule): supported_commands = (("ios", "check-backup"),) dependencies = (FirstModule,) @@ -151,7 +155,11 @@ class TestCommand: cmd.platform = "ios" cmd.name = "check-backup" cmd.modules = [FirstModule] - cmd.custom_modules = [CustomIOSBackupModule, CustomIOSFSModule] + cmd.custom_modules = [ + CustomIOSBackupModule, + CustomIOSFSModule, + UnscopedCustomModule, + ] assert [module.__name__ for module in cmd._ordered_modules()] == [ "FirstModule", diff --git a/tests/common/test_module_loader.py b/tests/common/test_module_loader.py index ce134270..15b60525 100644 --- a/tests/common/test_module_loader.py +++ b/tests/common/test_module_loader.py @@ -125,12 +125,13 @@ def test_load_custom_modules_loads_env_folder_first(tmp_path, monkeypatch): assert [module.__name__ for module in modules] == ["EnvModule", "CliModule"] -def test_module_supports_command_defaults_to_all_commands(tmp_path): +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 module_supports_command(module, "ios", "check-backup") - assert module_supports_command(module, "android", "check-bugreport") + 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): diff --git a/tests/test_check_ios_sysdiagnose.py b/tests/test_check_ios_sysdiagnose.py new file mode 100644 index 00000000..d73c0ffb --- /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 00000000..9f792dee --- /dev/null +++ b/tests/test_cmd_check_sysdiagnose.py @@ -0,0 +1,75 @@ +import tarfile +from datetime import timedelta + +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": "sysdiagnose/report.ips", "bug_type": 210} + ] + assert command.sysdiagnose_archive is None From 8140e350f7544915f3a6bbff24741c815dddd63d Mon Sep 17 00:00:00 2001 From: Rory Flynn <75283103+roaree@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:57:34 +0200 Subject: [PATCH 010/109] Update version.py (#854) --- src/mvt/common/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mvt/common/version.py b/src/mvt/common/version.py index d3fc913f..37d62f1e 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 = "2026.5.12" +MVT_VERSION = "2026.6.29" From 53fb12aee869f2365c972d54900c6b6250f0080d Mon Sep 17 00:00:00 2001 From: Rory Flynn <75283103+roaree@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:08:42 +0200 Subject: [PATCH 011/109] Correct version.py (#855) --- src/mvt/common/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mvt/common/version.py b/src/mvt/common/version.py index 37d62f1e..c8e77161 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 = "2026.6.29" +MVT_VERSION = "2026.7.29" From 8617e0bf54f1025de2945d8290e61d0a65292c22 Mon Sep 17 00:00:00 2001 From: besendorf Date: Wed, 5 Aug 2026 17:36:49 +0200 Subject: [PATCH 012/109] Alert on AndroidQF trusted ADB keys (#860) --- src/mvt/android/cmd_check_androidqf.py | 42 ++++++ .../modules/bugreport/dumpsys_adb_state.py | 123 ++++++++++++++++++ tests/android/test_artifact_dumpsys_adb.py | 118 +++++++++++++++++ tests/test_check_android_androidqf.py | 44 +++++++ 4 files changed, 327 insertions(+) diff --git a/src/mvt/android/cmd_check_androidqf.py b/src/mvt/android/cmd_check_androidqf.py index 319b6149..6f1089ce 100644 --- a/src/mvt/android/cmd_check_androidqf.py +++ b/src/mvt/android/cmd_check_androidqf.py @@ -3,6 +3,7 @@ # 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 @@ -94,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) diff --git a/src/mvt/android/modules/bugreport/dumpsys_adb_state.py b/src/mvt/android/modules/bugreport/dumpsys_adb_state.py index 506af30d..06b29d5b 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_adb_state.py +++ b/src/mvt/android/modules/bugreport/dumpsys_adb_state.py @@ -3,11 +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 @@ -53,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/tests/android/test_artifact_dumpsys_adb.py b/tests/android/test_artifact_dumpsys_adb.py index 47dc3dfc..f5574a67 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 @@ -114,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/test_check_android_androidqf.py b/tests/test_check_android_androidqf.py index 9b271bed..a8a6a5a8 100644 --- a/tests/test_check_android_androidqf.py +++ b/tests/test_check_android_androidqf.py @@ -3,6 +3,7 @@ # 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 @@ -32,6 +33,49 @@ class TestCheckAndroidqfCommand: result = runner.invoke(check_androidqf, [path]) assert result.exit_code == 0 + 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( From 93b7fb52322b4e98fd9f9251e5180432feb7cda5 Mon Sep 17 00:00:00 2001 From: besendorf Date: Wed, 5 Aug 2026 23:21:14 +0200 Subject: [PATCH 013/109] Store message URLs in analysis output (#856) --- docs/android/backup.md | 6 +++++ docs/ios/records.md | 9 +++++++ src/mvt/android/cmd_check_androidqf.py | 3 +++ src/mvt/android/modules/backup/sms.py | 5 ++++ src/mvt/common/command.py | 13 +++++++++- src/mvt/common/indicators.py | 15 ++++++++++- src/mvt/common/module.py | 28 +++++++++++++++++++++ src/mvt/common/module_types.py | 9 ++++++- src/mvt/ios/modules/mixed/sms.py | 5 ++++ src/mvt/ios/modules/mixed/whatsapp.py | 5 ++++ tests/common/test_command.py | 24 ++++++++++++++++++ tests/common/test_indicators.py | 9 +++++++ tests/ios_backup/test_sms.py | 8 ++++++ tests/ios_backup/test_whatsapp.py | 35 ++++++++++++++++++++++++++ tests/test_check_android_androidqf.py | 18 +++++++++++++ 15 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 tests/ios_backup/test_whatsapp.py diff --git a/docs/android/backup.md b/docs/android/backup.md index 81344aa1..fae0d176 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/ios/records.md b/docs/ios/records.md index 17be1929..016c8618 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/src/mvt/android/cmd_check_androidqf.py b/src/mvt/android/cmd_check_androidqf.py index 6f1089ce..ffa3e13a 100644 --- a/src/mvt/android/cmd_check_androidqf.py +++ b/src/mvt/android/cmd_check_androidqf.py @@ -262,6 +262,7 @@ class CmdAndroidCheckAndroidQF(Command): cmd.run() self.timeline.extend(cmd.timeline) + self.url_results.extend(cmd.url_results) self.alertstore.extend(cmd.alertstore.alerts) finally: if bugreport: @@ -299,6 +300,7 @@ class CmdAndroidCheckAndroidQF(Command): cmd.run() self.timeline.extend(cmd.timeline) + self.url_results.extend(cmd.url_results) self.alertstore.extend(cmd.alertstore.alerts) return True @@ -378,6 +380,7 @@ class CmdAndroidCheckAndroidQF(Command): cmd.run() self.timeline.extend(cmd.timeline) + self.url_results.extend(cmd.url_results) self.alertstore.extend(cmd.alertstore.alerts) return True diff --git a/src/mvt/android/modules/backup/sms.py b/src/mvt/android/modules/backup/sms.py index c42e9a46..1c73ea22 100644 --- a/src/mvt/android/modules/backup/sms.py +++ b/src/mvt/android/modules/backup/sms.py @@ -57,6 +57,11 @@ class SMS(BackupModule): 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" for file in self._get_files_by_pattern(sms_path): diff --git a/src/mvt/common/command.py b/src/mvt/common/command.py index 8d21aa32..aab53e65 100644 --- a/src/mvt/common/command.py +++ b/src/mvt/common/command.py @@ -20,7 +20,7 @@ from .config import settings from .indicators import Indicators from .module import EncryptedBackupError, MVTModule, run_module, save_timeline from .module_loader import module_supports_command -from .module_types import ModuleTimeline +from .module_types import ModuleTimeline, URLResult from .utils import ( CustomJSONEncoder, convert_datetime_to_iso, @@ -73,6 +73,7 @@ class Command: self.hashes = hashes self.hash_values: list[dict[str, Any]] = [] self.timeline: ModuleTimeline = [] + self.url_results: list[URLResult] = [] # Load IOCs self._create_storage() @@ -150,6 +151,14 @@ class Command: 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 @@ -396,6 +405,7 @@ class Command: self.executed.append(m) executed_by_type[module] = m self.timeline.extend(m.timeline) + self.url_results.extend(m.url_results) self.alertstore.extend(m.alertstore.alerts) try: @@ -410,4 +420,5 @@ class Command: self._store_timeline() self._store_alerts_timeline() self._store_alerts() + self._store_urls() self._store_info() diff --git a/src/mvt/common/indicators.py b/src/mvt/common/indicators.py index 7186ae36..94afadf1 100644 --- a/src/mvt/common/indicators.py +++ b/src/mvt/common/indicators.py @@ -49,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): @@ -439,9 +440,14 @@ 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 @@ -482,6 +488,13 @@ class Indicators: return 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. diff --git a/src/mvt/common/module.py b/src/mvt/common/module.py index 14c5fe58..cd127a60 100644 --- a/src/mvt/common/module.py +++ b/src/mvt/common/module.py @@ -18,6 +18,7 @@ from .module_types import ( ModuleResults, ModuleSerializedResult, ModuleTimeline, + URLResult, ) from .utils import CustomJSONEncoder, exec_or_profile @@ -82,6 +83,7 @@ class MVTModule: 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( @@ -110,6 +112,23 @@ class MVTModule: def check_indicators(self) -> None: 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: if not self.results_path: return @@ -249,6 +268,15 @@ def run_module(module: MVTModule) -> None: "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: diff --git a/src/mvt/common/module_types.py b/src/mvt/common/module_types.py index 06fdc12e..a47ffe4f 100644 --- a/src/mvt/common/module_types.py +++ b/src/mvt/common/module_types.py @@ -4,7 +4,7 @@ # https://license.mvt.re/1.1/ from dataclasses import dataclass -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Optional, TypedDict, Union # ModuleAtomicResult is a flexible dictionary that can contain any data. @@ -22,6 +22,13 @@ ModuleAtomicResult = Dict[str, Any] ModuleResults = Any +class URLResult(TypedDict): + url: str + expanded_url: Optional[str] + timestamp: Optional[str] + source: str + + @dataclass class ModuleAtomicTimeline: timestamp: str diff --git a/src/mvt/ios/modules/mixed/sms.py b/src/mvt/ios/modules/mixed/sms.py index 596ba471..d1afd061 100644 --- a/src/mvt/ios/modules/mixed/sms.py +++ b/src/mvt/ios/modules/mixed/sms.py @@ -101,6 +101,11 @@ class SMS(IOSExtraction): 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) diff --git a/src/mvt/ios/modules/mixed/whatsapp.py b/src/mvt/ios/modules/mixed/whatsapp.py index cf3a49b4..0a80aad6 100644 --- a/src/mvt/ios/modules/mixed/whatsapp.py +++ b/src/mvt/ios/modules/mixed/whatsapp.py @@ -70,6 +70,11 @@ class Whatsapp(IOSExtraction): 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( backup_ids=WHATSAPP_BACKUP_IDS, root_paths=WHATSAPP_ROOT_PATHS diff --git a/tests/common/test_command.py b/tests/common/test_command.py index fa90a3eb..4dbfe1a9 100644 --- a/tests/common/test_command.py +++ b/tests/common/test_command.py @@ -42,6 +42,15 @@ 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"),) @@ -87,6 +96,21 @@ class TestCommand: 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] diff --git a/tests/common/test_indicators.py b/tests/common/test_indicators.py index bbb55b2b..ac50f82b 100644 --- a/tests/common/test_indicators.py +++ b/tests/common/test_indicators.py @@ -197,6 +197,15 @@ class TestIndicators: 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", diff --git a/tests/ios_backup/test_sms.py b/tests/ios_backup/test_sms.py index 5081a67e..64646f49 100644 --- a/tests/ios_backup/test_sms.py +++ b/tests/ios_backup/test_sms.py @@ -18,6 +18,14 @@ class TestSMSModule: run_module(m) assert len(m.results) == 1 assert len(m.timeline) == 2 + 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): diff --git a/tests/ios_backup/test_whatsapp.py b/tests/ios_backup/test_whatsapp.py new file mode 100644 index 00000000..864fb849 --- /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/test_check_android_androidqf.py b/tests/test_check_android_androidqf.py index a8a6a5a8..2253a509 100644 --- a/tests/test_check_android_androidqf.py +++ b/tests/test_check_android_androidqf.py @@ -33,6 +33,24 @@ class TestCheckAndroidqfCommand: 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() From 067f053627630b1b2d06272c787bd1cf080f5f75 Mon Sep 17 00:00:00 2001 From: besendorf Date: Wed, 5 Aug 2026 23:30:28 +0200 Subject: [PATCH 014/109] Add extensible CLI commands (#853) * Add extensible CLI commands * Handle plugin SystemExit failures --- README.md | 5 + docs/custom_commands.md | 107 +++++++++ mkdocs.yml | 1 + src/mvt/android/cli.py | 14 ++ src/mvt/common/cli_plugins.py | 297 +++++++++++++++++++++++++ src/mvt/ios/cli.py | 14 ++ tests/common/test_cli_plugins.py | 371 +++++++++++++++++++++++++++++++ 7 files changed, 809 insertions(+) create mode 100644 docs/custom_commands.md create mode 100644 src/mvt/common/cli_plugins.py create mode 100644 tests/common/test_cli_plugins.py diff --git a/README.md b/README.md index adcee7ac..65fcdeee 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,11 @@ Module-running `check-*` commands can load custom Python modules with [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/custom_commands.md b/docs/custom_commands.md new file mode 100644 index 00000000..ab89fd90 --- /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/mkdocs.yml b/mkdocs.yml index bf63bc02..66f3471c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - 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" diff --git a/src/mvt/android/cli.py b/src/mvt/android/cli.py index 2830c98f..ca7e3dc6 100644 --- a/src/mvt/android/cli.py +++ b/src/mvt/android/cli.py @@ -8,6 +8,12 @@ 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, @@ -82,6 +88,7 @@ def _load_custom_modules(load_module): # 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 ) @@ -505,3 +512,10 @@ def check_iocs(ctx, iocs, list_modules, module, load_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/common/cli_plugins.py b/src/mvt/common/cli_plugins.py new file mode 100644 index 00000000..acf4c6dc --- /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/ios/cli.py b/src/mvt/ios/cli.py index 0a9d1eaa..c338fa54 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -8,6 +8,12 @@ import logging import os import click +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, @@ -86,6 +92,7 @@ def _load_custom_modules(load_module): # 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 ) @@ -523,3 +530,10 @@ def check_iocs(ctx, iocs, list_modules, module, load_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/tests/common/test_cli_plugins.py b/tests/common/test_cli_plugins.py new file mode 100644 index 00000000..ea7c7fa7 --- /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"} From 0b48d9fe1d64a2e69c6d4bbedbfe6fc85d5b65c0 Mon Sep 17 00:00:00 2001 From: besendorf Date: Fri, 7 Aug 2026 09:07:33 +0200 Subject: [PATCH 015/109] Speed up compressed sysdiagnose analysis (#861) * Speed up compressed sysdiagnose analysis * ci: retrigger Ruff check --- src/mvt/ios/cmd_check_sysdiagnose.py | 78 +++++++++++++++++++++++----- tests/test_cmd_check_sysdiagnose.py | 44 +++++++++++++++- 2 files changed, 109 insertions(+), 13 deletions(-) diff --git a/src/mvt/ios/cmd_check_sysdiagnose.py b/src/mvt/ios/cmd_check_sysdiagnose.py index 6ceea6f0..05d5b6b7 100644 --- a/src/mvt/ios/cmd_check_sysdiagnose.py +++ b/src/mvt/ios/cmd_check_sysdiagnose.py @@ -6,8 +6,10 @@ import json import logging import os +import shutil import tarfile -from pathlib import Path +from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory from typing import Any, Optional from mvt.common.command import Command @@ -54,6 +56,8 @@ class CmdIOSCheckSysdiagnose(Command): 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]: @@ -91,21 +95,68 @@ class CmdIOSCheckSysdiagnose(Command): 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") - for member in self.sysdiagnose_archive: - self.sysdiagnose_files.append(member.name) - if member.isfile() and member.name.endswith(".ips"): - archive_handle = self.sysdiagnose_archive.extractfile(member) - if archive_handle is not None: - with archive_handle: - self._add_ips_file(member.name, archive_handle.read()) + 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.sysdiagnose_archive is None: - raise RuntimeError("Sysdiagnose archive has not been initialized") - module.from_sysdiagnose_tar( - self.sysdiagnose_archive, self.sysdiagnose_files + 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: @@ -117,3 +168,6 @@ class CmdIOSCheckSysdiagnose(Command): 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/tests/test_cmd_check_sysdiagnose.py b/tests/test_cmd_check_sysdiagnose.py index 9f792dee..67f126bd 100644 --- a/tests/test_cmd_check_sysdiagnose.py +++ b/tests/test_cmd_check_sysdiagnose.py @@ -1,5 +1,7 @@ +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 @@ -70,6 +72,46 @@ def test_check_sysdiagnose_from_archive_closes_archive(tmp_path): {"content": "artifact", "timezone_offset": timedelta(hours=2).seconds} ] assert command.executed[0].ips_files == [ - {"file_path": "sysdiagnose/report.ips", "bug_type": 210} + { + "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() From d92a60c9bea7495c99fc3cb2758b7710833b4d03 Mon Sep 17 00:00:00 2001 From: besendorf Date: Mon, 10 Aug 2026 20:59:56 +0200 Subject: [PATCH 016/109] Preserve tombstone crash causes (#863) --- .../android/artifacts/tombstone_crashes.py | 3 ++ tests/android/test_artifact_tombstones.py | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/mvt/android/artifacts/tombstone_crashes.py b/src/mvt/android/artifacts/tombstone_crashes.py index d4888efc..bcfb3899 100644 --- a/src/mvt/android/artifacts/tombstone_crashes.py +++ b/src/mvt/android/artifacts/tombstone_crashes.py @@ -34,6 +34,7 @@ TOMBSTONE_TEXT_KEY_MAPPINGS = { "signal": "signal_info", "code": "code", "Cause": "cause", + "Abort message": "abort_message", } @@ -67,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 diff --git a/tests/android/test_artifact_tombstones.py b/tests/android/test_artifact_tombstones.py index e4e51cfa..f88a9c26 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,47 @@ 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" From 8123db26d9403cbfafb3f0cc00ef35c2865f5a1c Mon Sep 17 00:00:00 2001 From: besendorf Date: Mon, 10 Aug 2026 22:06:15 +0200 Subject: [PATCH 017/109] Fix iOS update workflow and refresh dependencies (#864) --- .github/workflows/update-ios-data.yml | 8 +- pyproject.toml | 26 +- src/mvt/ios/modules/mixed/calls.py | 4 +- uv.lock | 1629 ++++++++++++++----------- 4 files changed, 909 insertions(+), 758 deletions(-) diff --git a/.github/workflows/update-ios-data.yml b/.github/workflows/update-ios-data.yml index 5df9cfaf..ed0388cd 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@v6 + - uses: actions/setup-python@v6 + - 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/pyproject.toml b/pyproject.toml index e029a537..151d4e79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,25 +17,25 @@ classifiers = [ "Programming Language :: Python", ] dependencies = [ - "click==8.3.3", - "rich==14.3.3", + "click==8.4.2", + "rich==15.0.0", "tld==0.13.2", - "requests==2.33.1", - "simplejson==3.20.2", - "packaging==26.2", + "requests==2.34.2", + "simplejson==4.1.1", + "packaging==26.3", "appdirs==1.4.4", "iOSbackup==0.9.925", "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.3.1", - "betterproto2==0.9.1", - "pydantic==2.13.3", - "pydantic-settings==2.14.0", + "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.2", + "tzdata==2026.3", ] requires-python = ">= 3.10" @@ -65,9 +65,9 @@ dev = [ docs = [ "mkdocs==1.6.1", "mkdocs-autorefs==1.4.4", - "mkdocs-material==9.7.6", + "mkdocs-material==9.7.7", "mkdocs-material-extensions==1.3.1", - "mkdocstrings==1.0.4", + "mkdocstrings==1.0.6", ] [build-system] diff --git a/src/mvt/ios/modules/mixed/calls.py b/src/mvt/ios/modules/mixed/calls.py index 197f8bf9..cee58624 100644 --- a/src/mvt/ios/modules/mixed/calls.py +++ b/src/mvt/ios/modules/mixed/calls.py @@ -73,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/uv.lock b/uv.lock index f5212373..6c6db047 100644 --- a/uv.lock +++ b/uv.lock @@ -24,11 +24,11 @@ usb = [ [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +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/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { 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]] @@ -49,6 +49,70 @@ 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" @@ -60,33 +124,33 @@ wheels = [ [[package]] name = "backrefs" -version = "7.0" +version = "8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +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/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, - { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, - { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, + { 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.9.1" +version = "0.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/02/4ae507aa18b8aa1681ea6e9f0dec08fabd610a94ade584fd9b071d286bf1/betterproto2-0.9.1.tar.gz", hash = "sha256:59d518cfbdd316e216c3d7f53448a6956432c7bd86ab9918fd8793c88c623cce", size = 143980, upload-time = "2025-10-15T09:36:24.425Z" } +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/3e/a3/44f9d1188ee3802d2402efb121e7a9ce4c825eaa34ca739fbfb8979526a2/betterproto2-0.9.1-py3-none-any.whl", hash = "sha256:de0544b4b2b6b5a05ce0c1bfac36d232f745087b02907e748f31466becfc3dbd", size = 19562, upload-time = "2025-10-15T09:36:23.169Z" }, + { 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.0" +version = "0.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "betterproto2" }, @@ -95,217 +159,227 @@ dependencies = [ { name = "strenum", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/38/cdac9ca1cde9bf8a213bc7972985565b381d4faa2214ad097f8f614977b2/betterproto2_compiler-0.10.0.tar.gz", hash = "sha256:063179e0b6732e04f792e8317ed5d2cbfa069cac4eff42a3734f203a51e47599", size = 54682, upload-time = "2026-05-10T13:03:32.821Z" } +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/9e/7e/a7f247847253983b1adbd084bbb4789ee25dd14e2a7601eb5c7aca531e19/betterproto2_compiler-0.10.0-py3-none-any.whl", hash = "sha256:075252253ca4f730b664546e2c91ab33557a98bb98141f9554ed5621eb8d25a7", size = 66884, upload-time = "2026-05-10T13:03:31.366Z" }, + { 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.4.22" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +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/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { 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.0.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +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/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, + { 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.7" +version = "3.4.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +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/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { 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.3.3" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { 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]] @@ -319,115 +393,130 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.5" +version = "7.15.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +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/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, - { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, - { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, - { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, - { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, - { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, + { 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] @@ -437,62 +526,59 @@ toml = [ [[package]] name = "cryptography" -version = "47.0.0" +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/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +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/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, - { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, - { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, - { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, + { 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]] @@ -500,7 +586,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { 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 = [ @@ -521,11 +607,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +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/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { 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]] @@ -564,119 +650,164 @@ wheels = [ [[package]] name = "librt" -version = "0.9.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } +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/f3/4a/c64265d71b84030174ff3ac2cd16d8b664072afab8c41fccd8e2ee5a6f8d/librt-0.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f8e12706dcb8ff6b3ed57514a19e45c49ad00bcd423e87b2b2e4b5f64578443", size = 67529, upload-time = "2026-04-09T16:04:27.373Z" }, - { url = "https://files.pythonhosted.org/packages/23/b1/30ca0b3a8bdac209a00145c66cf42e5e7da2cc056ffc6ebc5c7b430ddd34/librt-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e3dda8345307fd7306db0ed0cb109a63a2c85ba780eb9dc2d09b2049a931f9c", size = 70248, upload-time = "2026-04-09T16:04:28.758Z" }, - { url = "https://files.pythonhosted.org/packages/fa/fc/c6018dc181478d6ac5aa24a5846b8185101eb90894346db239eb3ea53209/librt-0.9.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:de7dac64e3eb832ffc7b840eb8f52f76420cde1b845be51b2a0f6b870890645e", size = 202184, upload-time = "2026-04-09T16:04:29.893Z" }, - { url = "https://files.pythonhosted.org/packages/bf/58/d69629f002203370ef41ea69ff71c49a2c618aec39b226ff49986ecd8623/librt-0.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22a904cbdb678f7cb348c90d543d3c52f581663d687992fee47fd566dcbf5285", size = 212926, upload-time = "2026-04-09T16:04:31.126Z" }, - { url = "https://files.pythonhosted.org/packages/cc/55/01d859f57824e42bd02465c77bec31fa5ef9d8c2bcee702ccf8ef1b9f508/librt-0.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224b9727eb8bc188bc3bcf29d969dba0cd61b01d9bac80c41575520cc4baabb2", size = 225664, upload-time = "2026-04-09T16:04:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/9b/02/32f63ad0ef085a94a70315291efe1151a48b9947af12261882f8445b2a30/librt-0.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e94cbc6ad9a6aeea46d775cbb11f361022f778a9cc8cc90af653d3a594b057ce", size = 219534, upload-time = "2026-04-09T16:04:33.667Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5a/9d77111a183c885acf3b3b6e4c00f5b5b07b5817028226499a55f1fedc59/librt-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7bc30ad339f4e1a01d4917d645e522a0bc0030644d8973f6346397c93ba1503f", size = 227322, upload-time = "2026-04-09T16:04:34.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/05d700c93063753e12ab230b972002a3f8f3b9c95d8a980c2f646c8b6963/librt-0.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:56d65b583cf43b8cf4c8fbe1e1da20fa3076cc32a1149a141507af1062718236", size = 223407, upload-time = "2026-04-09T16:04:36.22Z" }, - { url = "https://files.pythonhosted.org/packages/c0/26/26c3124823c67c987456977c683da9a27cc874befc194ddcead5f9988425/librt-0.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0a1be03168b2691ba61927e299b352a6315189199ca18a57b733f86cb3cc8d38", size = 221302, upload-time = "2026-04-09T16:04:37.62Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/c7cc2be5cf4ff7b017d948a789256288cb33a517687ff1995e72a7eea79f/librt-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63c12efcd160e1d14da11af0c46c0217473e1e0d2ae1acbccc83f561ea4c2a7b", size = 243893, upload-time = "2026-04-09T16:04:38.909Z" }, - { url = "https://files.pythonhosted.org/packages/62/d3/da553d37417a337d12660450535d5fd51373caffbedf6962173c87867246/librt-0.9.0-cp310-cp310-win32.whl", hash = "sha256:e9002e98dcb1c0a66723592520decd86238ddcef168b37ff6cfb559200b4b774", size = 55375, upload-time = "2026-04-09T16:04:40.148Z" }, - { url = "https://files.pythonhosted.org/packages/9b/5a/46fa357bab8311b6442a83471591f2f9e5b15ecc1d2121a43725e0c529b8/librt-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9fcb461fbf70654a52a7cc670e606f04449e2374c199b1825f754e16dacfedd8", size = 62581, upload-time = "2026-04-09T16:04:41.452Z" }, - { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155, upload-time = "2026-04-09T16:04:42.933Z" }, - { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916, upload-time = "2026-04-09T16:04:44.042Z" }, - { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635, upload-time = "2026-04-09T16:04:45.5Z" }, - { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051, upload-time = "2026-04-09T16:04:47.016Z" }, - { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031, upload-time = "2026-04-09T16:04:48.207Z" }, - { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069, upload-time = "2026-04-09T16:04:50.025Z" }, - { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857, upload-time = "2026-04-09T16:04:51.684Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865, upload-time = "2026-04-09T16:04:52.949Z" }, - { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451, upload-time = "2026-04-09T16:04:54.174Z" }, - { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300, upload-time = "2026-04-09T16:04:55.452Z" }, - { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668, upload-time = "2026-04-09T16:04:56.689Z" }, - { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976, upload-time = "2026-04-09T16:04:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502, upload-time = "2026-04-09T16:04:58.806Z" }, - { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, - { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, - { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, - { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, - { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, - { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, - { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, - { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, - { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, - { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, - { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, - { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, - { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, - { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, - { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, - { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, - { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, - { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, - { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, - { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, + { 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.3.1" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/7f/c59ad56d1bca8fa4321d1bb77ba4687775751a4deceec14943a44da18ca0/libusb1-3.3.1.tar.gz", hash = "sha256:3951d360f2daf0e0eacf839e15d2d1d2f4f5e7830231eb3188eeffef2dd17bad", size = 107600, upload-time = "2025-03-24T05:36:47.834Z" } +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/10/f7/4577cfc55c9520ecab5563173e83235382ac7980c8c2c08d6c9f7ef9e615/libusb1-3.3.1-py3-none-any.whl", hash = "sha256:808c9362299dcee01651aa87e71e9d681ccedb27fc4dbd70aaf14e245fb855f1", size = 67243, upload-time = "2025-03-24T05:36:42.312Z" }, - { url = "https://files.pythonhosted.org/packages/6a/60/d3fd4831c601f063a16fc59f465ef4c1108247b07fbff371a982bd1bac45/libusb1-3.3.1-py3-none-win32.whl", hash = "sha256:0ef69825173ce74af34444754c081cc324233edc6acc405658b3ad784833e076", size = 129576, upload-time = "2025-03-24T05:36:45.202Z" }, - { url = "https://files.pythonhosted.org/packages/94/6d/344a164d32d65d503ffe9201cd74cf13a020099dc446554d1e50b07f167b/libusb1-3.3.1-py3-none-win_amd64.whl", hash = "sha256:6e21b772d80d6487fbb55d3d2141218536db302da82f1983754e96c72781c102", size = 141080, upload-time = "2025-03-24T05:36:46.594Z" }, + { 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.2" +version = "3.10.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +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/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { 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.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +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/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { 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]] @@ -836,7 +967,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.7.6" +version = "9.7.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -851,9 +982,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +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/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, + { 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]] @@ -867,7 +998,7 @@ wheels = [ [[package]] name = "mkdocstrings" -version = "1.0.4" +version = "1.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -877,9 +1008,9 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } +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/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, + { 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]] @@ -934,23 +1065,23 @@ docs = [ requires-dist = [ { name = "adb-shell", extras = ["usb"], specifier = "==0.4.4" }, { name = "appdirs", specifier = "==1.4.4" }, - { name = "betterproto2", specifier = "==0.9.1" }, - { name = "click", specifier = "==8.3.3" }, - { name = "cryptography", specifier = "==47.0.0" }, + { name = "betterproto2", specifier = "==0.10.0" }, + { name = "click", specifier = "==8.4.2" }, + { name = "cryptography", specifier = "==50.0.0" }, { name = "iosbackup", specifier = "==0.9.925" }, - { name = "libusb1", specifier = "==3.3.1" }, + { name = "libusb1", specifier = "==3.4.0" }, { name = "nskeyedunarchiver", specifier = "==1.5.2" }, - { name = "packaging", specifier = "==26.2" }, + { name = "packaging", specifier = "==26.3" }, { name = "pyahocorasick", specifier = "==2.3.1" }, - { name = "pydantic", specifier = "==2.13.3" }, - { name = "pydantic-settings", specifier = "==2.14.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.33.1" }, - { name = "rich", specifier = "==14.3.3" }, - { name = "simplejson", specifier = "==3.20.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.2" }, + { name = "tzdata", specifier = "==2026.3" }, ] [package.metadata.requires-dev] @@ -971,67 +1102,69 @@ dev = [ docs = [ { name = "mkdocs", specifier = "==1.6.1" }, { name = "mkdocs-autorefs", specifier = "==1.4.4" }, - { name = "mkdocs-material", specifier = "==9.7.6" }, + { name = "mkdocs-material", specifier = "==9.7.7" }, { name = "mkdocs-material-extensions", specifier = "==1.3.1" }, - { name = "mkdocstrings", specifier = "==1.0.4" }, + { name = "mkdocstrings", specifier = "==1.0.6" }, ] [[package]] name = "mypy" -version = "1.20.2" +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/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +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/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, - { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, - { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, - { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, - { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, - { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, - { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, - { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, - { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, - { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, - { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, - { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, - { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, - { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, - { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, - { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, - { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, - { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, - { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, + { 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]] @@ -1054,11 +1187,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +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/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { 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]] @@ -1081,11 +1214,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.11.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +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/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { 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]] @@ -1142,11 +1275,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +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/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { 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]] @@ -1195,7 +1328,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1203,139 +1336,139 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +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/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { 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.3" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +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/22/98/b50eb9a411e87483b5c65dba4fa430a06bac4234d3403a40e5a9905ebcd0/pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1", size = 2108971, upload-time = "2026-04-20T14:43:51.945Z" }, - { url = "https://files.pythonhosted.org/packages/08/4b/f364b9d161718ff2217160a4b5d41ce38de60aed91c3689ebffa1c939d23/pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f", size = 1949588, upload-time = "2026-04-20T14:44:10.386Z" }, - { url = "https://files.pythonhosted.org/packages/8f/8b/30bd03ee83b2f5e29f5ba8e647ab3c456bf56f2ec72fdbcc0215484a0854/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3", size = 1975986, upload-time = "2026-04-20T14:43:57.106Z" }, - { url = "https://files.pythonhosted.org/packages/3c/54/13ccf954d84ec275d5d023d5786e4aa48840bc9f161f2838dc98e1153518/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a", size = 2055830, upload-time = "2026-04-20T14:44:15.499Z" }, - { url = "https://files.pythonhosted.org/packages/be/0e/65f38125e660fdbd72aa858e7dfae893645cfa0e7b13d333e174a367cd23/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807", size = 2222340, upload-time = "2026-04-20T14:41:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/d1/88/f3ab7739efe0e7e80777dbb84c59eb98518e3f57ea433206194c2e425272/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda", size = 2280727, upload-time = "2026-04-20T14:41:30.461Z" }, - { url = "https://files.pythonhosted.org/packages/2a/6d/c228219080817bec4982f9531cadb18da6aaa770fdeb114f49c237ac2c9f/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57", size = 2092158, upload-time = "2026-04-20T14:44:07.305Z" }, - { url = "https://files.pythonhosted.org/packages/0f/b1/525a16711e7c6d61635fac3b0bd54600b5c5d9f60c6fc5aaab26b64a2297/pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045", size = 2116626, upload-time = "2026-04-20T14:42:34.118Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7c/17d30673351439a6951bf54f564cf2443ab00ae264ec9df00e2efd710eb5/pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943", size = 2160691, upload-time = "2026-04-20T14:41:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/86/66/af8adbcbc0886ead7f1a116606a534d75a307e71e6e08226000d51b880d2/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f", size = 2182543, upload-time = "2026-04-20T14:40:48.886Z" }, - { url = "https://files.pythonhosted.org/packages/b0/37/6de71e0f54c54a4190010f57deb749e1ddf75c568ada3b1320b70067f121/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4", size = 2324513, upload-time = "2026-04-20T14:42:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/51/b1/9fc74ce94f603d5ef59ff258ca9c2c8fb902fb548d340a96f77f4d1c3b7f/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a", size = 2361853, upload-time = "2026-04-20T14:43:24.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/d0/4c652fc592db35f100279ee751d5a145aca1b9a7984b9684ba7c1b5b0535/pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7", size = 1980465, upload-time = "2026-04-20T14:44:46.239Z" }, - { url = "https://files.pythonhosted.org/packages/27/b8/a920453c38afbe1f355e1ea0b0d94a0a3e0b0879d32d793108755fa171d5/pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6", size = 2073884, upload-time = "2026-04-20T14:43:01.201Z" }, - { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, - { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, - { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, - { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, - { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, - { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, - { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, - { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, - { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, - { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, - { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, - { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, - { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, - { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, - { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, - { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, - { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, - { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, - { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, - { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, - { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, - { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, - { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, - { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, - { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, + { 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.14.0" +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/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +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/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, + { 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]] @@ -1349,20 +1482,20 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" +version = "11.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +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/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { 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.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1373,9 +1506,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +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/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { 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]] @@ -1394,14 +1527,14 @@ wheels = [ [[package]] name = "pytest-github-actions-annotate-failures" -version = "0.4.0" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/e1/8f2c242e6d75a26a8e5ddcc23f652a411e4aac3eedc4b923808ac0582685/pytest_github_actions_annotate_failures-0.4.0.tar.gz", hash = "sha256:77d6baa29c8c61c2dacc494fa76eb95a185f0ee61666714ac43fb12ea672d217", size = 10857, upload-time = "2026-03-02T18:57:40.919Z" } +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/0f/bd/11809f5c78d5d8da2d0e004845d2382768bc20798b3e0988bca61efd349f/pytest_github_actions_annotate_failures-0.4.0-py3-none-any.whl", hash = "sha256:285fed86e16b0b7a8eac6acdcde31913798fb739b15ef5b86895b4f5e32bf237", size = 6039, upload-time = "2026-03-02T18:57:39.991Z" }, + { 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]] @@ -1439,11 +1572,11 @@ wheels = [ [[package]] name = "pytz" -version = "2026.1.post1" +version = "2026.3.post1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +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/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, + { 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]] @@ -1524,7 +1657,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1532,22 +1665,22 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +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/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { 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 = "14.3.3" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +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/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, + { 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]] @@ -1564,88 +1697,102 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.16" +version = "0.16.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +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/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, - { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, - { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, - { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, - { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, - { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, - { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, - { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, - { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, - { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, + { 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 = "3.20.2" +version = "4.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" } +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/78/09/2bf3761de89ea2d91bdce6cf107dcd858892d0adc22c995684878826cc6b/simplejson-3.20.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6d7286dc11af60a2f76eafb0c2acde2d997e87890e37e24590bb513bec9f1bc5", size = 94039, upload-time = "2025-09-26T16:27:29.283Z" }, - { url = "https://files.pythonhosted.org/packages/0f/33/c3277db8931f0ae9e54b9292668863365672d90fb0f632f4cf9829cb7d68/simplejson-3.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c01379b4861c3b0aa40cba8d44f2b448f5743999aa68aaa5d3ef7049d4a28a2d", size = 75894, upload-time = "2025-09-26T16:27:30.378Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ea/ae47b04d03c7c8a7b7b1a8b39a6e27c3bd424e52f4988d70aca6293ff5e5/simplejson-3.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a16b029ca25645b3bc44e84a4f941efa51bf93c180b31bd704ce6349d1fc77c1", size = 76116, upload-time = "2025-09-26T16:27:31.42Z" }, - { url = "https://files.pythonhosted.org/packages/4b/42/6c9af551e5a8d0f171d6dce3d9d1260068927f7b80f1f09834e07887c8c4/simplejson-3.20.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e22a5fb7b1437ffb057e02e1936a3bfb19084ae9d221ec5e9f4cf85f69946b6", size = 138827, upload-time = "2025-09-26T16:27:32.486Z" }, - { url = "https://files.pythonhosted.org/packages/2b/22/5e268bbcbe9f75577491e406ec0a5536f5b2fa91a3b52031fea51cd83e1d/simplejson-3.20.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8b6ff02fc7b8555c906c24735908854819b0d0dc85883d453e23ca4c0445d01", size = 146772, upload-time = "2025-09-26T16:27:34.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/b4/800f14728e2ad666f420dfdb57697ca128aeae7f991b35759c09356b829a/simplejson-3.20.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bfc1c396ad972ba4431130b42307b2321dba14d988580c1ac421ec6a6b7cee3", size = 134497, upload-time = "2025-09-26T16:27:35.211Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b9/c54eef4226c6ac8e9a389bbe5b21fef116768f97a2dc1a683c716ffe66ef/simplejson-3.20.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a97249ee1aee005d891b5a211faf58092a309f3d9d440bc269043b08f662eda", size = 138172, upload-time = "2025-09-26T16:27:36.44Z" }, - { url = "https://files.pythonhosted.org/packages/09/36/4e282f5211b34620f1b2e4b51d9ddaab5af82219b9b7b78360a33f7e5387/simplejson-3.20.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f1036be00b5edaddbddbb89c0f80ed229714a941cfd21e51386dc69c237201c2", size = 140272, upload-time = "2025-09-26T16:27:37.605Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b0/94ad2cf32f477c449e1f63c863d8a513e2408d651c4e58fe4b6a7434e168/simplejson-3.20.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5d6f5bacb8cdee64946b45f2680afa3f54cd38e62471ceda89f777693aeca4e4", size = 140468, upload-time = "2025-09-26T16:27:39.015Z" }, - { url = "https://files.pythonhosted.org/packages/e5/46/827731e4163be3f987cb8ee90f5d444161db8f540b5e735355faa098d9bc/simplejson-3.20.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8db6841fb796ec5af632f677abf21c6425a1ebea0d9ac3ef1a340b8dc69f52b8", size = 148700, upload-time = "2025-09-26T16:27:40.171Z" }, - { url = "https://files.pythonhosted.org/packages/c7/28/c32121064b1ec2fb7b5d872d9a1abda62df064d35e0160eddfa907118343/simplejson-3.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c0a341f7cc2aae82ee2b31f8a827fd2e51d09626f8b3accc441a6907c88aedb7", size = 141323, upload-time = "2025-09-26T16:27:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/46/b6/c897c54326fe86dd12d101981171a49361949f4728294f418c3b86a1af77/simplejson-3.20.2-cp310-cp310-win32.whl", hash = "sha256:27f9c01a6bc581d32ab026f515226864576da05ef322d7fc141cd8a15a95ce53", size = 74377, upload-time = "2025-09-26T16:27:42.533Z" }, - { url = "https://files.pythonhosted.org/packages/ad/87/a6e03d4d80cca99c1fee4e960f3440e2f21be9470e537970f960ca5547f1/simplejson-3.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0a63ec98a4547ff366871bf832a7367ee43d047bcec0b07b66c794e2137b476", size = 76081, upload-time = "2025-09-26T16:27:43.945Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3e/96898c6c66d9dca3f9bd14d7487bf783b4acc77471b42f979babbb68d4ca/simplejson-3.20.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06190b33cd7849efc413a5738d3da00b90e4a5382fd3d584c841ac20fb828c6f", size = 92633, upload-time = "2025-09-26T16:27:45.028Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a2/cd2e10b880368305d89dd540685b8bdcc136df2b3c76b5ddd72596254539/simplejson-3.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4ad4eac7d858947a30d2c404e61f16b84d16be79eb6fb316341885bdde864fa8", size = 75309, upload-time = "2025-09-26T16:27:46.142Z" }, - { url = "https://files.pythonhosted.org/packages/5d/02/290f7282eaa6ebe945d35c47e6534348af97472446951dce0d144e013f4c/simplejson-3.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b392e11c6165d4a0fde41754a0e13e1d88a5ad782b245a973dd4b2bdb4e5076a", size = 75308, upload-time = "2025-09-26T16:27:47.542Z" }, - { url = "https://files.pythonhosted.org/packages/43/91/43695f17b69e70c4b0b03247aa47fb3989d338a70c4b726bbdc2da184160/simplejson-3.20.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51eccc4e353eed3c50e0ea2326173acdc05e58f0c110405920b989d481287e51", size = 143733, upload-time = "2025-09-26T16:27:48.673Z" }, - { url = "https://files.pythonhosted.org/packages/9b/4b/fdcaf444ac1c3cbf1c52bf00320c499e1cf05d373a58a3731ae627ba5e2d/simplejson-3.20.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306e83d7c331ad833d2d43c76a67f476c4b80c4a13334f6e34bb110e6105b3bd", size = 153397, upload-time = "2025-09-26T16:27:49.89Z" }, - { url = "https://files.pythonhosted.org/packages/c4/83/21550f81a50cd03599f048a2d588ffb7f4c4d8064ae091511e8e5848eeaa/simplejson-3.20.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f820a6ac2ef0bc338ae4963f4f82ccebdb0824fe9caf6d660670c578abe01013", size = 141654, upload-time = "2025-09-26T16:27:51.168Z" }, - { url = "https://files.pythonhosted.org/packages/cf/54/d76c0e72ad02450a3e723b65b04f49001d0e73218ef6a220b158a64639cb/simplejson-3.20.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e7a066528a5451433eb3418184f05682ea0493d14e9aae690499b7e1eb6b81", size = 144913, upload-time = "2025-09-26T16:27:52.331Z" }, - { url = "https://files.pythonhosted.org/packages/3f/49/976f59b42a6956d4aeb075ada16ad64448a985704bc69cd427a2245ce835/simplejson-3.20.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:438680ddde57ea87161a4824e8de04387b328ad51cfdf1eaf723623a3014b7aa", size = 144568, upload-time = "2025-09-26T16:27:53.41Z" }, - { url = "https://files.pythonhosted.org/packages/60/c7/30bae30424ace8cd791ca660fed454ed9479233810fe25c3f3eab3d9dc7b/simplejson-3.20.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cac78470ae68b8d8c41b6fca97f5bf8e024ca80d5878c7724e024540f5cdaadb", size = 146239, upload-time = "2025-09-26T16:27:54.502Z" }, - { url = "https://files.pythonhosted.org/packages/79/3e/7f3b7b97351c53746e7b996fcd106986cda1954ab556fd665314756618d2/simplejson-3.20.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7524e19c2da5ef281860a3d74668050c6986be15c9dd99966034ba47c68828c2", size = 154497, upload-time = "2025-09-26T16:27:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/1d/48/7241daa91d0bf19126589f6a8dcbe8287f4ed3d734e76fd4a092708947be/simplejson-3.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e9b6d845a603b2eef3394eb5e21edb8626cd9ae9a8361d14e267eb969dbe413", size = 148069, upload-time = "2025-09-26T16:27:57.039Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f4/ef18d2962fe53e7be5123d3784e623859eec7ed97060c9c8536c69d34836/simplejson-3.20.2-cp311-cp311-win32.whl", hash = "sha256:47d8927e5ac927fdd34c99cc617938abb3624b06ff86e8e219740a86507eb961", size = 74158, upload-time = "2025-09-26T16:27:58.265Z" }, - { url = "https://files.pythonhosted.org/packages/35/fd/3d1158ecdc573fdad81bf3cc78df04522bf3959758bba6597ba4c956c74d/simplejson-3.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:ba4edf3be8e97e4713d06c3d302cba1ff5c49d16e9d24c209884ac1b8455520c", size = 75911, upload-time = "2025-09-26T16:27:59.292Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9e/1a91e7614db0416885eab4136d49b7303de20528860ffdd798ce04d054db/simplejson-3.20.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4376d5acae0d1e91e78baeba4ee3cf22fbf6509d81539d01b94e0951d28ec2b6", size = 93523, upload-time = "2025-09-26T16:28:00.356Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2b/d2413f5218fc25608739e3d63fe321dfa85c5f097aa6648dbe72513a5f12/simplejson-3.20.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f8fe6de652fcddae6dec8f281cc1e77e4e8f3575249e1800090aab48f73b4259", size = 75844, upload-time = "2025-09-26T16:28:01.756Z" }, - { url = "https://files.pythonhosted.org/packages/ad/f1/efd09efcc1e26629e120fef59be059ce7841cc6e1f949a4db94f1ae8a918/simplejson-3.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25ca2663d99328d51e5a138f22018e54c9162438d831e26cfc3458688616eca8", size = 75655, upload-time = "2025-09-26T16:28:03.037Z" }, - { url = "https://files.pythonhosted.org/packages/97/ec/5c6db08e42f380f005d03944be1af1a6bd501cc641175429a1cbe7fb23b9/simplejson-3.20.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a6b2816b6cab6c3fd273d43b1948bc9acf708272074c8858f579c394f4cbc9", size = 150335, upload-time = "2025-09-26T16:28:05.027Z" }, - { url = "https://files.pythonhosted.org/packages/81/f5/808a907485876a9242ec67054da7cbebefe0ee1522ef1c0be3bfc90f96f6/simplejson-3.20.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac20dc3fcdfc7b8415bfc3d7d51beccd8695c3f4acb7f74e3a3b538e76672868", size = 158519, upload-time = "2025-09-26T16:28:06.5Z" }, - { url = "https://files.pythonhosted.org/packages/66/af/b8a158246834645ea890c36136584b0cc1c0e4b83a73b11ebd9c2a12877c/simplejson-3.20.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db0804d04564e70862ef807f3e1ace2cc212ef0e22deb1b3d6f80c45e5882c6b", size = 148571, upload-time = "2025-09-26T16:28:07.715Z" }, - { url = "https://files.pythonhosted.org/packages/20/05/ed9b2571bbf38f1a2425391f18e3ac11cb1e91482c22d644a1640dea9da7/simplejson-3.20.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:979ce23ea663895ae39106946ef3d78527822d918a136dbc77b9e2b7f006237e", size = 152367, upload-time = "2025-09-26T16:28:08.921Z" }, - { url = "https://files.pythonhosted.org/packages/81/2c/bad68b05dd43e93f77994b920505634d31ed239418eb6a88997d06599983/simplejson-3.20.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a2ba921b047bb029805726800819675249ef25d2f65fd0edb90639c5b1c3033c", size = 150205, upload-time = "2025-09-26T16:28:10.086Z" }, - { url = "https://files.pythonhosted.org/packages/69/46/90c7fc878061adafcf298ce60cecdee17a027486e9dce507e87396d68255/simplejson-3.20.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:12d3d4dc33770069b780cc8f5abef909fe4a3f071f18f55f6d896a370fd0f970", size = 151823, upload-time = "2025-09-26T16:28:11.329Z" }, - { url = "https://files.pythonhosted.org/packages/ab/27/b85b03349f825ae0f5d4f780cdde0bbccd4f06c3d8433f6a3882df887481/simplejson-3.20.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aff032a59a201b3683a34be1169e71ddda683d9c3b43b261599c12055349251e", size = 158997, upload-time = "2025-09-26T16:28:12.917Z" }, - { url = "https://files.pythonhosted.org/packages/71/ad/d7f3c331fb930638420ac6d236db68e9f4c28dab9c03164c3cd0e7967e15/simplejson-3.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e590e133b06773f0dc9c3f82e567463df40598b660b5adf53eb1c488202544", size = 154367, upload-time = "2025-09-26T16:28:14.393Z" }, - { url = "https://files.pythonhosted.org/packages/f0/46/5c67324addd40fa2966f6e886cacbbe0407c03a500db94fb8bb40333fcdf/simplejson-3.20.2-cp312-cp312-win32.whl", hash = "sha256:8d7be7c99939cc58e7c5bcf6bb52a842a58e6c65e1e9cdd2a94b697b24cddb54", size = 74285, upload-time = "2025-09-26T16:28:15.931Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c9/5cc2189f4acd3a6e30ffa9775bf09b354302dbebab713ca914d7134d0f29/simplejson-3.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c0b4a67e75b945489052af6590e7dca0ed473ead5d0f3aad61fa584afe814ab", size = 75969, upload-time = "2025-09-26T16:28:17.017Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530, upload-time = "2025-09-26T16:28:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846, upload-time = "2025-09-26T16:28:19.12Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661, upload-time = "2025-09-26T16:28:20.219Z" }, - { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579, upload-time = "2025-09-26T16:28:21.337Z" }, - { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797, upload-time = "2025-09-26T16:28:22.53Z" }, - { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851, upload-time = "2025-09-26T16:28:23.733Z" }, - { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598, upload-time = "2025-09-26T16:28:24.962Z" }, - { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498, upload-time = "2025-09-26T16:28:26.114Z" }, - { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129, upload-time = "2025-09-26T16:28:27.552Z" }, - { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359, upload-time = "2025-09-26T16:28:28.667Z" }, - { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" }, - { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" }, - { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" }, + { 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]] @@ -1758,62 +1905,62 @@ wheels = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20260408" +version = "2.9.0.20260807" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/f3/2427775f80cd5e19a0a71ba8e5ab7645a01a852f43a5fd0ffc24f66338e0/types_python_dateutil-2.9.0.20260408.tar.gz", hash = "sha256:8b056ec01568674235f64ecbcef928972a5fac412f5aab09c516dfa2acfbb582", size = 16981, upload-time = "2026-04-08T04:28:10.995Z" } +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/fd/c6/eeba37bfee282a6a97f889faef9352d6172c6a5088eb9a4daf570d9d748d/types_python_dateutil-2.9.0.20260408-py3-none-any.whl", hash = "sha256:473139d514a71c9d1fbd8bb328974bedcb1cc3dba57aad04ffa4157f483c216f", size = 18437, upload-time = "2026-04-08T04:28:10.095Z" }, + { 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.20260408" +version = "6.0.12.20260724" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/73/b759b1e413c31034cc01ecdfb96b38115d0ab4db55a752a3929f0cd449fd/types_pyyaml-6.0.12.20260408.tar.gz", hash = "sha256:92a73f2b8d7f39ef392a38131f76b970f8c66e4c42b3125ae872b7c93b556307", size = 17735, upload-time = "2026-04-08T04:30:50.974Z" } +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/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" }, + { 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.20260508" +version = "2.33.0.20260712" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/6b/eb226bdd61a982c9a03e02c657fb4ab001733506e6423906ac142331f2e3/types_requests-2.33.0.20260508.tar.gz", hash = "sha256:81b2ae5f0d20967714a6aa5ef9284c05570d7cb06b7de8f2a77b918b63ddd411", size = 23991, upload-time = "2026-05-08T04:50:56.818Z" } +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/cb/96/080db0afdf2c5cc5fe512b41354e8d114fe8f65e9510c56ff8dfd40216ce/types_requests-2.33.0.20260508-py3-none-any.whl", hash = "sha256:fa01459cca184229713df03709db46a905325906d27e042cd4fd7ea3d15d3400", size = 20722, upload-time = "2026-05-08T04:50:55.548Z" }, + { 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.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +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/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { 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.2" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +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/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { 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.2" +version = "2026.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +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/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, + { 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]] From 47fd771b2ac2814560f766e8d4865165f30e8230 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:07:52 +0200 Subject: [PATCH 018/109] Add new iOS versions and build numbers (#865) Co-authored-by: DonnchaC <3081375+DonnchaC@users.noreply.github.com> --- src/mvt/ios/data/ios_versions.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/mvt/ios/data/ios_versions.json b/src/mvt/ios/data/ios_versions.json index ffe09ac2..16fbba5b 100644 --- a/src/mvt/ios/data/ios_versions.json +++ b/src/mvt/ios/data/ios_versions.json @@ -1216,6 +1216,10 @@ "version": "18.7.9", "build": "22H355" }, + { + "version": "18.7.10", + "build": "22H373" + }, { "version": "26", "build": "23A341" @@ -1255,5 +1259,9 @@ { "version": "26.5", "build": "23F77" + }, + { + "version": "26.6.1", + "build": "23G82" } ] \ No newline at end of file From 7b6446372703e7342d54cfc6b795661809b2834b Mon Sep 17 00:00:00 2001 From: besendorf Date: Wed, 12 Aug 2026 15:42:15 +0200 Subject: [PATCH 019/109] Update GitHub Actions (#866) --- .github/workflows/add-issue-to-project.yml | 2 +- .github/workflows/mypy.yml | 6 +++--- .github/workflows/publish-release-docker.yml | 11 +++++------ .github/workflows/ruff.yml | 6 +++--- .github/workflows/tests.yml | 6 +++--- .github/workflows/update-ios-data.yml | 4 ++-- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/.github/workflows/add-issue-to-project.yml b/.github/workflows/add-issue-to-project.yml index c4e6d847..825eeee6 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 6a80720d..54402fcf 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -11,13 +11,13 @@ 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.10" - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v10.0.0 with: enable-cache: true - name: Install Dependencies diff --git a/.github/workflows/publish-release-docker.yml b/.github/workflows/publish-release-docker.yml index dde12b7a..eddca22d 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 ee241646..08fc2cdd 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -12,13 +12,13 @@ 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.10" - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v10.0.0 with: enable-cache: true - name: Install Dependencies diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8eeefc4f..c98ab223 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,13 +15,13 @@ 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@v7 + 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 diff --git a/.github/workflows/update-ios-data.yml b/.github/workflows/update-ios-data.yml index ed0388cd..443918c9 100644 --- a/.github/workflows/update-ios-data.yml +++ b/.github/workflows/update-ios-data.yml @@ -11,8 +11,8 @@ jobs: update-ios-version: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - 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. From 683b8ba13352cb2c89def60cb2a8177bdda02fb2 Mon Sep 17 00:00:00 2001 From: besendorf Date: Fri, 14 Aug 2026 09:40:34 +0200 Subject: [PATCH 020/109] Parse package installer from bugreports (#868) --- src/mvt/android/artifacts/dumpsys_packages.py | 3 +++ tests/android_bugreport/test_bugreport.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/mvt/android/artifacts/dumpsys_packages.py b/src/mvt/android/artifacts/dumpsys_packages.py index 81da22ef..a18d4f4f 100644 --- a/src/mvt/android/artifacts/dumpsys_packages.py +++ b/src/mvt/android/artifacts/dumpsys_packages.py @@ -71,6 +71,7 @@ class DumpsysPackagesArtifact(AndroidArtifact): "timestamp": "", "first_install_time": "", "last_update_time": "", + "installer": "", "permissions": list(), "requested_permissions": list(), } @@ -126,6 +127,8 @@ 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("firstInstallTime="): details["first_install_time"] = line.split("=")[1].strip() elif line.strip().startswith("lastUpdateTime="): diff --git a/tests/android_bugreport/test_bugreport.py b/tests/android_bugreport/test_bugreport.py index 2bfaef54..75df1061 100644 --- a/tests/android_bugreport/test_bugreport.py +++ b/tests/android_bugreport/test_bugreport.py @@ -54,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 From fa24b5465b24a1c6e5e15d899f05c03a1ca1a55a Mon Sep 17 00:00:00 2001 From: besendorf Date: Fri, 14 Aug 2026 14:33:28 +0200 Subject: [PATCH 021/109] Scope package fields to the primary user (#872) --- src/mvt/android/artifacts/dumpsys_packages.py | 26 ++++++++++-- .../android/test_artifact_dumpsys_packages.py | 41 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_packages.py b/src/mvt/android/artifacts/dumpsys_packages.py index a18d4f4f..341c57db 100644 --- a/src/mvt/android/artifacts/dumpsys_packages.py +++ b/src/mvt/android/artifacts/dumpsys_packages.py @@ -4,7 +4,7 @@ # https://license.mvt.re/1.1/ import re -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from mvt.android.utils import ROOT_PACKAGES from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult @@ -79,7 +79,14 @@ class DumpsysPackagesArtifact(AndroidArtifact): 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 @@ -103,7 +110,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: @@ -130,7 +137,7 @@ class DumpsysPackagesArtifact(AndroidArtifact): elif line.strip().startswith("installerPackageName="): details["installer"] = line.split("=", 1)[1].strip() 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:": @@ -142,6 +149,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]]: diff --git a/tests/android/test_artifact_dumpsys_packages.py b/tests/android/test_artifact_dumpsys_packages.py index 7b2ec0f1..39789399 100644 --- a/tests/android/test_artifact_dumpsys_packages.py +++ b/tests/android/test_artifact_dumpsys_packages.py @@ -25,6 +25,7 @@ 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" def test_ioc_check(self, indicator_file): dpa = DumpsysPackagesArtifact() @@ -40,3 +41,43 @@ class TestDumpsysPackagesArtifact: assert len(dpa.alertstore.alerts) == 0 dpa.check_indicators() 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" From 0ee25edf0a353ca08870e38667e36aa941959f02 Mon Sep 17 00:00:00 2001 From: besendorf Date: Fri, 14 Aug 2026 15:58:05 +0200 Subject: [PATCH 022/109] Fix dumpsys package system flag parsing (#874) --- src/mvt/android/artifacts/dumpsys_packages.py | 3 +++ .../android/test_artifact_dumpsys_packages.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/mvt/android/artifacts/dumpsys_packages.py b/src/mvt/android/artifacts/dumpsys_packages.py index 341c57db..bef77444 100644 --- a/src/mvt/android/artifacts/dumpsys_packages.py +++ b/src/mvt/android/artifacts/dumpsys_packages.py @@ -72,6 +72,7 @@ class DumpsysPackagesArtifact(AndroidArtifact): "first_install_time": "", "last_update_time": "", "installer": "", + "system": False, "permissions": list(), "requested_permissions": list(), } @@ -136,6 +137,8 @@ class DumpsysPackagesArtifact(AndroidArtifact): 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="): first_install_times[current_user] = line.split("=", 1)[1].strip() elif line.strip().startswith("lastUpdateTime="): diff --git a/tests/android/test_artifact_dumpsys_packages.py b/tests/android/test_artifact_dumpsys_packages.py index 39789399..bd5fb753 100644 --- a/tests/android/test_artifact_dumpsys_packages.py +++ b/tests/android/test_artifact_dumpsys_packages.py @@ -26,6 +26,24 @@ class TestDumpsysPackagesArtifact: ) 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() From 545ac19158120eb13292d6df9ca4ef6f3ea94b3d Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Mon, 17 Aug 2026 13:24:30 +0200 Subject: [PATCH 023/109] Restore concurrent backup decryption --- src/mvt/common/help.py | 1 + src/mvt/ios/cli.py | 18 +++- src/mvt/ios/decrypt.py | 147 +++++++++++++++++++++++-------- tests/ios_backup/test_decrypt.py | 68 +++++++++++++- 4 files changed, 195 insertions(+), 39 deletions(-) diff --git a/src/mvt/common/help.py b/src/mvt/common/help.py index 5101f939..74615382 100644 --- a/src/mvt/common/help.py +++ b/src/mvt/common/help.py @@ -28,6 +28,7 @@ HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup" HELP_MSG_BACKUP_DESTINATION = ( "Path to the folder where the decrypted backup should be stored" ) +HELP_MSG_DECRYPT_JOBS = "Number of files to decrypt concurrently" HELP_MSG_IOS_BACKUP_PASSWORD = ( "Password to use to decrypt the backup (or, set the {MVT_IOS_BACKUP_PASSWORD} " "environment variable)" diff --git a/src/mvt/ios/cli.py b/src/mvt/ios/cli.py index c338fa54..0bd362a2 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -33,6 +33,7 @@ from mvt.common.help import ( HELP_MSG_VERSION, HELP_MSG_DECRYPT_BACKUP, HELP_MSG_BACKUP_DESTINATION, + HELP_MSG_DECRYPT_JOBS, HELP_MSG_IOS_BACKUP_PASSWORD, HELP_MSG_BACKUP_KEYFILE, HELP_MSG_HASHES, @@ -58,7 +59,11 @@ 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 .decrypt import ( + DEFAULT_DECRYPT_WORKERS, + MAX_DECRYPT_WORKERS, + DecryptBackup, +) from .modules.backup import BACKUP_MODULES from .modules.fs import FS_MODULES from .modules.mixed import MIXED_MODULES @@ -162,6 +167,13 @@ def completion(ctx, shell, install): "decrypt-backup", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_DECRYPT_BACKUP ) @click.option("--destination", "-d", required=True, help=HELP_MSG_BACKUP_DESTINATION) +@click.option( + "--jobs", + type=click.IntRange(1, MAX_DECRYPT_WORKERS), + default=DEFAULT_DECRYPT_WORKERS, + show_default=True, + help=HELP_MSG_DECRYPT_JOBS, +) @click.option( "--password", "-p", @@ -180,8 +192,8 @@ def completion(ctx, shell, install): @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) @click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.pass_context -def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path): - backup = DecryptBackup(backup_path, destination) +def decrypt_backup(ctx, destination, jobs, password, key_file, hashes, backup_path): + backup = DecryptBackup(backup_path, destination, max_workers=jobs) if key_file: if MVT_IOS_BACKUP_PASSWORD in os.environ: diff --git a/src/mvt/ios/decrypt.py b/src/mvt/ios/decrypt.py index 2ed3efe8..faf3000e 100644 --- a/src/mvt/ios/decrypt.py +++ b/src/mvt/ios/decrypt.py @@ -12,6 +12,13 @@ import plistlib import shutil import sqlite3 import tempfile +from concurrent.futures import ( + ALL_COMPLETED, + FIRST_COMPLETED, + Future, + ThreadPoolExecutor, + wait, +) from pathlib import Path from typing import Optional @@ -21,6 +28,9 @@ from iphone_backup_decrypt.utils import FilePlist log = logging.getLogger(__name__) +DEFAULT_DECRYPT_WORKERS = 4 +MAX_DECRYPT_WORKERS = 32 + # Import pbkdf2_hmac from the same source iphone_backup_decrypt uses internally, # so our key derivation is consistent with theirs. try: @@ -178,19 +188,68 @@ class DecryptBackup: """ - def __init__(self, backup_path: str, dest_path: Optional[str] = None) -> None: + def __init__( + self, + backup_path: str, + dest_path: Optional[str] = None, + max_workers: int = DEFAULT_DECRYPT_WORKERS, + ) -> None: """Decrypts an encrypted iOS backup. :param backup_path: Path to the encrypted backup folder :param dest_path: Path to the folder where to store the decrypted backup """ self.backup_path = os.path.abspath(backup_path) self.dest_path = dest_path + if not 1 <= max_workers <= MAX_DECRYPT_WORKERS: + raise ValueError(f"max_workers must be between 1 and {MAX_DECRYPT_WORKERS}") + self.max_workers = max_workers self._backup: Optional[MVTEncryptedBackup] = None self._decryption_key: Optional[str] = None def can_process(self) -> bool: return self._backup is not None + def _process_file( + self, + *, + file_id: str, + file_bplist: bytes, + output_path: Path, + relative_path: str, + domain: str, + ) -> None: + assert self._backup is not None + self._backup.extract_file_by_id( + file_id=file_id, + file_bplist=file_bplist, + output_filename=str(output_path), + ) + log.info( + "Decrypted file %s [%s] to %s/%s", + relative_path, + domain, + output_path.parent, + file_id, + ) + + @staticmethod + def _wait_for_files( + pending: dict[Future[None], str], *, all_files: bool = False + ) -> None: + if not pending: + return + + done, _ = wait( + pending, + return_when=ALL_COMPLETED if all_files else FIRST_COMPLETED, + ) + for future in done: + relative_path = pending.pop(future) + try: + future.result() + except Exception as exc: + log.error("Failed to decrypt file %s: %s", relative_path, exc) + @staticmethod def is_encrypted(backup_path: str) -> bool: """Query Manifest.db file to see if it's encrypted or not. @@ -226,45 +285,63 @@ class DecryptBackup: # 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" - ) - 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 = 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.", - source_file_path, - ) - continue + pending: dict[Future[None], str] = {} + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + with self._backup.manifest_db_cursor() as cur: + cur.execute( + "SELECT fileID, domain, relativePath, file FROM Files WHERE flags=1" + ) + 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 = 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.", + source_file_path, + ) + continue - 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) + 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: - self._backup.extract_file_by_id( + if self.max_workers == 1: + try: + self._process_file( + file_id=file_id, + file_bplist=file_bplist, + output_path=output_path, + relative_path=relative_path, + domain=domain, + ) + except Exception as exc: + log.error( + "Failed to decrypt file %s: %s", + relative_path, + exc, + ) + continue + + future = executor.submit( + self._process_file, file_id=file_id, file_bplist=file_bplist, - output_filename=str(output_path), + output_path=output_path, + relative_path=relative_path, + domain=domain, ) - log.info( - "Decrypted file %s [%s] to %s/%s", - relative_path, - domain, - output_path.parent, - file_id, - ) - except Exception as exc: - log.error("Failed to decrypt file %s: %s", relative_path, exc) + pending[future] = relative_path + if len(pending) >= self.max_workers: + self._wait_for_files(pending) + + self._wait_for_files(pending, all_files=True) # Copying over the root plist files as well. for file_name in os.listdir(self.backup_path): diff --git a/tests/ios_backup/test_decrypt.py b/tests/ios_backup/test_decrypt.py index 28eeb83b..25decce5 100644 --- a/tests/ios_backup/test_decrypt.py +++ b/tests/ios_backup/test_decrypt.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 logging +import threading from pathlib import Path from Crypto.Cipher import AES @@ -114,7 +116,9 @@ def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_pat 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 = DecryptBackup( + str(backup_path), str(destination), max_workers=1 + ) decryptor._backup = backup decryptor.process_backup() @@ -123,3 +127,65 @@ def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_pat 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 + + +def test_process_backup_decrypts_files_concurrently(mocker, tmp_path): + backup_path = tmp_path / "backup" + destination = tmp_path / "destination" + backup_path.mkdir() + + file_ids = ["ab" + "1" * 38, "cd" + "2" * 38] + for file_id in file_ids: + source_path = backup_path / file_id[:2] / file_id + source_path.parent.mkdir() + source_path.write_bytes(b"encrypted") + + cursor = mocker.MagicMock() + cursor.__iter__.return_value = iter( + (file_id, "Domain", file_id, b"plist") for file_id in file_ids + ) + cursor_context = mocker.MagicMock() + cursor_context.__enter__.return_value = cursor + + barrier = threading.Barrier(2) + backup = mocker.MagicMock() + backup.manifest_db_cursor.return_value = cursor_context + + def extract_file_by_id(*, file_id, output_filename, **kwargs): + barrier.wait(timeout=5) + Path(output_filename).write_bytes(file_id.encode()) + + backup.extract_file_by_id.side_effect = extract_file_by_id + decryptor = DecryptBackup(str(backup_path), str(destination), max_workers=2) + decryptor._backup = backup + + decryptor.process_backup() + + for file_id in file_ids: + assert (destination / file_id[:2] / file_id).read_bytes() == file_id.encode() + + +def test_process_backup_logs_worker_errors(mocker, tmp_path, caplog): + backup_path = tmp_path / "backup" + destination = tmp_path / "destination" + backup_path.mkdir() + file_id = "ef" + "3" * 38 + source_path = backup_path / file_id[:2] / file_id + source_path.parent.mkdir() + source_path.write_bytes(b"encrypted") + + cursor = mocker.MagicMock() + cursor.__iter__.return_value = iter([(file_id, "Domain", "failing-file", b"plist")]) + cursor_context = mocker.MagicMock() + cursor_context.__enter__.return_value = cursor + + backup = mocker.MagicMock() + backup.manifest_db_cursor.return_value = cursor_context + backup.extract_file_by_id.side_effect = ValueError("broken file") + decryptor = DecryptBackup(str(backup_path), str(destination)) + decryptor._backup = backup + + with caplog.at_level(logging.ERROR, logger="mvt.ios.decrypt"): + decryptor.process_backup() + + assert "Failed to decrypt file failing-file: broken file" in caplog.text From 0fdc2c34b00300392e60fa1e590f3f3238848cf5 Mon Sep 17 00:00:00 2001 From: besendorf Date: Mon, 17 Aug 2026 13:42:22 +0200 Subject: [PATCH 024/109] Document iOS device access from Docker (#875) * Document iOS device access from Docker * Simplify docker.md instructions Removed unnecessary note about mounting the entire '/var/run' directory. --- docs/docker.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/docker.md b/docs/docker.md index d8f4ff8e..453c245f 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -31,4 +31,18 @@ Test if the image was created successfully: docker run -it mvt ``` -If a prompt is spawned successfully, you can close it with `exit`. \ No newline at end of file +If a prompt is spawned successfully, you can close it with `exit`. + +## Access an iOS device from Docker + +On the Linux host, install and start [usbmuxd](https://github.com/libimobiledevice/usbmuxd), then connect and unlock the iOS device. The daemon exposes the device through the `/var/run/usbmuxd` socket. + +Bind that socket into the container to let MVT communicate with the device: + +```bash +docker run -it \ + --mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \ + ghcr.io/mvt-project/mvt +``` + +If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`. From 3b21c9347c616ceed6dfce62a4a4767e052f7717 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:04:35 +0200 Subject: [PATCH 025/109] Add new iOS versions and build numbers (#879) Co-authored-by: DonnchaC <3081375+DonnchaC@users.noreply.github.com> --- src/mvt/ios/data/ios_versions.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/mvt/ios/data/ios_versions.json b/src/mvt/ios/data/ios_versions.json index 16fbba5b..9b4613bb 100644 --- a/src/mvt/ios/data/ios_versions.json +++ b/src/mvt/ios/data/ios_versions.json @@ -1220,6 +1220,10 @@ "version": "18.7.10", "build": "22H373" }, + { + "version": "18.7.10", + "build": "22H374" + }, { "version": "26", "build": "23A341" @@ -1263,5 +1267,9 @@ { "version": "26.6.1", "build": "23G82" + }, + { + "version": "26.6.1", + "build": "23G83" } ] \ No newline at end of file From 10a5bccbe08fa106bfa8b71e43fba0a1c3ceda9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Wed, 19 Aug 2026 11:43:40 +0200 Subject: [PATCH 026/109] Fix usability nits in the Docker images (#881) * Fix error in ADB key generation commmand * Add useful forensic analysis tools --- Dockerfile | 15 +++++++++++++-- Dockerfile.android | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index da06064c..58bc2166 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,12 +119,23 @@ ARG DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y \ adb \ + binutils \ default-jre-headless \ + file \ + jq \ + less \ libcurl4 \ + libimage-exiftool-perl \ libssl3 \ libusb-1.0-0 \ + moreutils \ + p7zip-full \ python3 \ - sqlite3 + ripgrep \ + sqlite3 \ + tree \ + unzip \ + xxd COPY --from=build-libplist /build / COPY --from=build-libimobiledevice-glue /build / COPY --from=build-libtatsu /build / @@ -144,7 +155,7 @@ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2 RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc # Generate adb key folder -RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey 2&>1 > /dev/null; fi' >> ~/.bashrc +RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey > /dev/null 2>&1; fi' >> ~/.bashrc RUN mkdir /root/.android # Setup investigations environment diff --git a/Dockerfile.android b/Dockerfile.android index 2c060dba..2b746114 100644 --- a/Dockerfile.android +++ b/Dockerfile.android @@ -31,7 +31,7 @@ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2 RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc # Generate adb key folder -RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey 2&>1 > /dev/null; fi' >> ~/.bashrc +RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey > /dev/null 2>&1; fi' >> ~/.bashrc RUN mkdir /root/.android ENTRYPOINT [ "/usr/local/bin/mvt-android" ] From a92ec7f9638b54ce7bc617085fd2c1ce7a07986b Mon Sep 17 00:00:00 2001 From: besendorf Date: Wed, 19 Aug 2026 11:46:48 +0200 Subject: [PATCH 027/109] Build Docker images for ARM64 (#880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Donncha Ó Cearbhaill --- .github/workflows/publish-release-docker.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish-release-docker.yml b/.github/workflows/publish-release-docker.yml index eddca22d..b3fde3ae 100644 --- a/.github/workflows/publish-release-docker.yml +++ b/.github/workflows/publish-release-docker.yml @@ -59,6 +59,9 @@ jobs: type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }} type=sha,suffix=${{ matrix.platform.tag-suffix }} type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }} + # Register emulators so Buildx can build the ARM64 images on the AMD64 runner. + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 # This step sets up some additional capabilities to generate the provenance and sbom attestations - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -71,6 +74,7 @@ jobs: with: file: ${{ matrix.platform.dockerfile }} context: . + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From 30c11f68c7a0cbfeca7dfa1b4c07d30d132a1037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Wed, 19 Aug 2026 14:07:27 +0200 Subject: [PATCH 028/109] Add WhatsApp contacts module and fix InteractionC contact resolution (#882) * Add WhatsappContacts module to extract WhatsApp disappearing messages state WhatsApp on iOS stores the disappearing messages timer for 1:1 chats on the contact records in ContactsV2.sqlite, not in ChatStorage.sqlite. Add a new WhatsappContacts module which extracts contact records from this database, including phone numbers, WhatsApp and LID identifiers, and the per-contact disappearing messages duration, and emits a timeline event when a disappearing messages timer was set. The database is often missing from incremental backups, so the module logs a clear warning and returns no results instead of failing. Columns are selected based on the actual table schema to tolerate changes across WhatsApp versions, and if the disappearing messages column is absent the state is reported as unknown rather than off. The test fixture is a synthetic ContactsV2.sqlite with fictional contacts, stored under the backup file ID derived from the WhatsApp shared app group domain. * Fix InteractionC contact resolution and resolve WhatsApp LIDs to contacts The two primary InteractionC queries contained a SQL syntax error in their direction CASE expression (a double column alias), so they always failed and the module silently fell back to a reduced query without the recipient join. As a result outgoing messages were serialized with no counterpart at all ("from None (None)"). Fix the syntax so recipient names and identifiers are extracted again, and normalize the raw 0/1 direction values from the fallback queries to INCOMING/OUTGOING. WhatsApp identifies chat peers in interactionC.db by LID and stores the peer LID in the domain identifier, which InteractionC could not map to a person. Declare a dependency on the WhatsappContacts module and resolve sender, recipient and domain identifiers (LID, JID or phone number) against the WhatsApp contacts database, adding resolved phone number and name fields to WhatsApp records. Rewrite the timeline serialization to use the resolved values, fall back to the chat peer from the domain identifier when no recipient was recorded, label the local user instead of printing None, and include the message direction and group name. * Add timeline events for all WhatsApp contact timestamps Extract ZABOUTEXPIRATIONTIMESTAMP and emit a timeline event for each timestamp stored on a WhatsApp contact record: disappearing messages timer changes, "about" text changes and scheduled expiry, and contact record updates. ContactsV2.sqlite stores no other date attributes in any released schema version. * Add first and last interaction timeline events for WhatsApp chats Extract one record per ZWACHATSESSION with the first and last stored message dates, the session's own last-message date, the group creation date and message counts. Each chat produces chat_first_message and chat_last_message timeline events, and groups a group_created event. The session last-message date is preferred over the newest stored message because it survives message deletion. * Resolve WhatsApp LID chat identifiers via the LID pair table Recent WhatsApp versions key 1:1 chat sessions by an opaque LID rather than the contact's phone number. Extract the ZWAPHONENUMBERLIDPAIR table from the dedicated LID.sqlite database (or from ChatStorage itself in versions that store it there) and use it to populate partner_resolved_phone_number on chat session records and in timeline events, without requiring the often-missing ContactsV2.sqlite. Each pair is also extracted as a record and produces a lid_pair_recorded timeline event marking when the association was learned. * Reduce duplicate InteractionC timeline events The interaction record's creation date normally trails its start date by milliseconds, so serializing both nearly doubled the timeline with duplicate entries. Only emit the creation date when it diverges from the start date by more than an hour, with explicit wording, since a record created long after its event indicates backfill by sync, restore or tampering. Per-contact aggregate dates from ZCONTACTS repeat on every interaction row of the same contact and carried that row's message text. Serialize them with contact-centric data strings instead, so timeline de-duplication collapses them into one first/last-seen event per contact. --- docs/ios/records.md | 16 +- src/mvt/ios/modules/mixed/__init__.py | 2 + src/mvt/ios/modules/mixed/interactionc.py | 240 +++++++++++++- src/mvt/ios/modules/mixed/whatsapp.py | 247 +++++++++++++- .../ios/modules/mixed/whatsapp_contacts.py | 308 ++++++++++++++++++ .../1f5a521220a3ad80ebfdc196978df8e7a2e49dee | Bin 0 -> 24576 bytes .../7c7fba66680ef796b916b067077cc246adacf01d | Bin 0 -> 24576 bytes .../b8548dc30aa1030df0ce18ef08b882cf7ab5212f | Bin 0 -> 8192 bytes .../e794f6ffcc3c222535f47684a63d5178da3c4500 | Bin 0 -> 8192 bytes tests/ios_backup/test_interactionc.py | 105 ++++++ tests/ios_backup/test_whatsapp.py | 71 ++++ tests/ios_backup/test_whatsapp_contacts.py | 83 +++++ tests/ios_fs/test_filesystem.py | 8 +- 13 files changed, 1063 insertions(+), 17 deletions(-) create mode 100644 src/mvt/ios/modules/mixed/whatsapp_contacts.py create mode 100644 tests/artifacts/ios_backup/1f/1f5a521220a3ad80ebfdc196978df8e7a2e49dee create mode 100644 tests/artifacts/ios_backup/7c/7c7fba66680ef796b916b067077cc246adacf01d create mode 100644 tests/artifacts/ios_backup/b8/b8548dc30aa1030df0ce18ef08b882cf7ab5212f create mode 100644 tests/artifacts/ios_backup/e7/e794f6ffcc3c222535f47684a63d5178da3c4500 create mode 100644 tests/ios_backup/test_interactionc.py create mode 100644 tests/ios_backup/test_whatsapp_contacts.py diff --git a/docs/ios/records.md b/docs/ios/records.md index 016c8618..b34183ad 100644 --- a/docs/ios/records.md +++ b/docs/ios/records.md @@ -417,7 +417,21 @@ If indicators are provided through the command-line, they are checked against th Backup: :material-check: Full filesystem dump: :material-check: -This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*. +This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*, along with one record per chat session (marked with `"record_type": "chat_session"`) containing the first and last interaction dates of each conversation. Chat sessions produce `chat_first_message` and `chat_last_message` timeline events, and group chats additionally produce a `group_created` event. A chat session's last-message date can postdate its newest stored message when the most recent messages in the chat were deleted. + +Recent WhatsApp versions key 1:1 chat sessions by an opaque LID identifier rather than the contact's phone number. The module resolves these using the `ZWAPHONENUMBERLIDPAIR` table from the *LID.sqlite* database in the same app group (or from *ChatStorage.sqlite* itself in versions that store it there), populating `partner_resolved_phone_number` on chat session records and using the phone number in timeline events. Each LID-phone number pair is also extracted as a record (`"record_type": "lid_phone_number_pair"`) and produces a `lid_pair_recorded` timeline event marking when WhatsApp learned the association. If indicators are provided through the command-line, they are checked against the extracted HTTP links. Any matches are stored in *whatsapp_detected.json*. +--- + +### `whatsapp_contacts.json` + +!!! info "Availability" + Backup: :material-check: + Full filesystem dump: :material-check: + +This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extracts WhatsApp contact records from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ContactsV2.sqlite*, including each contact's phone number, WhatsApp and LID identifiers, and the per-contact disappearing messages timer, which is not recorded in *ChatStorage.sqlite*. Each timestamp stored on a contact record produces a timeline event: `disappearing_mode_set` (when the disappearing messages timer was last changed), `about_changed` (when the contact last changed their "about" text), `about_expiration` (when a timed "about" is scheduled to expire) and `contact_last_updated` (when the contact record was last updated). + +This database is often missing from incremental backups. When it cannot be found, the module logs a warning and produces no results, in which case the disappearing messages state of chats cannot be determined from the backup. + diff --git a/src/mvt/ios/modules/mixed/__init__.py b/src/mvt/ios/modules/mixed/__init__.py index 0e50c262..a846bf2e 100644 --- a/src/mvt/ios/modules/mixed/__init__.py +++ b/src/mvt/ios/modules/mixed/__init__.py @@ -26,6 +26,7 @@ from .tcc import TCC from .webkit_resource_load_statistics import WebkitResourceLoadStatistics from .webkit_session_resource_log import WebkitSessionResourceLog from .whatsapp import Whatsapp +from .whatsapp_contacts import WhatsappContacts MIXED_MODULES = [ Calls, @@ -47,6 +48,7 @@ MIXED_MODULES = [ WebkitResourceLoadStatistics, WebkitSessionResourceLog, Whatsapp, + WhatsappContacts, Shortcuts, Applications, Calendar, diff --git a/src/mvt/ios/modules/mixed/interactionc.py b/src/mvt/ios/modules/mixed/interactionc.py index 81a67e26..4d213826 100644 --- a/src/mvt/ios/modules/mixed/interactionc.py +++ b/src/mvt/ios/modules/mixed/interactionc.py @@ -3,9 +3,11 @@ # 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 logging +import re import sqlite3 -from typing import Optional +from typing import Optional, Tuple from mvt.common.module_types import ( ModuleAtomicResult, @@ -15,6 +17,7 @@ from mvt.common.module_types import ( from mvt.common.utils import convert_mactime_to_iso from ..base import IOSExtraction +from .whatsapp_contacts import WhatsappContacts INTERACTIONC_BACKUP_IDS = [ "1f5a521220a3ad80ebfdc196978df8e7a2e49dee", @@ -22,6 +25,49 @@ INTERACTIONC_BACKUP_IDS = [ INTERACTIONC_ROOT_PATHS = [ "private/var/mobile/Library/CoreDuet/People/interactionC.db", ] + +# The interaction record's creation date normally trails its start date by +# milliseconds: emitting it as a timeline event only duplicates the start +# date event. A large divergence, however, indicates the record was +# backfilled (sync, restore, or tampering) and is worth surfacing. +CREATION_DATE_DIVERGENCE_THRESHOLD = 3600.0 + +# Per-contact aggregate dates from ZCONTACTS are repeated on every +# interaction row of the same contact. They are serialized with a +# contact-centric data string so that timeline de-duplication collapses +# them into one event per contact. +CONTACT_EVENT_TEMPLATES = { + "contacts_creation_date": "Contact {party} first recorded in interactionC", + "first_incoming_sender_date": "First incoming interaction from {party}", + "last_incoming_sender_date": "Last incoming interaction from {party}", + "first_incoming_recipient_date": ( + "First incoming interaction where {party} was a recipient" + ), + "last_incoming_recipient_date": ( + "Last incoming interaction where {party} was a recipient" + ), + "first_outgoing_recipient_date": ( + "First outgoing interaction to {party}" + ), + "last_outgoing_recipient_date": ( + "Last outgoing interaction to {party}" + ), +} + + +def _parse_iso(timestamp) -> Optional[datetime.datetime]: + try: + return datetime.datetime.strptime( + timestamp, "%Y-%m-%d %H:%M:%S.%f" + ) + except (TypeError, ValueError): + return None + + +def _describe_delta(seconds: float) -> str: + if seconds >= 86400: + return f"{seconds / 86400:.0f} days" + return f"{seconds / 3600:.0f} hours" # Taken from APOLLO # https://github.com/mac4n6/APOLLO/blob/master/modules/interaction_contact_interactions.txt QUERIES = [ @@ -34,7 +80,7 @@ QUERIES = [ CASE ZINTERACTIONS.ZDIRECTION WHEN '0' THEN 'INCOMING' WHEN '1' THEN 'OUTGOING' - END 'DIRECTION' AS "direction", + END AS "direction", ZCONTACTS.ZDISPLAYNAME AS "sender_display_name", ZCONTACTS.ZIDENTIFIER AS "sender_identifier", ZCONTACTS.ZPERSONID AS "sender_personid", @@ -89,7 +135,7 @@ QUERIES = [ CASE ZINTERACTIONS.ZDIRECTION WHEN '0' THEN 'INCOMING' WHEN '1' THEN 'OUTGOING' - END 'DIRECTION' AS "direction", + END AS "direction", ZCONTACTS.ZDISPLAYNAME AS "sender_display_name", ZCONTACTS.ZIDENTIFIER AS "sender_identifier", ZCONTACTS.ZPERSONID AS "sender_personid", @@ -117,7 +163,7 @@ QUERIES = [ CASE ZCONTACTS.ZLASTINCOMINGRECIPIENTDATE WHEN '0' THEN '0' ELSE ZCONTACTS.ZLASTINCOMINGRECIPIENTDATE - END 'LAST INCOMING RECIPIENT DATE' AS "last_incoming_recipient_date", + END AS "last_incoming_recipient_date", ZCONTACTS.ZLASTOUTGOINGRECIPIENTDATE AS "last_outgoing_recipient_date", ZCONTACTS.ZCUSTOMIDENTIFIER AS "custom_id", ZINTERACTIONS.ZCONTENTURL AS "interaction_content_url", @@ -218,9 +264,16 @@ QUERIES = [ ] +WHATSAPP_BUNDLE_ID = "net.whatsapp.WhatsApp" + + class InteractionC(IOSExtraction): """This module extracts data from InteractionC db.""" + # WhatsApp identifies chat peers by LID in interactionC.db, which only the + # WhatsApp contacts database can map back to a phone number and name. + dependencies = [WhatsappContacts] + def __init__( self, file_path: Optional[str] = None, @@ -252,10 +305,51 @@ class InteractionC(IOSExtraction): "last_outgoing_recipient_date", ] + @staticmethod + def _describe_party(record: ModuleAtomicResult, prefix: str) -> Optional[str]: + name = record.get(f"{prefix}_display_name") or record.get( + f"{prefix}_resolved_name" + ) + identifier = record.get(f"{prefix}_resolved_phone_number") or record.get( + f"{prefix}_identifier" + ) + if name and identifier: + # A display name that is just a formatted copy of the phone + # number adds no information. + name_digits = re.sub(r"\D", "", name) + if name_digits and name_digits == re.sub(r"\D", "", identifier): + return identifier + return f"{name} ({identifier})" + return name or identifier or None + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: + sender = self._describe_party(record, "sender") + # The chat peer from the domain identifier stands in when the + # recipient was not recorded (or the recipient join is unavailable). + recipient = self._describe_party(record, "recipient") or self._describe_party( + record, "domain" + ) + direction = record.get("direction") + if not sender and direction == "OUTGOING": + sender = "local user" + if not recipient and direction == "INCOMING": + recipient = "local user" + + header = f"[{record['bundle_id']}]" + if record.get("account"): + header += f" {record['account']}" + if direction: + header += f" {direction}" + + data = f"{header} from {sender or 'unknown'} to {recipient or 'unknown'}" + if record.get("group_name"): + data += f" (group: {record['group_name']})" + if record.get("content"): + data += f": {record['content']}" + records = [] processed = [] - for timestamp in self.timestamps: + for timestamp in ("start_date", "end_date"): # Check if the record has the current timestamp. if timestamp not in record or not record[timestamp]: continue @@ -269,16 +363,142 @@ class InteractionC(IOSExtraction): "timestamp": record[timestamp], "module": self.__class__.__name__, "event": timestamp, - "data": f"[{record['bundle_id']}] {record['account']} - " - f"from {record['sender_display_name']} ({record['sender_identifier']}) " - f"to {record.get('recipient_display_name', '')} ({record.get('recipient_identifier', '')}):" - f" {record.get('content', '')}", + "data": data, } ) processed.append(record[timestamp]) + creation_event = self._serialize_creation_date(record, data) + if creation_event: + records.append(creation_event) + + # Contact-level aggregates describe the sender's contact record. + party = self._describe_party(record, "sender") + if party: + for field, template in CONTACT_EVENT_TEMPLATES.items(): + if not record.get(field): + continue + records.append( + { + "timestamp": record[field], + "module": self.__class__.__name__, + "event": field, + "data": template.format(party=party), + } + ) + return records + def _serialize_creation_date( + self, record: ModuleAtomicResult, data: str + ) -> Optional[dict]: + """Serialize the interaction record's creation date only when it + diverges from the start date enough to indicate the record was + backfilled.""" + creation = record.get("interactions_creation_date") + if not creation: + return None + + event = { + "timestamp": creation, + "module": self.__class__.__name__, + "event": "interactions_creation_date", + "data": data, + } + + start = _parse_iso(record.get("start_date")) + creation_parsed = _parse_iso(creation) + if not start or not creation_parsed: + # Without a start date the creation date is the only anchor. + return event + + delta = (creation_parsed - start).total_seconds() + if abs(delta) < CREATION_DATE_DIVERGENCE_THRESHOLD: + return None + + direction = "after" if delta > 0 else "before" + event["data"] = ( + f"Interaction record created {_describe_delta(abs(delta))} " + f"{direction} the event: {data}" + ) + return event + + def _whatsapp_contact_maps(self) -> Tuple[dict, dict]: + """Build LID and phone-digit lookup maps from the WhatsappContacts + module results, when available.""" + by_lid: dict = {} + by_phone: dict = {} + contacts_module = self.dependency_modules.get(WhatsappContacts) + if not contacts_module: + return by_lid, by_phone + + for contact in contacts_module.results: + name = contact.get("full_name") or contact.get("given_name") + phone = contact.get("phone_number") + entry = (phone, name) + if contact.get("lid"): + by_lid[contact["lid"]] = entry + if phone: + by_phone[re.sub(r"\D", "", phone)] = entry + whatsapp_id = contact.get("whatsapp_id") + if whatsapp_id and "@" in whatsapp_id: + by_phone.setdefault(whatsapp_id.split("@")[0], entry) + + return by_lid, by_phone + + @staticmethod + def _resolve_whatsapp_identifier( + value, by_lid: dict, by_phone: dict + ) -> Tuple[Optional[str], Optional[str]]: + """Resolve a WhatsApp identifier (LID, JID or phone number) to a + (phone_number, contact_name) tuple.""" + if not value: + return None, None + + value = str(value) + if value.endswith("@lid"): + return by_lid.get(value, (None, None)) + if value.endswith("@g.us"): + return None, None + if value.endswith("@s.whatsapp.net"): + digits = value.split("@")[0] + phone, name = by_phone.get(digits, (None, None)) + return phone or f"+{digits}", name + if value.startswith("+"): + _, name = by_phone.get(re.sub(r"\D", "", value), (None, None)) + return None, name + + return None, None + + def _postprocess_results(self) -> None: + by_lid, by_phone = self._whatsapp_contact_maps() + + for entry in self.results: + # The fallback queries return ZDIRECTION raw instead of labelled. + if entry.get("direction") in (0, "0"): + entry["direction"] = "INCOMING" + elif entry.get("direction") in (1, "1"): + entry["direction"] = "OUTGOING" + + if entry.get("bundle_id") != WHATSAPP_BUNDLE_ID: + continue + + candidates = { + "sender": [entry.get("sender_identifier"), entry.get("custom_id")], + "recipient": [entry.get("recipient_identifier")], + "domain": [entry.get("domain_identifier")], + } + for prefix, values in candidates.items(): + phone = name = None + for value in values: + phone, name = self._resolve_whatsapp_identifier( + value, by_lid, by_phone + ) + if phone or name: + break + entry[f"{prefix}_resolved_phone_number"] = phone + entry[f"{prefix}_resolved_name"] = name + def run(self) -> None: self._find_ios_database( backup_ids=INTERACTIONC_BACKUP_IDS, root_paths=INTERACTIONC_ROOT_PATHS @@ -325,4 +545,6 @@ class InteractionC(IOSExtraction): cur.close() conn.close() + self._postprocess_results() + self.log.info("Extracted a total of %d InteractionC events", len(self.results)) diff --git a/src/mvt/ios/modules/mixed/whatsapp.py b/src/mvt/ios/modules/mixed/whatsapp.py index 0a80aad6..89601dfb 100644 --- a/src/mvt/ios/modules/mixed/whatsapp.py +++ b/src/mvt/ios/modules/mixed/whatsapp.py @@ -4,7 +4,9 @@ # https://license.mvt.re/1.1/ import logging -from typing import Optional +import os +import sqlite3 +from typing import Dict, Optional from mvt.common.module_types import ( ModuleAtomicResult, @@ -22,9 +24,71 @@ WHATSAPP_ROOT_PATHS = [ "private/var/mobile/Containers/Shared/AppGroup/*/ChatStorage.sqlite", ] +WHATSAPP_LID_BACKUP_IDS = [ + # SHA-1 of "AppDomainGroup-group.net.whatsapp.WhatsApp.shared-LID.sqlite" + "e794f6ffcc3c222535f47684a63d5178da3c4500", +] +WHATSAPP_LID_ROOT_PATHS = [ + "private/var/mobile/Containers/Shared/AppGroup/*/LID.sqlite", +] + +# WhatsApp records the mapping between a contact's LID and phone number +# identifiers in the ZWAPHONENUMBERLIDPAIR table. Depending on the WhatsApp +# version this lives in a dedicated LID.sqlite database or in +# ChatStorage.sqlite itself. +LID_PAIRS_QUERY = """ + SELECT + ZLID AS "lid", + ZPHONENUMBER AS "phone_number", + ZTIMESTAMP AS "pair_timestamp" + FROM ZWAPHONENUMBERLIDPAIR; +""" + +CHAT_SESSIONS_QUERY = """ + SELECT + ZWACHATSESSION.Z_PK AS "session_pk", + ZWACHATSESSION.ZCONTACTJID AS "contact_jid", + ZWACHATSESSION.ZPARTNERNAME AS "partner_name", + ZWACHATSESSION.ZSESSIONTYPE AS "session_type", + ZWACHATSESSION.ZARCHIVED AS "archived", + ZWACHATSESSION.ZREMOVED AS "removed", + ZWACHATSESSION.ZMESSAGECOUNTER AS "message_counter", + ZWACHATSESSION.ZLASTMESSAGEDATE AS "last_message_date", + ZWAGROUPINFO.ZCREATIONDATE AS "group_creation_date", + MIN(ZWAMESSAGE.ZMESSAGEDATE) AS "first_stored_message_date", + MAX(ZWAMESSAGE.ZMESSAGEDATE) AS "last_stored_message_date", + COUNT(ZWAMESSAGE.Z_PK) AS "stored_message_count" + FROM ZWACHATSESSION + LEFT JOIN ZWAGROUPINFO + ON ZWACHATSESSION.ZGROUPINFO = ZWAGROUPINFO.Z_PK + LEFT JOIN ZWAMESSAGE + ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK + GROUP BY ZWACHATSESSION.Z_PK; +""" + +CHAT_SESSION_DATE_FIELDS = [ + "last_message_date", + "group_creation_date", + "first_stored_message_date", + "last_stored_message_date", +] + + +def _describe_chat(record: ModuleAtomicResult) -> str: + jid = record.get("contact_jid") or "unknown" + name = record.get("partner_name") + identifier = record.get("partner_resolved_phone_number") or jid + label = f"'{name}' ({identifier})" if name else identifier + is_group = jid.endswith("@g.us") or record.get("group_creation_date") + if is_group: + return f"WhatsApp group chat {label}" + return f"WhatsApp chat with {label}" + class Whatsapp(IOSExtraction): - """This module extracts all WhatsApp messages containing links.""" + """This module extracts all WhatsApp messages containing links, as well + as per-chat records with the first and last interaction dates of each + conversation.""" def __init__( self, @@ -45,6 +109,21 @@ class Whatsapp(IOSExtraction): ) def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: + if record.get("record_type") == "chat_session": + return self._serialize_chat_session(record) + if record.get("record_type") == "lid_phone_number_pair": + if not record.get("pair_timestamp"): + return [] + return { + "timestamp": record["pair_timestamp"], + "module": self.__class__.__name__, + "event": "lid_pair_recorded", + "data": ( + f"WhatsApp associated LID {record.get('lid')} with " + f"phone number {record.get('phone_number')}" + ), + } + text = record.get("ZTEXT", "").replace("\n", "\\n") links_text = "" if record.get("links"): @@ -57,6 +136,49 @@ class Whatsapp(IOSExtraction): "data": f"'{text}' from {record.get('ZFROMJID', 'Unknown')}{links_text}", } + def _serialize_chat_session( + self, record: ModuleAtomicResult + ) -> ModuleSerializedResult: + records = [] + chat = _describe_chat(record) + + if record.get("group_creation_date"): + records.append( + { + "timestamp": record["group_creation_date"], + "module": self.__class__.__name__, + "event": "group_created", + "data": f"{chat} was created", + } + ) + + if record.get("first_stored_message_date"): + records.append( + { + "timestamp": record["first_stored_message_date"], + "module": self.__class__.__name__, + "event": "chat_first_message", + "data": f"First stored message in {chat}", + } + ) + + # The chat session's own last-message date is authoritative: it can + # postdate the newest stored message if that message was deleted. + last_message_date = record.get("last_message_date") or record.get( + "last_stored_message_date" + ) + if last_message_date: + records.append( + { + "timestamp": last_message_date, + "module": self.__class__.__name__, + "event": "chat_last_message", + "data": f"Last message in {chat}", + } + ) + + return records + def check_indicators(self) -> None: if not self.indicators: return @@ -146,7 +268,126 @@ class Whatsapp(IOSExtraction): message["links"] = list(set(filtered_links)) self.results.append(message) + total_messages = len(self.results) + lid_map = self._extract_lid_pairs(cur) + total_sessions = self._extract_chat_sessions(cur, lid_map) + cur.close() conn.close() - self.log.info("Extracted a total of %d WhatsApp messages", len(self.results)) + self.log.info( + "Extracted a total of %d WhatsApp messages, %d chat sessions " + "and %d LID-phone number pairs", + total_messages, + total_sessions, + len(lid_map), + ) + + def _find_lid_db_path(self) -> Optional[str]: + for backup_id in WHATSAPP_LID_BACKUP_IDS: + file_path = self._get_backup_file_from_id(backup_id) + if file_path and os.path.exists(file_path): + return file_path + for found_path in self._get_fs_files_from_patterns( + WHATSAPP_LID_ROOT_PATHS + ): + return found_path + return None + + def _extract_lid_pairs(self, chat_cur: sqlite3.Cursor) -> Dict[str, str]: + """Extract the LID to phone number mapping from the dedicated + LID.sqlite database, falling back to the same table in + ChatStorage.sqlite. Returns a map of LID digits to phone number + digits.""" + rows = [] + lid_db_path = self._find_lid_db_path() + if lid_db_path: + self.log.info( + "Found WhatsApp LID database at path: %s", lid_db_path + ) + lid_conn = self._open_sqlite_db(lid_db_path) + try: + lid_cur = lid_conn.cursor() + lid_cur.execute(LID_PAIRS_QUERY) + rows = lid_cur.fetchall() + lid_cur.close() + except sqlite3.DatabaseError as exc: + self.log.warning( + "Unable to extract WhatsApp LID-phone number pairs: %s", + exc, + ) + finally: + lid_conn.close() + else: + try: + chat_cur.execute(LID_PAIRS_QUERY) + rows = chat_cur.fetchall() + except sqlite3.OperationalError: + self.log.info( + "No WhatsApp LID database found in this backup or " + "filesystem dump: LID chat identifiers cannot be " + "resolved to phone numbers" + ) + + lid_map: Dict[str, str] = {} + for lid, phone_number, pair_timestamp in rows: + record = { + "record_type": "lid_phone_number_pair", + "lid": lid, + "phone_number": phone_number, + "pair_timestamp": ( + convert_mactime_to_iso(pair_timestamp) or None + ) + if pair_timestamp + else None, + } + self.results.append(record) + + if lid and phone_number: + lid_digits = str(lid).split("@")[0] + phone_digits = str(phone_number).split("@")[0].lstrip("+") + lid_map[lid_digits] = phone_digits + + return lid_map + + def _extract_chat_sessions( + self, cur: sqlite3.Cursor, lid_map: Dict[str, str] + ) -> int: + """Extract one record per chat session with the first and last + interaction dates of each conversation.""" + try: + cur.execute(CHAT_SESSIONS_QUERY) + except sqlite3.OperationalError as exc: + self.log.warning( + "Unable to extract WhatsApp chat sessions: %s", exc + ) + return 0 + + names = [description[0] for description in cur.description] + + total_sessions = 0 + for row in cur.fetchall(): + session = dict(zip(names, row)) + session["record_type"] = "chat_session" + + session["partner_resolved_phone_number"] = None + jid = session.get("contact_jid") or "" + if jid.endswith("@lid"): + phone_digits = lid_map.get(jid.split("@")[0]) + if phone_digits: + session["partner_resolved_phone_number"] = ( + f"+{phone_digits}" + ) + + for field in CHAT_SESSION_DATE_FIELDS: + if session.get(field): + session[field] = ( + convert_mactime_to_iso(session[field]) or None + ) + else: + session[field] = None + + self.results.append(session) + total_sessions += 1 + + return total_sessions diff --git a/src/mvt/ios/modules/mixed/whatsapp_contacts.py b/src/mvt/ios/modules/mixed/whatsapp_contacts.py new file mode 100644 index 00000000..309da22b --- /dev/null +++ b/src/mvt/ios/modules/mixed/whatsapp_contacts.py @@ -0,0 +1,308 @@ +# 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 +import sqlite3 +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 + +WHATSAPP_CONTACTS_BACKUP_IDS = [ + # SHA-1 of "AppDomainGroup-group.net.whatsapp.WhatsApp.shared-ContactsV2.sqlite" + "b8548dc30aa1030df0ce18ef08b882cf7ab5212f", +] +WHATSAPP_CONTACTS_ROOT_PATHS = [ + "private/var/mobile/Containers/Shared/AppGroup/*/ContactsV2.sqlite", +] + +# WhatsApp's standard disappearing-messages timer values, in seconds. +DISAPPEARING_DURATION_LABELS = { + 86400: "24 hours", + 604800: "7 days", + 1209600: "14 days", + 2592000: "30 days", + 7776000: "90 days", +} + +# Output field -> candidate columns in ZWAADDRESSBOOKCONTACT, in order of +# preference. WhatsApp renames columns across versions, so the query is built +# from the columns actually present in the database. +COLUMN_CANDIDATES = { + "whatsapp_id": ["ZWHATSAPPID"], + "lid": ["ZLID"], + "phone_number": ["ZPHONENUMBER"], + "localized_phone_number": ["ZLOCALIZEDPHONENUMBER"], + "full_name": ["ZFULLNAME"], + "given_name": ["ZGIVENNAME"], + "last_name": ["ZLASTNAME"], + "user_name": ["ZUSERNAME"], + "business_name": ["ZBUSINESSNAME"], + "about_text": ["ZABOUTTEXT"], + "about_emoji": ["ZABOUTEMOJI"], + "notes": ["ZNOTES"], + "disappearing_mode_duration": ["ZDISAPPEARINGMODEDURATION"], + "disappearing_mode_timestamp": ["ZDISAPPEARINGMODETIMESTAMP"], + "about_timestamp": ["ZABOUTTIMESTAMP"], + "about_expiration_timestamp": ["ZABOUTEXPIRATIONTIMESTAMP"], + "last_updated": ["ZLASTUPDATED"], + "phone_status": ["ZPHONESTATUS", "ZPHONENUMBERSTATUS"], + "sync_policy": ["ZSYNCPOLICY"], +} + +STRING_FIELDS = [ + "whatsapp_id", + "lid", + "phone_number", + "localized_phone_number", + "full_name", + "given_name", + "last_name", + "user_name", + "business_name", + "about_text", + "about_emoji", + "notes", +] + +DATE_FIELDS = [ + "disappearing_mode_timestamp", + "about_timestamp", + "about_expiration_timestamp", + "last_updated", +] + + +def _decode_string(value) -> Optional[str]: + # CoreData stores string attributes as UTF-8 blobs in some WhatsApp + # versions, so values can arrive as either bytes or str. + if value is None: + return None + if isinstance(value, bytes): + return value.decode("utf-8", "replace") + return str(value) + + +def _label_duration(duration) -> str: + if not duration: + return "off" + return DISAPPEARING_DURATION_LABELS.get( + int(duration), f"{int(duration)} seconds" + ) + + +def _describe_contact(record: ModuleAtomicResult) -> str: + contact = ( + record.get("whatsapp_id") + or record.get("lid") + or record.get("phone_number") + or "unknown" + ) + full_name = record.get("full_name") + if full_name: + contact = f"{contact} ({full_name})" + return contact + + +class WhatsappContacts(IOSExtraction): + """This module extracts WhatsApp contact records and per-contact + disappearing-messages settings from ContactsV2.sqlite. + + ChatStorage.sqlite does not record the disappearing-messages state of 1:1 + chats: the authoritative timer is stored on each contact record in this + database, alongside the mapping between a contact's LID and phone number + identifiers. + """ + + 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, + ) + + def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: + records = [] + contact = _describe_contact(record) + + if record.get("disappearing_mode_timestamp"): + records.append( + { + "timestamp": record["disappearing_mode_timestamp"], + "module": self.__class__.__name__, + "event": "disappearing_mode_set", + "data": ( + f"WhatsApp disappearing messages timer set to " + f"'{record.get('disappearing_mode_label')}' " + f"for {contact}" + ), + } + ) + + if record.get("about_timestamp"): + data = f"WhatsApp about text of {contact} changed" + about_text = record.get("about_text") + if about_text: + data += f' to "{about_text}"' + records.append( + { + "timestamp": record["about_timestamp"], + "module": self.__class__.__name__, + "event": "about_changed", + "data": data, + } + ) + + if record.get("about_expiration_timestamp"): + records.append( + { + "timestamp": record["about_expiration_timestamp"], + "module": self.__class__.__name__, + "event": "about_expiration", + "data": ( + f"WhatsApp about text of {contact} scheduled " + f"to expire" + ), + } + ) + + if record.get("last_updated"): + records.append( + { + "timestamp": record["last_updated"], + "module": self.__class__.__name__, + "event": "contact_last_updated", + "data": f"WhatsApp contact record for {contact} updated", + } + ) + + return records + + def run(self) -> None: + try: + self._find_ios_database( + backup_ids=WHATSAPP_CONTACTS_BACKUP_IDS, + root_paths=WHATSAPP_CONTACTS_ROOT_PATHS, + ) + except DatabaseNotFoundError: + self.log.warning( + "Unable to find the WhatsApp ContactsV2.sqlite database in " + "this backup or filesystem dump. WhatsApp disappearing " + "messages settings and contact records cannot be extracted. " + "This database is often missing from incremental backups." + ) + return + + self.log.info( + "Found WhatsApp contacts database at path: %s", self.file_path + ) + + assert self.file_path is not None + conn = self._open_sqlite_db(self.file_path) + cur = conn.cursor() + try: + try: + cur.execute("PRAGMA table_info(ZWAADDRESSBOOKCONTACT)") + available_columns = {row[1] for row in cur.fetchall()} + except sqlite3.DatabaseError as exc: + self.log.error( + "Unable to read the ZWAADDRESSBOOKCONTACT table schema: %s", + exc, + ) + return + + if not available_columns: + self.log.warning( + "The WhatsApp contacts database does not contain a " + "ZWAADDRESSBOOKCONTACT table" + ) + return + + selected = {} + for field, candidates in COLUMN_CANDIDATES.items(): + for candidate in candidates: + if candidate in available_columns: + selected[field] = candidate + break + + # A record with no duration column is "unknown", not "off": the + # timer state cannot be determined from this database version. + has_duration = "disappearing_mode_duration" in selected + if not has_duration: + self.log.warning( + "The ZDISAPPEARINGMODEDURATION column is not present in " + "this WhatsApp contacts database: disappearing messages " + "state is unknown" + ) + + columns = ["Z_PK"] + list(selected.values()) + cur.execute( + f"SELECT {', '.join(columns)} FROM ZWAADDRESSBOOKCONTACT;" + ) + fields = ["row_pk"] + list(selected.keys()) + + for row in cur: + record = dict(zip(fields, row)) + + for field in STRING_FIELDS: + if field in record: + record[field] = _decode_string(record[field]) + else: + record[field] = None + + for field in DATE_FIELDS: + if record.get(field) is not None: + record[field] = ( + convert_mactime_to_iso(record[field]) or None + ) + else: + record[field] = None + + duration = record.get("disappearing_mode_duration") + if has_duration: + record["disappearing_mode_is_on"] = bool(duration) + record["disappearing_mode_label"] = _label_duration( + duration + ) + else: + record["disappearing_mode_duration"] = None + record["disappearing_mode_is_on"] = None + record["disappearing_mode_label"] = None + + record.setdefault("phone_status", None) + record.setdefault("sync_policy", None) + + self.results.append(record) + finally: + cur.close() + conn.close() + + total_ephemeral = sum( + 1 for record in self.results if record["disappearing_mode_is_on"] + ) + self.log.info( + "Extracted a total of %d WhatsApp contacts (%d with disappearing " + "messages enabled)", + len(self.results), + total_ephemeral, + ) diff --git a/tests/artifacts/ios_backup/1f/1f5a521220a3ad80ebfdc196978df8e7a2e49dee b/tests/artifacts/ios_backup/1f/1f5a521220a3ad80ebfdc196978df8e7a2e49dee new file mode 100644 index 0000000000000000000000000000000000000000..1d278325152b6c4a2d7bcc67d721b5a364b26f24 GIT binary patch literal 24576 zcmeI2&u`;I6vxMj(`**4Tos{QkZP$yT~uV6%>swL)Ul`OX#FF2#tY45N!g&S)NTVC z3HE|0>|y@_B>n;p{1qHHAnlp|fq#J;GqIhHQ+rEqsQOOgcrx$3d2c@RW~6b#KMjuO zQ~Rs4Z%!w3yHD1MVURuBCWNfzm%J@XOMaSpUEK_=URPZs&d;y!NNKC{Ho5cHTR+@> z)cHeJQ9u9$KmY_l00cnb|3u*2X|=l#9vJ7(=aZw8>G<(I773cTo?vm5P>+omjYLiJ znwL;lP+PczfZ8=m_TAfdUZlpdOKvMiLz;Y`iThQ~3&n-r`{$O~?(Xau=V=~rPqX3U z$EB~Wl>ke^QeZt-2-iCZbkWoJqH2~Sxe%117(FZo zYmX8Zy2+vakRED;yjVhugrJ`bTYYGvRIt^iTsuvIRS9Qf%A&!cpv%E%U5yT65eKp7 z2F#b<*gm7lY7d%Z5X1w`+6&^;*TgE$jMU4d(e7?;8n5zXQ}M}6zEK@3)ugAz| zd=$8cksHd)EHhe(N(*{K6COv*U$yX3F5jzyjnN5f@advO|xViq+Ag(9bsPm?;W zI2FVAzjU_%C6}nL)}LI3f#zzTC0rE#mVsTVnMGEsb}6(f&A`3zT;HFr^~U&=V!huy zsqPx>?)J9v%F32sX*{Yo*}y91mg#@|nb}cJzv%mlIHzlYSh!OfHSuoq#8lkK%~air(wDAho}!kka(>Acsku zj;a>0TsrI#_0`VePSg)<0)>em6Ol4+nO7_j@17vfJD3IZwaeQ%?`xZLAYg7%c2Gt=*3eDecSGvQ#`F zsAy8>fBC0>6c7Lb5C8!X009sH0T2KI5C8!X0D&7!0O$W3Jh>Pe2!H?xfB*=900@8p z2!H?xfB*>O1l0N8u>K+PjRFE700JNY0w4eaAOHd&00JNY0w8eH2(*l*VKf^50&X<` A5&!@I literal 0 HcmV?d00001 diff --git a/tests/artifacts/ios_backup/7c/7c7fba66680ef796b916b067077cc246adacf01d b/tests/artifacts/ios_backup/7c/7c7fba66680ef796b916b067077cc246adacf01d new file mode 100644 index 0000000000000000000000000000000000000000..b8ae144dd2db59a308c6f17793c47e861eeaee6d GIT binary patch literal 24576 zcmeI(!B5jr90%~%Zetq@Ok?5>Udn;s64R}K7d^P=_Sh=xI=eR!5(8sam<~4C2p$FE z)tia`h!;=ZO}u&ZAK>oY*R5lvl*1BZ4Eesm`gp(hUVr_(K9a&#ds=SwZRJ(xV6V|v zmdFhvNhGHzgpeusTO0-@B3{F8-8o2}`c>5_qJH{5BchSSZ8Cc{yF9ay_$sO>5P$## zAOHafKmY;|_>TmR=OWS6{JeA$={L69_Qo15T4uefS5{1KeJXD;%9+CH<1$mc5#{c* z;+Bog>Z+nwI4d$ssam>0t#xIUt=}8>$d@Ruv06X z?saX^6p1Rwwb2tWV=5P$##AOHafKwwgVOejtyO74VX zFzy+*gI?%Vw}yWQ;U+3I%l8!=0uX=z z1Rwwb2tWV=5P$##AOL}DEP&_#YrMF)GzdTd0uX=z1Rwwb2tWV=5P*Oy;5`4!$QNgyu%Iv|B*b?wX6AGH~S5dZ)H literal 0 HcmV?d00001 diff --git a/tests/artifacts/ios_backup/b8/b8548dc30aa1030df0ce18ef08b882cf7ab5212f b/tests/artifacts/ios_backup/b8/b8548dc30aa1030df0ce18ef08b882cf7ab5212f new file mode 100644 index 0000000000000000000000000000000000000000..132059c9e044a30c9e8cf17697a344a73ccd596c GIT binary patch literal 8192 zcmeH~%Wl&^6o$tk5^|*iLWoLT4Hc?V0md$rF4%Z%56Lw4jBAfclMQmIp^=g{YK(wg zS+HTliuYm9jyK><80R94Ys$LPIr3%ZoZtSBznP8WeSer|_|56rIL+}BbPH)3vN1*o zH5RA3%|cVx=3-pjiuwPdfsAiIR+UOyeT>ur0R(^m5C8%|00;m9AOHk_01&t;1U_@^ z`r7WU_T_n=9*(j>j}Xs`XdF92bX~zEab<0?;YO55iY0M;ifbjfwSpHhcolXr*}Q|2 zulbj+`N!9+_*!N;EPFIO>SP~rew>|U_b|ih7{8kiPu}8Q(JWETYWwGg_4T{45u6+0 I9lL1!H%o*46#xJL literal 0 HcmV?d00001 diff --git a/tests/artifacts/ios_backup/e7/e794f6ffcc3c222535f47684a63d5178da3c4500 b/tests/artifacts/ios_backup/e7/e794f6ffcc3c222535f47684a63d5178da3c4500 new file mode 100644 index 0000000000000000000000000000000000000000..d54a9c5554d55ba745082b0f28c1b9ab57705d40 GIT binary patch literal 8192 zcmeI#K}*9h6bJC64u`^+H?I!_aZJXF%C5`wp00Izz00bZa0SG_<0ub1sz~_go@ArE0tC7zhU*^+WWv)gU zXE*79ZK5xX(zdb~om*Cp$*DoYRM|pDyHsZSV1}eK$Cqr$Se+_6p&?I>gAUHzUrSgX znX0nWDUY2>4f$gvLBBsUIlgm2Dfpt|Rk3Gl^>4lq&BX@!u{8uB009U<00Izz00bZa i0SG_<0{;>?lyxC@YVCuq&b{u(-7s-{8lU-eefbxJO-!r+ literal 0 HcmV?d00001 diff --git a/tests/ios_backup/test_interactionc.py b/tests/ios_backup/test_interactionc.py new file mode 100644 index 00000000..eec26569 --- /dev/null +++ b/tests/ios_backup/test_interactionc.py @@ -0,0 +1,105 @@ +# 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/ + +from mvt.common.module import run_module +from mvt.ios.modules.mixed.interactionc import InteractionC +from mvt.ios.modules.mixed.whatsapp_contacts import WhatsappContacts + +from ..utils import get_ios_backup_folder + + +class TestInteractionCModule: + def test_extraction_with_whatsapp_contacts(self): + contacts = WhatsappContacts(target_path=get_ios_backup_folder()) + run_module(contacts) + + m = InteractionC(target_path=get_ios_backup_folder()) + m.dependency_modules = {WhatsappContacts: contacts} + run_module(m) + + assert len(m.results) == 3 + + incoming = next( + r for r in m.results if r["sender_identifier"] == "100000000000001@lid" + ) + assert incoming["direction"] == "INCOMING" + assert incoming["sender_resolved_phone_number"] == "+14155550100" + assert incoming["sender_resolved_name"] == "Alice Example" + + outgoing = next( + r for r in m.results if r["direction"] == "OUTGOING" + ) + assert outgoing["recipient_identifier"] == "+14155550100" + assert outgoing["recipient_resolved_name"] == "Alice Example" + assert outgoing["domain_resolved_phone_number"] == "+14155550100" + assert outgoing["domain_resolved_name"] == "Alice Example" + + sms = next( + r for r in m.results if r["bundle_id"] == "com.apple.MobileSMS" + ) + assert sms.get("sender_resolved_name") is None + assert sms["sender_display_name"] == "Bob Example" + + events = [entry["data"] for entry in m.timeline] + assert ( + "[net.whatsapp.WhatsApp] INCOMING from " + "Alice Example (+14155550100) to local user" in events + ) + assert ( + "[net.whatsapp.WhatsApp] OUTGOING from local user to " + "Alice Example (+14155550100)" in events + ) + assert ( + "[com.apple.MobileSMS] INCOMING from " + "Bob Example (+14155550101) to local user" in events + ) + + # The creation date is only serialized when it diverges from the + # start date; the SMS record was created 90 days after the event. + creation_events = [ + entry + for entry in m.timeline + if entry["event"] == "interactions_creation_date" + ] + assert len(creation_events) == 1 + assert creation_events[0]["timestamp"] == "2025-12-09 12:26:40.000000" + assert creation_events[0]["data"] == ( + "Interaction record created 90 days after the event: " + "[com.apple.MobileSMS] INCOMING from " + "Bob Example (+14155550101) to local user" + ) + + # Per-contact aggregate dates use contact-centric data strings. + first_seen = [ + entry + for entry in m.timeline + if entry["event"] == "first_incoming_sender_date" + ] + assert len(first_seen) == 1 + assert first_seen[0]["timestamp"] == "2025-09-03 13:46:40.000000" + assert first_seen[0]["data"] == ( + "First incoming interaction from Bob Example (+14155550101)" + ) + assert ( + "Last incoming interaction from Bob Example (+14155550101)" + in events + ) + + def test_extraction_without_whatsapp_contacts(self): + # Without the WhatsappContacts dependency the module still runs, and + # unresolvable LIDs are shown as-is. + m = InteractionC(target_path=get_ios_backup_folder()) + run_module(m) + + assert len(m.results) == 3 + events = [entry["data"] for entry in m.timeline] + assert ( + "[net.whatsapp.WhatsApp] INCOMING from " + "100000000000001@lid to local user" in events + ) + assert ( + "[net.whatsapp.WhatsApp] OUTGOING from local user to " + "+14155550100" in events + ) diff --git a/tests/ios_backup/test_whatsapp.py b/tests/ios_backup/test_whatsapp.py index 864fb849..3bc920d9 100644 --- a/tests/ios_backup/test_whatsapp.py +++ b/tests/ios_backup/test_whatsapp.py @@ -6,8 +6,79 @@ import logging from mvt.common.indicators import Indicators +from mvt.common.module import run_module from mvt.ios.modules.mixed.whatsapp import Whatsapp +from ..utils import get_ios_backup_folder + + +def test_extraction(): + m = Whatsapp(target_path=get_ios_backup_folder()) + run_module(m) + + messages = [r for r in m.results if "ZTEXT" in r] + sessions = [r for r in m.results if r.get("record_type") == "chat_session"] + pairs = [ + r for r in m.results + if r.get("record_type") == "lid_phone_number_pair" + ] + assert len(messages) == 3 + assert len(sessions) == 2 + assert len(pairs) == 1 + + assert pairs[0]["lid"] == "100000000000001" + assert pairs[0]["phone_number"] == "14155550100" + assert pairs[0]["pair_timestamp"] == "2025-08-25 07:33:20.000000" + + linked = next(r for r in messages if r.get("links")) + assert linked["links"] == ["https://example.org/news"] + + alice = next(s for s in sessions if s["partner_name"] == "Alice Example") + assert alice["contact_jid"] == "100000000000001@lid" + assert alice["partner_resolved_phone_number"] == "+14155550100" + assert alice["first_stored_message_date"] == "2025-08-27 15:06:40.000000" + assert alice["last_message_date"] == "2025-08-28 18:53:20.000000" + assert alice["group_creation_date"] is None + assert alice["stored_message_count"] == 2 + + group = next(s for s in sessions if s["partner_name"] == "Example Group") + assert group["group_creation_date"] == "2025-08-21 20:13:20.000000" + assert group["first_stored_message_date"] == "2025-08-29 22:40:00.000000" + # The last stored message predates the session's own last-message date: + # the newest message in this chat was deleted. + assert group["last_stored_message_date"] == "2025-08-29 22:40:00.000000" + assert group["last_message_date"] == "2025-08-31 02:26:40.000000" + + # 3 message events, first/last per chat, the group creation and the + # LID-phone number pair. + assert len(m.timeline) == 9 + events = { + (entry["event"], entry["timestamp"]): entry["data"] + for entry in m.timeline + } + # Alice's session is keyed by LID but labelled with the phone number + # resolved through LID.sqlite. + assert events[("chat_first_message", "2025-08-27 15:06:40.000000")] == ( + "First stored message in WhatsApp chat with " + "'Alice Example' (+14155550100)" + ) + assert events[("chat_last_message", "2025-08-28 18:53:20.000000")] == ( + "Last message in WhatsApp chat with " + "'Alice Example' (+14155550100)" + ) + assert events[("lid_pair_recorded", "2025-08-25 07:33:20.000000")] == ( + "WhatsApp associated LID 100000000000001 with " + "phone number 14155550100" + ) + assert events[("group_created", "2025-08-21 20:13:20.000000")] == ( + "WhatsApp group chat 'Example Group' " + "(120000000000000001@g.us) was created" + ) + assert ("chat_first_message", "2025-08-29 22:40:00.000000") in events + assert ("chat_last_message", "2025-08-31 02:26:40.000000") in events + + assert len(m.alertstore.alerts) == 0 + def test_collect_url_results_includes_expansion(): module = Whatsapp( diff --git a/tests/ios_backup/test_whatsapp_contacts.py b/tests/ios_backup/test_whatsapp_contacts.py new file mode 100644 index 00000000..a91b8f1b --- /dev/null +++ b/tests/ios_backup/test_whatsapp_contacts.py @@ -0,0 +1,83 @@ +# 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/ + +from mvt.common.module import run_module +from mvt.ios.modules.mixed.whatsapp_contacts import WhatsappContacts + +from ..utils import get_ios_backup_folder + + +class TestWhatsappContactsModule: + def test_extraction(self): + m = WhatsappContacts(target_path=get_ios_backup_folder()) + run_module(m) + assert len(m.results) == 2 + + alice = next(r for r in m.results if r["given_name"] == "Alice") + assert alice["full_name"] == "Alice Example" + assert alice["phone_number"] == "+14155550100" + assert alice["whatsapp_id"] == "14155550100@s.whatsapp.net" + assert alice["lid"] == "100000000000001@lid" + assert alice["user_name"] == "alice.example" + assert alice["disappearing_mode_duration"] == 86400.0 + assert alice["disappearing_mode_is_on"] is True + assert alice["disappearing_mode_label"] == "24 hours" + assert alice["disappearing_mode_timestamp"] == "2025-07-23 21:46:40.000000" + assert alice["about_timestamp"] == "2025-07-12 08:00:00.000000" + assert alice["about_expiration_timestamp"] == "2025-08-16 01:20:00.000000" + assert alice["last_updated"] == "2025-08-04 11:33:20.000000" + + bob = next(r for r in m.results if r["given_name"] == "Bob") + assert bob["lid"] is None + assert bob["disappearing_mode_duration"] is None + assert bob["disappearing_mode_is_on"] is False + assert bob["disappearing_mode_label"] == "off" + assert bob["disappearing_mode_timestamp"] is None + + # Alice: disappearing_mode_set, about_changed, about_expiration and + # contact_last_updated. Bob: contact_last_updated only. + assert len(m.timeline) == 5 + + events = { + (entry["event"], entry["timestamp"]): entry["data"] + for entry in m.timeline + } + assert ( + "24 hours" + in events[("disappearing_mode_set", "2025-07-23 21:46:40.000000")] + ) + assert ( + "14155550100@s.whatsapp.net (Alice Example)" + in events[("disappearing_mode_set", "2025-07-23 21:46:40.000000")] + ) + assert ( + 'changed to "Hey there! I am using WhatsApp."' + in events[("about_changed", "2025-07-12 08:00:00.000000")] + ) + assert ( + "scheduled to expire" + in events[("about_expiration", "2025-08-16 01:20:00.000000")] + ) + + updated = [ + entry["data"] + for entry in m.timeline + if entry["event"] == "contact_last_updated" + ] + assert len(updated) == 2 + assert all( + entry["timestamp"] == "2025-08-04 11:33:20.000000" + for entry in m.timeline + if entry["event"] == "contact_last_updated" + ) + assert any("14155550101@s.whatsapp.net (Bob Example)" in d for d in updated) + + assert len(m.alertstore.alerts) == 0 + + def test_missing_database(self, tmp_path): + m = WhatsappContacts(target_path=str(tmp_path)) + run_module(m) + assert m.results == [] + assert len(m.alertstore.alerts) == 0 diff --git a/tests/ios_fs/test_filesystem.py b/tests/ios_fs/test_filesystem.py index 9fa664f4..636c004a 100644 --- a/tests/ios_fs/test_filesystem.py +++ b/tests/ios_fs/test_filesystem.py @@ -15,8 +15,8 @@ class TestFilesystem: def test_filesystem(self): m = Filesystem(target_path=get_ios_backup_folder()) run_module(m) - assert len(m.results) == 15 - assert len(m.timeline) == 15 + assert len(m.results) == 23 + assert len(m.timeline) == 23 assert len(m.alertstore.alerts) == 0 def test_detection(self, indicator_file): @@ -29,6 +29,6 @@ class TestFilesystem: ) m.indicators = ind run_module(m) - assert len(m.results) == 15 - assert len(m.timeline) == 15 + assert len(m.results) == 23 + assert len(m.timeline) == 23 assert len(m.alertstore.alerts) == 1 From dd8bd2cb01bb5275affebd93e71f311166014972 Mon Sep 17 00:00:00 2001 From: besendorf Date: Wed, 19 Aug 2026 16:46:31 +0200 Subject: [PATCH 029/109] Group custom docs under development (#878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Donncha Ó Cearbhaill --- mkdocs.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 66f3471c..a7b22acb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,7 +31,6 @@ nav: - 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" @@ -50,5 +49,7 @@ nav: - Check an Android Backup (SMS messages): "android/backup.md" - Check Android Intrusion Logs: "android/intrusion_logs.md" - Indicators of Compromise: "iocs.md" - - Development: "development.md" + - Development: + - Development Instructions: "development.md" + - Custom CLI Commands: "custom_commands.md" - License: "license.md" From dac4acb180457e2e44673d03d3a29dc845102b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Wed, 19 Aug 2026 23:15:48 +0200 Subject: [PATCH 030/109] Load installed module packages via entry points (#883) * Load installed module packages via entry points Python packages can already register custom CLI commands which load automatically, but custom modules still require --load-module or the MVT_CUSTOM_MODULES environment variable on every invocation. Add an mvt.modules entry-point group so installed packages can register forensic modules which load automatically into every module-running check-* command. An entry point resolves to an iterable of MVTModule subclasses, or a callable returning one. Broken entry points are skipped with a warning so a faulty package cannot break MVT. * Record the source of loaded modules for auditability Now that installed module packages load automatically, record where every module came from: - --list-modules groups the available modules by source, one line per source with the modules comma-separated: MVT itself with its version, each installed package with its version and VCS commit when recorded (PEP 610 direct_url.json), and each --load-module/MVT_CUSTOM_MODULES file with its SHA-256 hash. - Commands log one line per module source with its version or hash and the modules loaded from it, so command.log records exactly which modules ran and where they came from. - Make init_logging() idempotent: a loaded module package importing an MVT CLI module would previously add a second console handler and duplicate every console log line. * Route loaded module logging under the mvt.ext namespace Modules loaded from installed packages or file paths live outside the mvt logger hierarchy, so their log records never reach MVT's console and file handlers and instead fall through to logging.lastResort: alerts print as bare unformatted lines and INFO messages are dropped entirely. Add get_module_logger() and use it everywhere module loggers are created. Built-in mvt.* modules keep their existing logger names, and everything external is parented under a dedicated mvt.ext namespace so records reach the handlers and external names can never collide with MVT's internal logger tree. File-path modules are named after their file (mvt.ext.) instead of the mangled internal import name. Document a naming convention for community module packages: distribute as mvt-plugin- with import package mvt_plugin_, including the publishing organization in the name. The prefix is advisory (loading is by entry point, and it is no mark of authenticity), but conforming packages get a cleaner logger namespace: the mvt_plugin_ prefix is stripped, so mvt_plugin_amnesty_custom logs as mvt.ext.amnesty_custom. --- docs/development.md | 87 +++++++++++ src/mvt/common/cmd_check_iocs.py | 3 +- src/mvt/common/command.py | 39 ++++- src/mvt/common/help.py | 2 +- src/mvt/common/module_loader.py | 227 ++++++++++++++++++++++++++++- src/mvt/common/utils.py | 7 + tests/common/test_module_loader.py | 43 ++++++ tests/common/test_utils.py | 19 +++ tests/test_custom_modules.py | 150 +++++++++++++++++++ 9 files changed, 566 insertions(+), 11 deletions(-) diff --git a/docs/development.md b/docs/development.md index ffe1265c..53a8f612 100644 --- a/docs/development.md +++ b/docs/development.md @@ -117,6 +117,93 @@ class DependentCustomModule(MVTModule): self.results = [{"manifest_entries": len(manifest_results)}] ``` +## Installed module packages + +Python packages can register modules so they load automatically in every +module-running `check-*` command, without `--load-module` or +`MVT_CUSTOM_MODULES`. Register an entry point in the `mvt.modules` group in +the package's `pyproject.toml`: + +```toml +[project.entry-points."mvt.modules"] +mvt-plugin-amnesty-custom = "mvt_plugin_amnesty_custom:get_modules" +``` + +The entry point must resolve to an iterable of `MVTModule` subclasses, or to +a callable returning one: + +```python +from mvt.common.module import MVTModule + + +class PackagedModule(MVTModule): + supported_commands = (("ios", "check-backup"),) + + def run(self): + self.results = [{"message": "packaged module ran"}] + + +def get_modules() -> list[type[MVTModule]]: + return [PackagedModule] +``` + +Installed modules follow the same rules as other custom modules: each module +must declare `supported_commands`, and dependencies are resolved with the +standard ordering logic. A broken entry point is skipped with a warning and +does not prevent MVT from running. As with custom commands, installed module +packages run as trusted code inside the MVT process, so install only packages +from sources you trust. + +For a `pipx` installation of MVT, inject the package into MVT's environment: + +```bash +pipx inject mvt mvt-plugin-amnesty-custom +``` + +### Naming module packages + +Name module packages `mvt-plugin-` (import package `mvt_plugin_`), +and include the name of the publishing organization or author so packages from +different groups do not collide: for example, Amnesty International's custom +modules would be distributed as `mvt-plugin-amnesty-custom` with the import +package `mvt_plugin_amnesty_custom`. + +The prefix makes module packages easy to find on PyPI and keeps their import +names from clashing with unrelated Python packages. It is a convention, not a +technical requirement: modules load through the `mvt.modules` entry point +regardless of what the package is called, and existing packages with other +names keep working. Note that the prefix is also not a mark of authenticity — +anyone can publish a package with any available name, so vet a module package +and its publisher before installing it, whatever it is called. + +### Module logging + +Modules log through `self.log`, and MVT names the logger for where the module +came from. MVT's own modules log under their dotted path (for example +`mvt.ios.modules.mixed.whatsapp`). Everything external is namespaced under +`mvt.ext` to keep it visually distinct from built-in modules and isolated from +MVT's internal logger tree: + +- Installed packages log under `mvt.ext.`, with the `mvt_plugin_` + prefix stripped: modules in `mvt_plugin_amnesty_custom` log as + `mvt.ext.amnesty_custom.*`. +- Files loaded with `--load-module` or `MVT_CUSTOM_MODULES` log as + `mvt.ext.`. + +## Auditing loaded modules + +Because installed module packages load automatically, MVT records where every +module came from: + +- `--list-modules` groups the available modules by source: MVT itself + (with its version), each installed package (with its version and, when + installed directly from a repository, the commit), and each file loaded + with `--load-module` or `MVT_CUSTOM_MODULES` (with the SHA-256 hash of the + file). +- When a command runs with an `--output` folder, the `command.log` file + records one line per module source with the source's version or hash and + the list of modules loaded from it. + ## 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 diff --git a/src/mvt/common/cmd_check_iocs.py b/src/mvt/common/cmd_check_iocs.py index c227659b..c2dbdcd9 100644 --- a/src/mvt/common/cmd_check_iocs.py +++ b/src/mvt/common/cmd_check_iocs.py @@ -9,6 +9,7 @@ from typing import Optional from mvt.common.command import Command from mvt.common.module import MVTModule +from mvt.common.module_loader import get_module_logger from mvt.common.utils import exec_or_profile log = logging.getLogger(__name__) @@ -76,7 +77,7 @@ class CmdCheckIOCS(Command): ) m = iocs_module.from_json( - file_path, log=logging.getLogger(iocs_module.__module__) + file_path, log=get_module_logger(iocs_module) ) if not m: log.warning("No result from this module, skipping it") diff --git a/src/mvt/common/command.py b/src/mvt/common/command.py index aab53e65..6111dc73 100644 --- a/src/mvt/common/command.py +++ b/src/mvt/common/command.py @@ -19,7 +19,12 @@ 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_loader import ( + ModuleOrigin, + get_module_logger, + get_module_origin, + module_supports_command, +) from .module_types import ModuleTimeline, URLResult from .utils import ( CustomJSONEncoder, @@ -210,10 +215,34 @@ class Command: for file in generate_hashes_from_path(self.target_path, self.log): self.hash_values.append(file) + @staticmethod + def _modules_by_origin( + modules: list[type[MVTModule]], + ) -> dict[ModuleOrigin, list[str]]: + origins: dict[ModuleOrigin, list[str]] = {} + for module in modules: + origins.setdefault(get_module_origin(module), []).append(module.__name__) + return origins + def list_modules(self) -> None: self.log.info("Following is the list of available %s modules:", self.name) - for module in self._available_modules(): - self.log.info(" - %s", module.__name__) + for origin, module_names in self._modules_by_origin( + self._available_modules() + ).items(): + self.log.info( + " - Modules from %s: %s", origin.label, ", ".join(module_names) + ) + + def _log_loaded_modules(self, modules: list[type[MVTModule]]) -> None: + """Record the loaded modules and their origins for auditability.""" + for origin, module_names in self._modules_by_origin(modules).items(): + self.log.info( + "Loaded %d %s modules from %s: %s", + len(module_names), + self.name, + origin.label, + ", ".join(module_names), + ) def _available_modules(self) -> list[type[MVTModule]]: modules = list(self.modules) @@ -360,6 +389,8 @@ class Command: if ordered_modules is None: return + self._log_loaded_modules(ordered_modules) + try: self.init() except NotImplementedError: @@ -368,7 +399,7 @@ class Command: executed_by_type: dict[type[MVTModule], MVTModule] = {} for module in ordered_modules: - module_logger = logging.getLogger(module.__module__) + module_logger = get_module_logger(module) m = module( target_path=self.target_path, diff --git a/src/mvt/common/help.py b/src/mvt/common/help.py index 5101f939..75141135 100644 --- a/src/mvt/common/help.py +++ b/src/mvt/common/help.py @@ -8,7 +8,7 @@ HELP_MSG_VERSION = "Show the currently installed version of MVT" HELP_MSG_OUTPUT = "Specify a path to a folder where you want to store JSON results" 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_LIST_MODULES = "Print list of available modules and their source, then 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 " diff --git a/src/mvt/common/module_loader.py b/src/mvt/common/module_loader.py index da948265..956da4c3 100644 --- a/src/mvt/common/module_loader.py +++ b/src/mvt/common/module_loader.py @@ -4,18 +4,28 @@ # https://license.mvt.re/1.1/ import hashlib +import importlib.metadata import importlib.util import inspect +import json import logging import os import sys +from dataclasses import dataclass +from functools import lru_cache from pathlib import Path from types import ModuleType from typing import Iterable, Optional from .module import MVTModule +from .version import MVT_VERSION MVT_CUSTOM_MODULES_ENV = "MVT_CUSTOM_MODULES" +MODULES_ENTRY_POINT_GROUP = "mvt.modules" +EXTERNAL_LOGGER_NAMESPACE = "mvt.ext" +PLUGIN_PACKAGE_PREFIX = "mvt_plugin_" +_ORIGIN_ATTRIBUTE = "_mvt_module_origin" +_PATH_MODULE_PREFIX = "_mvt_custom_module_" log = logging.getLogger(__name__) @@ -23,15 +33,74 @@ class CustomModuleLoadError(Exception): pass +@dataclass(frozen=True) +class ModuleOrigin: + """Describes where a loaded module came from, for auditability. + + ``kind`` is one of ``builtin`` (shipped with MVT), ``package`` (loaded + from an installed package) or ``path`` (loaded from a file passed with + ``--load-module`` or the environment variable). + """ + + kind: str + name: str + version: Optional[str] = None + commit: Optional[str] = None + file_sha256: Optional[str] = None + + @property + def label(self) -> str: + label = self.name + if self.version: + label += f"@{self.version}" + label = f"'{label}'" + if self.commit: + label += f" (commit {self.commit})" + if self.file_sha256: + label += f" (sha256: {self.file_sha256})" + return label + + 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}" + return f"{_PATH_MODULE_PREFIX}{path.stem}_{digest}" + + +def get_module_logger(module_class: type[MVTModule]) -> logging.Logger: + """Return the logger a module's records should be emitted through. + + Modules loaded from installed packages or file paths live outside the + "mvt" logger hierarchy, so their records would never reach the handlers + attached to the "mvt" logger and instead fall through to + logging.lastResort (which prints bare messages and drops anything below + WARNING). Their loggers are parented under the "mvt.ext" namespace, + keeping external module names from colliding with MVT's own logger + tree. File-path modules are named after their file instead of the + mangled internal import name, and packages following the recommended + "mvt_plugin_" naming convention log under "mvt.ext.". + """ + name = module_class.__module__ + if name == "mvt" or name.startswith("mvt."): + return logging.getLogger(name) + + if name.startswith(_PATH_MODULE_PREFIX): + name = Path(get_module_origin(module_class).name).stem + else: + top_level, separator, rest = name.partition(".") + if top_level.startswith(PLUGIN_PACKAGE_PREFIX) and len(top_level) > len( + PLUGIN_PACKAGE_PREFIX + ): + name = top_level[len(PLUGIN_PACKAGE_PREFIX) :] + separator + rest + + return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{name}") 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}") + raise CustomModuleLoadError( + f"Custom module file is not a Python file: {path}" + ) yield path return @@ -59,7 +128,9 @@ def _load_python_file(path: Path) -> ModuleType: try: spec.loader.exec_module(module) except Exception as exc: - raise CustomModuleLoadError(f"Unable to import custom module {path}: {exc}") from exc + raise CustomModuleLoadError( + f"Unable to import custom module {path}: {exc}" + ) from exc return module @@ -84,17 +155,156 @@ def load_custom_modules_from_path(path: str) -> list[type[MVTModule]]: resolved_path = Path(path).expanduser().resolve() for module_file in _iter_module_files(resolved_path): + file_sha256 = hashlib.sha256(module_file.read_bytes()).hexdigest() loaded_module = _load_python_file(module_file) + origin = ModuleOrigin( + kind="path", name=str(module_file), file_sha256=file_sha256 + ) for module_class in discover_mvt_modules(loaded_module): key = (str(module_file), module_class.__qualname__) if key in seen: continue seen.add(key) + setattr(module_class, _ORIGIN_ATTRIBUTE, origin) custom_modules.append(module_class) return custom_modules +def _module_key(module_class: type[MVTModule]) -> tuple[str, str]: + try: + source = str(Path(inspect.getfile(module_class)).resolve()) + except (OSError, TypeError): + source = module_class.__module__ + return (source, module_class.__qualname__) + + +def _distribution_commit(dist: importlib.metadata.Distribution) -> Optional[str]: + """Return the VCS commit a distribution was installed from, if recorded. + + Packages installed directly from a repository (``pip install git+...``) + record the commit in ``direct_url.json`` (PEP 610). + """ + try: + direct_url_text = dist.read_text("direct_url.json") + if not direct_url_text: + return None + commit = json.loads(direct_url_text).get("vcs_info", {}).get("commit_id") + return commit if isinstance(commit, str) else None + except Exception: + return None + + +def _entry_point_origin(entry_point: importlib.metadata.EntryPoint) -> ModuleOrigin: + name = entry_point.name + version = None + commit = None + # Manually constructed entry points have no associated distribution. + dist = getattr(entry_point, "dist", None) + if dist is not None: + try: + name = dist.name or name + version = dist.version + except Exception: + pass + commit = _distribution_commit(dist) + return ModuleOrigin(kind="package", name=name, version=version, commit=commit) + + +@lru_cache(maxsize=1) +def _packages_distributions() -> dict[str, list[str]]: + try: + return dict(importlib.metadata.packages_distributions()) + except Exception: + return {} + + +def get_module_origin(module_class: type[MVTModule]) -> ModuleOrigin: + """Return the origin of a module class for auditing purposes.""" + origin = module_class.__dict__.get(_ORIGIN_ATTRIBUTE) + if isinstance(origin, ModuleOrigin): + return origin + + top_level = module_class.__module__.partition(".")[0] + if top_level == "mvt": + return ModuleOrigin(kind="builtin", name="mvt", version=MVT_VERSION) + + distributions = _packages_distributions().get(top_level) + if distributions: + name = distributions[0] + version = None + commit = None + try: + dist = importlib.metadata.distribution(name) + version = dist.version + commit = _distribution_commit(dist) + except Exception: + pass + return ModuleOrigin(kind="package", name=name, version=version, commit=commit) + + try: + source = str(Path(inspect.getfile(module_class)).resolve()) + except (OSError, TypeError): + source = module_class.__module__ + return ModuleOrigin(kind="path", name=source) + + +def load_installed_modules() -> list[type[MVTModule]]: + """Load MVT modules registered by installed packages. + + Packages register modules in the ``mvt.modules`` entry-point group. Each + entry point must resolve to an iterable of MVTModule subclasses, or to a + callable which returns one. A broken entry point is skipped with a + warning so that a faulty plugin package cannot break MVT. + """ + try: + entry_points = importlib.metadata.entry_points(group=MODULES_ENTRY_POINT_GROUP) + except Exception as exc: + log.warning( + "Unable to discover installed module packages in entry-point group %s: %s", + MODULES_ENTRY_POINT_GROUP, + exc, + ) + return [] + + installed_modules: list[type[MVTModule]] = [] + ordered_entry_points = sorted( + entry_points, key=lambda entry_point: (entry_point.name, entry_point.value) + ) + for entry_point in ordered_entry_points: + try: + loaded = entry_point.load() + if callable(loaded) and not isinstance(loaded, type): + loaded = loaded() + module_classes = list(loaded) + except (Exception, SystemExit) as exc: + log.warning( + "Unable to load modules from entry point %s (%s): %s", + entry_point.name, + entry_point.value, + exc, + ) + continue + + origin = _entry_point_origin(entry_point) + for module_class in module_classes: + if not ( + isinstance(module_class, type) and issubclass(module_class, MVTModule) + ): + log.warning( + "Entry point %s (%s) provided %r which is not an " + "MVTModule subclass", + entry_point.name, + entry_point.value, + module_class, + ) + continue + setattr(module_class, _ORIGIN_ATTRIBUTE, origin) + installed_modules.append(module_class) + + return installed_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) @@ -105,10 +315,17 @@ def load_custom_modules(paths: Optional[Iterable[str]] = None) -> list[type[MVTM custom_modules: list[type[MVTModule]] = [] seen: set[tuple[str, str]] = set() + + for module_class in load_installed_modules(): + key = _module_key(module_class) + if key in seen: + continue + seen.add(key) + custom_modules.append(module_class) + 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__) + key = _module_key(module_class) if key in seen: continue seen.add(key) diff --git a/src/mvt/common/utils.py b/src/mvt/common/utils.py index 30de1595..ad3b3949 100644 --- a/src/mvt/common/utils.py +++ b/src/mvt/common/utils.py @@ -239,6 +239,13 @@ def init_logging(verbose: bool = False): """ log = logging.getLogger("mvt") log.setLevel(logging.DEBUG) + + # Importing an MVT CLI module calls init_logging() at import time, and + # loaded module packages may import one indirectly. Keep this idempotent + # so console log lines are not duplicated by a second handler. + if any(isinstance(handler, MVTLogHandler) for handler in log.handlers): + return + consoleHandler = MVTLogHandler() consoleHandler.setFormatter(logging.Formatter("%(message)s")) if verbose: diff --git a/tests/common/test_module_loader.py b/tests/common/test_module_loader.py index 15b60525..dec636ef 100644 --- a/tests/common/test_module_loader.py +++ b/tests/common/test_module_loader.py @@ -3,10 +3,12 @@ import pytest from mvt.common.module import MVTModule from mvt.common.module_loader import ( CustomModuleLoadError, + get_module_logger, load_custom_modules, load_custom_modules_from_path, module_supports_command, ) +from mvt.ios.modules.mixed.whatsapp import Whatsapp MODULE_TEMPLATE = """ @@ -144,3 +146,44 @@ def test_module_supports_command_honors_supported_commands(tmp_path): assert module_supports_command(module, "ios", "check-backup") assert not module_supports_command(module, "ios", "check-fs") + + +def test_get_module_logger_keeps_builtin_names(): + assert get_module_logger(Whatsapp).name == "mvt.ios.modules.mixed.whatsapp" + + +def test_get_module_logger_parents_package_modules_under_mvt_ext(): + class PackageModule(MVTModule): + pass + + PackageModule.__module__ = "some_plugin_package.ios.custom" + + assert ( + get_module_logger(PackageModule).name + == "mvt.ext.some_plugin_package.ios.custom" + ) + + +def test_get_module_logger_strips_the_plugin_package_prefix(): + class PluginModule(MVTModule): + pass + + PluginModule.__module__ = "mvt_plugin_amnesty_custom.ios.custom" + + assert get_module_logger(PluginModule).name == "mvt.ext.amnesty_custom.ios.custom" + + +def test_get_module_logger_only_strips_the_prefix_from_the_top_level(): + class NestedModule(MVTModule): + pass + + NestedModule.__module__ = "other_package.mvt_plugin_sub" + + assert get_module_logger(NestedModule).name == "mvt.ext.other_package.mvt_plugin_sub" + + +def test_get_module_logger_names_path_modules_after_their_file(tmp_path): + module_path = _write_module(tmp_path / "my_custom_module.py", "PathModule") + module = load_custom_modules_from_path(str(module_path))[0] + + assert get_module_logger(module).name == "mvt.ext.my_custom_module" diff --git a/tests/common/test_utils.py b/tests/common/test_utils.py index 4dbe5c0a..b8791e18 100644 --- a/tests/common/test_utils.py +++ b/tests/common/test_utils.py @@ -8,6 +8,7 @@ import logging import os from datetime import datetime +from mvt.common.log import MVTLogHandler from mvt.common.utils import ( CustomJSONEncoder, convert_datetime_to_iso, @@ -16,6 +17,7 @@ from mvt.common.utils import ( convert_unix_to_utc_datetime, generate_hashes_from_path, get_sha256_from_file_path, + init_logging, ) from ..utils import get_artifact_folder @@ -103,3 +105,20 @@ class TestCustomJSONEncoder: json.dumps({"name": "家".encode()}, cls=CustomJSONEncoder) == '{"name": "\\u5bb6"}' ) + + +class TestInitLogging: + def test__init_logging_is_idempotent(self): + # Loaded module packages may import an MVT CLI module, which calls + # init_logging() again at import time. A second call must not add + # a duplicate console handler. + log = logging.getLogger("mvt") + init_logging() + handler_count = sum( + isinstance(handler, MVTLogHandler) for handler in log.handlers + ) + init_logging() + assert ( + sum(isinstance(handler, MVTLogHandler) for handler in log.handlers) + == handler_count + ) diff --git a/tests/test_custom_modules.py b/tests/test_custom_modules.py index a00faea4..43ab6214 100644 --- a/tests/test_custom_modules.py +++ b/tests/test_custom_modules.py @@ -1,3 +1,7 @@ +import hashlib +import importlib.metadata +import json + from click.testing import CliRunner from mvt.android.cli import check_bugreport @@ -5,7 +9,9 @@ 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 import module_loader from mvt.common.module import MVTModule +from mvt.common.version import MVT_VERSION from mvt.ios.cli import check_backup, check_fs @@ -106,6 +112,150 @@ def test_custom_modules_load_from_environment_without_cli_flag(tmp_path, monkeyp assert "EnvBugreportModule" in result.output +class InstalledPackageModule(MVTModule): + supported_commands = (("ios", "check-backup"),) + + +def get_installed_package_modules(): + return [InstalledPackageModule] + + +def _fake_entry_points(monkeypatch, value, name="test-modules"): + entry_point = importlib.metadata.EntryPoint( + name=name, value=value, group=module_loader.MODULES_ENTRY_POINT_GROUP + ) + + def fake_entry_points(*, group): + assert group == module_loader.MODULES_ENTRY_POINT_GROUP + return [entry_point] + + monkeypatch.setattr( + module_loader.importlib.metadata, "entry_points", fake_entry_points + ) + + +def test_installed_module_package_loads_from_entry_point(monkeypatch): + _fake_entry_points(monkeypatch, f"{__name__}:get_installed_package_modules") + + modules = module_loader.load_custom_modules() + + assert modules == [InstalledPackageModule] + + +def test_broken_module_entry_point_is_skipped(monkeypatch, caplog): + _fake_entry_points(monkeypatch, "nonexistent_module_xyz:get_modules") + + with caplog.at_level("WARNING"): + modules = module_loader.load_custom_modules() + + assert modules == [] + assert "Unable to load modules from entry point" in caplog.text + + +def test_entry_point_module_deduplicated_against_paths(monkeypatch, tmp_path): + _fake_entry_points(monkeypatch, f"{__name__}:get_installed_package_modules") + module_path = _write_custom_module( + tmp_path / "custom.py", + "PathLoadedModule", + (("ios", "check-backup"),), + ) + + modules = module_loader.load_custom_modules([str(module_path)]) + + assert [module.__name__ for module in modules] == [ + "InstalledPackageModule", + "PathLoadedModule", + ] + + +def test_list_modules_shows_module_sources(tmp_path, caplog): + module_path = _write_custom_module( + tmp_path / "custom.py", + "SourcedBackupModule", + (("ios", "check-backup"),), + ) + file_sha256 = hashlib.sha256(module_path.read_bytes()).hexdigest() + custom_modules = module_loader.load_custom_modules([str(module_path)]) + + from mvt.ios.cmd_check_backup import CmdIOSCheckBackup + + cmd = CmdIOSCheckBackup(target_path=str(tmp_path), custom_modules=custom_modules) + cmd.list_modules() + + assert f" - Modules from 'mvt@{MVT_VERSION}':" in caplog.text + assert ( + f" - Modules from '{module_path}' (sha256: {file_sha256}): SourcedBackupModule" + in caplog.text + ) + + +def test_builtin_module_origin(): + from mvt.ios.modules.backup import BACKUP_MODULES + + origin = module_loader.get_module_origin(BACKUP_MODULES[0]) + + assert origin.kind == "builtin" + assert origin.name == "mvt" + assert origin.version == MVT_VERSION + + +def test_installed_module_origin(monkeypatch): + _fake_entry_points(monkeypatch, f"{__name__}:get_installed_package_modules") + + modules = module_loader.load_custom_modules() + + origin = module_loader.get_module_origin(modules[0]) + assert origin.kind == "package" + assert origin.name == "test-modules" + + +def test_distribution_commit_read_from_direct_url(): + class FakeDistribution: + def read_text(self, filename): + assert filename == "direct_url.json" + return json.dumps( + { + "url": "https://github.com/example/example-modules", + "vcs_info": {"commit_id": "abc1234", "vcs": "git"}, + } + ) + + assert module_loader._distribution_commit(FakeDistribution()) == "abc1234" + + +def test_command_log_records_loaded_modules(tmp_path): + (tmp_path / "Manifest.db").touch() + (tmp_path / "Info.plist").touch() + module_path = _write_custom_module( + tmp_path / "custom.py", + "AuditedRunModule", + (("ios", "check-backup"),), + slug="audited_run_module", + ) + file_sha256 = hashlib.sha256(module_path.read_bytes()).hexdigest() + output_path = tmp_path / "out" + + result = CliRunner().invoke( + check_backup, + [ + "--module", + "AuditedRunModule", + "--load-module", + str(module_path), + "--output", + str(output_path), + str(tmp_path), + ], + ) + + assert result.exit_code == 0 + command_log = (output_path / "command.log").read_text(encoding="utf-8") + assert ( + f"Loaded 1 check-backup modules from '{module_path}' " + f"(sha256: {file_sha256}): AuditedRunModule" in command_log + ) + + class NestedBugreportModule(MVTModule): supported_commands = (("android", "check-bugreport"),) From dc0650bf76d75fc889b11fc5f1c0a83c643c77f8 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:23 +0200 Subject: [PATCH 031/109] Fix bugreport command section extraction --- src/mvt/android/artifacts/getprop.py | 8 +++--- src/mvt/android/modules/bugreport/base.py | 27 ++++++++++++++++++- .../modules/bugreport/dumpsys_getprop.py | 21 +++------------ tests/android/test_artifact_getprop.py | 9 +++++++ 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/mvt/android/artifacts/getprop.py b/src/mvt/android/artifacts/getprop.py index debcfc4c..a03491b7 100644 --- a/src/mvt/android/artifacts/getprop.py +++ b/src/mvt/android/artifacts/getprop.py @@ -28,18 +28,18 @@ INTERESTING_PROPERTIES = [ class GetProp(AndroidArtifact): def parse(self, entry: str) -> None: self.results: List[Dict[str, str]] = [] - rxp = re.compile(r"\[(.+?)\]: \[(.+?)\]") + rxp = re.compile(r"^\[([^]]+)\]: \[(.*)\]$") for line in entry.splitlines(): line = line.strip() if line == "": continue - matches = re.findall(rxp, line) - if not matches or len(matches[0]) != 2: + match = rxp.match(line) + if not match: continue - prop_entry = {"name": matches[0][0], "value": matches[0][1]} + prop_entry = {"name": match.group(1), "value": match.group(2)} self.results.append(prop_entry) def get_device_timezone(self) -> str | None: diff --git a/src/mvt/android/modules/bugreport/base.py b/src/mvt/android/modules/bugreport/base.py index 156e01cf..01af8c96 100644 --- a/src/mvt/android/modules/bugreport/base.py +++ b/src/mvt/android/modules/bugreport/base.py @@ -72,7 +72,11 @@ class BugReportModule(MVTModule): 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()): + if ( + not Path(joined) + .resolve() + .is_relative_to(Path(self.extract_path).resolve()) + ): raise ValueError("unsafe file_path") handle = open(joined, "rb") @@ -100,6 +104,27 @@ class BugReportModule(MVTModule): return None + @staticmethod + def extract_command_section(content: str, heading: str) -> str: + """Return a bugreport command section without consuming the next one. + + Bugreport separators include timing text, so looking for a line equal to + ``------`` is not sufficient and can accidentally feed the remainder of + dumpstate to a parser. + """ + lines: list[str] = [] + in_section = False + for line in content.splitlines(): + stripped = line.strip() + if not in_section: + if stripped.startswith(heading): + in_section = True + continue + if stripped.startswith("------"): + break + lines.append(line) + return "\n".join(lines) + 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 diff --git a/src/mvt/android/modules/bugreport/dumpsys_getprop.py b/src/mvt/android/modules/bugreport/dumpsys_getprop.py index 198a1d2a..b9c20eab 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_getprop.py +++ b/src/mvt/android/modules/bugreport/dumpsys_getprop.py @@ -44,21 +44,8 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule): ) return - lines = [] - in_getprop = False - - for line in content.decode(errors="ignore").splitlines(): - if line.strip().startswith("------ SYSTEM PROPERTIES"): - in_getprop = True - continue - - if not in_getprop: - continue - - if line.strip() == "------": - break - - lines.append(line) - - self.parse("\n".join(lines)) + section = self.extract_command_section( + content.decode(errors="ignore"), "------ SYSTEM PROPERTIES" + ) + self.parse(section) self.log.info("Extracted %d Android system properties", len(self.results)) diff --git a/tests/android/test_artifact_getprop.py b/tests/android/test_artifact_getprop.py index d9fec6b7..7815fbd1 100644 --- a/tests/android/test_artifact_getprop.py +++ b/tests/android/test_artifact_getprop.py @@ -39,3 +39,12 @@ class TestGetPropArtifact: assert len(gp.alertstore.alerts) == 0 gp.check_indicators() assert len(gp.alertstore.alerts) == 1 + + def test_empty_values_and_invalid_lines(self): + gp = GetProp() + gp.parse("[empty]: []\n[valid]: [value]\n0\n[broken]: [value") + + assert gp.results == [ + {"name": "empty", "value": ""}, + {"name": "valid", "value": "value"}, + ] From ab879bb23b8c38ff436dc6b25e2ede54511176b3 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:23 +0200 Subject: [PATCH 032/109] Parse all package resolver categories --- .../artifacts/dumpsys_package_activities.py | 61 +------- .../android/artifacts/dumpsys_receivers.py | 147 +++++------------- .../android/artifacts/package_resolvers.py | 70 +++++++++ .../modules/bugreport/dumpsys_receivers.py | 4 +- ...est_artifact_dumpsys_package_activities.py | 14 +- .../test_artifact_dumpsys_receivers.py | 33 ++-- 6 files changed, 134 insertions(+), 195 deletions(-) create mode 100644 src/mvt/android/artifacts/package_resolvers.py diff --git a/src/mvt/android/artifacts/dumpsys_package_activities.py b/src/mvt/android/artifacts/dumpsys_package_activities.py index 31c6bbdb..443d18fe 100644 --- a/src/mvt/android/artifacts/dumpsys_package_activities.py +++ b/src/mvt/android/artifacts/dumpsys_package_activities.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ from .artifact import AndroidArtifact +from .package_resolvers import parse_resolver_table class DumpsysPackageActivitiesArtifact(AndroidArtifact): @@ -19,67 +20,11 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact): ) continue - def parse(self, content: str): + def parse(self, content: str) -> None: """ Parse the Dumpsys Package section for activities Adds results to self.results :param content: content of the package section (string) """ - self.results = [] - - in_activity_resolver_table = False - in_non_data_actions = False - intent = None - for line in content.splitlines(): - if line.startswith("Activity Resolver Table:"): - in_activity_resolver_table = True - continue - - if not in_activity_resolver_table: - continue - - if line.startswith(" Non-Data Actions:"): - in_non_data_actions = True - continue - - if not in_non_data_actions: - continue - - # If we hit an empty line, the Non-Data Actions section should be - # finished. - if line.strip() == "": - break - - # We detect the action name. - if ( - line.startswith(" " * 6) - and not line.startswith(" " * 8) - and ":" in line - ): - intent = line.strip().replace(":", "") - continue - - # If we are not in an intent block yet, skip. - if not intent: - continue - - # If we are in a block but the line does not start with 8 spaces - # it means the block ended a new one started, so we reset and - # continue. - if not line.startswith(" " * 8): - intent = None - continue - - # If we got this far, we are processing receivers for the - # activities we are interested in. - activity = line.strip().split(" ")[1] - package_name = activity.split("/")[0] - - self.results.append( - { - "intent": intent, - "package_name": package_name, - "activity": activity, - } - ) + self.results = parse_resolver_table(content, "Activity") diff --git a/src/mvt/android/artifacts/dumpsys_receivers.py b/src/mvt/android/artifacts/dumpsys_receivers.py index b4379309..75943356 100644 --- a/src/mvt/android/artifacts/dumpsys_receivers.py +++ b/src/mvt/android/artifacts/dumpsys_receivers.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ from .artifact import AndroidArtifact +from .package_resolvers import parse_resolver_table INTENT_NEW_OUTGOING_SMS = "android.provider.Telephony.NEW_OUTGOING_SMS" INTENT_SMS_RECEIVED = "android.provider.Telephony.SMS_RECEIVED" @@ -18,115 +19,45 @@ class DumpsysReceiversArtifact(AndroidArtifact): """ def check_indicators(self) -> None: - for intent, receivers in self.results.items(): - for receiver in receivers: - if intent == INTENT_NEW_OUTGOING_SMS: - self.log.info( - 'Found a receiver to intercept outgoing SMS messages: "%s"', - receiver["receiver"], - ) - elif intent == INTENT_SMS_RECEIVED: - self.log.info( - 'Found a receiver to intercept incoming SMS messages: "%s"', - receiver["receiver"], - ) - elif intent == INTENT_DATA_SMS_RECEIVED: - self.log.info( - 'Found a receiver to intercept incoming data SMS message: "%s"', - receiver["receiver"], - ) - elif intent == INTENT_PHONE_STATE: - self.log.info( - "Found a receiver monitoring " - 'telephony state/incoming calls: "%s"', - receiver["receiver"], - ) - elif intent == INTENT_NEW_OUTGOING_CALL: - self.log.info( - 'Found a receiver monitoring outgoing calls: "%s"', - receiver["receiver"], - ) + for receiver in self.results: + intent = receiver["key"] + if intent == INTENT_NEW_OUTGOING_SMS: + self.log.info( + 'Found a receiver to intercept outgoing SMS messages: "%s"', + receiver["component"], + ) + elif intent == INTENT_SMS_RECEIVED: + self.log.info( + 'Found a receiver to intercept incoming SMS messages: "%s"', + receiver["component"], + ) + elif intent == INTENT_DATA_SMS_RECEIVED: + self.log.info( + 'Found a receiver to intercept incoming data SMS message: "%s"', + receiver["component"], + ) + elif intent == INTENT_PHONE_STATE: + self.log.info( + 'Found a receiver monitoring telephony state/incoming calls: "%s"', + receiver["component"], + ) + elif intent == INTENT_NEW_OUTGOING_CALL: + self.log.info( + 'Found a receiver monitoring outgoing calls: "%s"', + receiver["component"], + ) - if not self.indicators: - continue + if not self.indicators: + continue - 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 + ioc_match = self.indicators.check_app_id(receiver["package_name"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, + "", + receiver, + matched_indicator=ioc_match.ioc, + ) def parse(self, output: str) -> None: - self.results: dict[str, list[dict[str, str]]] = {} - - in_receiver_resolver_table = False - in_non_data_actions = False - intent = None - for line in output.splitlines(): - if line.startswith("Receiver Resolver Table:"): - in_receiver_resolver_table = True - continue - - if not in_receiver_resolver_table: - continue - - if line.startswith(" Non-Data Actions:"): - in_non_data_actions = True - continue - - if not in_non_data_actions: - continue - - # If we hit an empty line, the Non-Data Actions section should be - # finished. - if line.strip() == "": - break - - # We detect the action name. - if ( - line.startswith(" " * 6) - and not line.startswith(" " * 8) - and ":" in line - ): - intent = line.strip().replace(":", "") - 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 - - # If we are in a block but the line does not start with 8 spaces - # it means the block ended a new one started, so we reset and - # continue. - if not line.startswith(" " * 8): - intent = None - continue - - # If we got this far, we are processing receivers for the - # activities we are interested in. - receiver = parts[1] - package_name = receiver.split("/")[0] - - self.results[intent].append( - { - "package_name": package_name, - "receiver": receiver, - } - ) + self.results = parse_resolver_table(output, "Receiver") diff --git a/src/mvt/android/artifacts/package_resolvers.py b/src/mvt/android/artifacts/package_resolvers.py new file mode 100644 index 00000000..60c22125 --- /dev/null +++ b/src/mvt/android/artifacts/package_resolvers.py @@ -0,0 +1,70 @@ +# 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 re + + +RESOLVER_TYPES = { + "Full MIME Types": "full_mime_type", + "Base MIME Types": "base_mime_type", + "Wild MIME Types": "wild_mime_type", + "Schemes": "scheme", + "Non-Data Actions": "non_data_action", + "MIME Typed Actions": "mime_typed_action", +} + +ENTRY_RE = re.compile( + r"^\s{8,}[0-9a-fA-F]+\s+(?P\S+)" + r"(?:\s+\((?P\d+)\s+filters?\))?\s*$" +) + + +def parse_resolver_table(content: str, table_name: str) -> list[dict]: + """Parse every resolver category from a dumpsys package table.""" + results: list[dict] = [] + in_table = False + resolver_type: str | None = None + key: str | None = None + + for line in content.splitlines(): + if line.startswith(f"{table_name} Resolver Table:"): + in_table = True + continue + if not in_table: + continue + + if line and not line.startswith(" "): + break + + section_match = re.match(r"^ {2}([^ ].*):\s*$", line) + if section_match: + resolver_type = RESOLVER_TYPES.get(section_match.group(1)) + key = None + continue + if resolver_type is None: + continue + + key_match = re.match(r"^ {6,}([^ ].*):\s*$", line) + if key_match: + key = key_match.group(1) + continue + if key is None: + continue + + entry_match = ENTRY_RE.match(line) + if not entry_match: + continue + component = entry_match.group("component") + results.append( + { + "resolver_type": resolver_type, + "key": key, + "package_name": component.split("/", 1)[0], + "component": component, + "filter_count": int(entry_match.group("filters") or 1), + } + ) + + return results diff --git a/src/mvt/android/modules/bugreport/dumpsys_receivers.py b/src/mvt/android/modules/bugreport/dumpsys_receivers.py index 8907d5e3..a82f0213 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_receivers.py +++ b/src/mvt/android/modules/bugreport/dumpsys_receivers.py @@ -33,7 +33,7 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule): results=results, ) - self.results = results if results else {} + self.results = results if results else [] def run(self) -> None: content = self._get_dumpstate_file() @@ -49,4 +49,4 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule): ) self.parse(dumpsys_section) - self.log.info("Extracted receivers for %d intents", len(self.results)) + self.log.info("Extracted %d package receivers", len(self.results)) diff --git a/tests/android/test_artifact_dumpsys_package_activities.py b/tests/android/test_artifact_dumpsys_package_activities.py index 5eab63d6..66ef7908 100644 --- a/tests/android/test_artifact_dumpsys_package_activities.py +++ b/tests/android/test_artifact_dumpsys_package_activities.py @@ -21,12 +21,16 @@ class TestDumpsysPackageActivitiesArtifact: assert len(dpa.results) == 0 dpa.parse(data) - assert len(dpa.results) == 4 - assert dpa.results[0]["package_name"] == "com.samsung.android.app.social" + assert len(dpa.results) == 10 + assert dpa.results[0]["package_name"] == "com.samsung.android.messaging" assert ( - dpa.results[0]["activity"] - == "com.samsung.android.app.social/.feed.FeedsActivity" + dpa.results[0]["component"] + == "com.samsung.android.messaging/.ui.RcsTransferContent" ) + assert {result["resolver_type"] for result in dpa.results} == { + "full_mime_type", + "non_data_action", + } def test_ioc_check(self, indicator_file): dpa = DumpsysPackageActivitiesArtifact() @@ -41,4 +45,4 @@ class TestDumpsysPackageActivitiesArtifact: dpa.indicators = ind assert len(dpa.alertstore.alerts) == 0 dpa.check_indicators() - assert len(dpa.alertstore.alerts) == 1 + assert len(dpa.alertstore.alerts) == 2 diff --git a/tests/android/test_artifact_dumpsys_receivers.py b/tests/android/test_artifact_dumpsys_receivers.py index 7875a52e..6e8b92e0 100644 --- a/tests/android/test_artifact_dumpsys_receivers.py +++ b/tests/android/test_artifact_dumpsys_receivers.py @@ -19,17 +19,14 @@ class TestDumpsysReceiversArtifact: assert len(dr.results) == 0 dr.parse(data) - assert len(dr.results) == 4 - assert ( - list(dr.results.keys())[0] - == "com.android.storagemanager.automatic.SHOW_NOTIFICATION" - ) - assert ( - dr.results["com.android.storagemanager.automatic.SHOW_NOTIFICATION"][0][ - "package_name" - ] - == "com.android.storagemanager" + assert len(dr.results) == 9 + assert dr.results[0]["resolver_type"] == "full_mime_type" + storage_manager = next( + result + for result in dr.results + if result["key"] == "com.android.storagemanager.automatic.SHOW_NOTIFICATION" ) + assert storage_manager["package_name"] == "com.android.storagemanager" def test_parsing_misindented_action(self): dr = DumpsysReceiversArtifact() @@ -44,12 +41,8 @@ Receiver Resolver Table: dr.parse(data) - assert ( - dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][ - "package_name" - ] - == "com.psycatgames.nhiegame" - ) + assert dr.results[1]["key"] == "android.intent.action.MY_PACKAGE_REPLACED" + assert dr.results[1]["package_name"] == "com.psycatgames.nhiegame" def test_parsing_misindented_first_action(self): dr = DumpsysReceiversArtifact() @@ -62,12 +55,8 @@ Receiver Resolver Table: dr.parse(data) - assert ( - dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][ - "package_name" - ] - == "com.psycatgames.nhiegame" - ) + assert dr.results[0]["key"] == "android.intent.action.MY_PACKAGE_REPLACED" + assert dr.results[0]["package_name"] == "com.psycatgames.nhiegame" def test_ioc_check(self, indicator_file): dr = DumpsysReceiversArtifact() From 008844b440e4c7a31dd569703ab6d73de01c4e74 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:23 +0200 Subject: [PATCH 033/109] Parse accessibility service states per user --- .../artifacts/dumpsys_accessibility.py | 101 +++++++++++------- .../bugreport/dumpsys_accessibility.py | 4 +- .../test_artifact_dumpsys_accessibility.py | 22 +++- 3 files changed, 86 insertions(+), 41 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_accessibility.py b/src/mvt/android/artifacts/dumpsys_accessibility.py index 3d8fa6c2..da46043c 100644 --- a/src/mvt/android/artifacts/dumpsys_accessibility.py +++ b/src/mvt/android/artifacts/dumpsys_accessibility.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ import re +from typing import Any from .artifact import AndroidArtifact @@ -20,10 +21,10 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): continue self.alertstore.medium( - f'Found accessibility service: "{result["service"]}"', + f'Found accessibility service: "{result["component"]}"', "", result, - ) + ) def parse(self, content: str) -> None: """ @@ -33,41 +34,69 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): :param content: content of the accessibility section (string) """ - # "Old" syntax - in_services = False + self.results: list[dict[str, Any]] = [] + services: dict[tuple[int | None, str], dict] = {} + user_id: int | None = None + state: str | None = None + for line in content.splitlines(): - if line.strip().startswith("installed services:"): - in_services = True - continue + user_match = re.search(r"attributes:\{id=(\d+)", line) + if user_match: + user_id = int(user_match.group(1)) - if not in_services: - continue - - if line.strip() == "}": - # At end of installed services - break - - service = line.split(":")[1].strip() - - self.results.append( - { - "package_name": service.split("/")[0], - "service": service, - } + stripped = line.strip() + state_match = re.match( + r"(?i)(installed|enabled|binding|bound|crashed) services\s*:\s*\{(.*)", + stripped, ) - - # "New" syntax - AOSP >= 14 (?) - # Looks like: - # Enabled services:{{com.azure.authenticator/com.microsoft.brooklyn.module.accessibility.BrooklynAccessibilityService}, {com.agilebits.onepassword/com.agilebits.onepassword.filling.accessibility.FillingAccessibilityService}} - - for line in content.splitlines(): - if line.strip().startswith("Enabled services:"): - matches = re.finditer(r"{([^{]+?)}", line) - - for match in matches: - # Each match is in format: / - package_name, _, service = match.group(1).partition("/") - - self.results.append( - {"package_name": package_name, "service": service} + if state_match: + state = state_match.group(1).lower() + inline = state_match.group(2) + for component in re.findall( + r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline + ): + service = services.setdefault( + (user_id, component), self._new_service(component, user_id) ) + service[self._state_field(state)] = True + service["accessibility_tool"] = "(A11yTool)" in inline + continue + + if not state: + continue + if stripped == "}" or stripped.startswith("AccessibilityInputFilter"): + state = None + continue + component_match = re.search( + r"(?:\d+\s*:\s*)?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?", + stripped, + ) + if component_match: + component = component_match.group(1) + service = services.setdefault( + (user_id, component), self._new_service(component, user_id) + ) + service[self._state_field(state)] = True + service["accessibility_tool"] = "(A11yTool)" in stripped + + self.results.extend(services.values()) + + @staticmethod + def _state_field(state: str) -> str: + return {"binding": "binding", "bound": "bound"}.get(state, state) + + @staticmethod + def _new_service(component: str, user_id: int | None) -> dict: + package_name, service_name = component.split("/", 1) + return { + "user_id": user_id, + "component": component, + "package_name": package_name, + "service_name": service_name, + "installed": False, + "enabled": False, + "binding": False, + "bound": False, + "crashed": False, + "accessibility_tool": False, + } diff --git a/src/mvt/android/modules/bugreport/dumpsys_accessibility.py b/src/mvt/android/modules/bugreport/dumpsys_accessibility.py index 72208c02..02d85a30 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_accessibility.py +++ b/src/mvt/android/modules/bugreport/dumpsys_accessibility.py @@ -49,9 +49,7 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule): self.parse(content) for result in self.results: - self.log.info( - 'Found installed accessibility service "%s"', result.get("service") - ) + self.log.info('Found accessibility service "%s"', result.get("component")) self.log.info( "Identified a total of %d accessibility services", len(self.results) diff --git a/tests/android/test_artifact_dumpsys_accessibility.py b/tests/android/test_artifact_dumpsys_accessibility.py index 1f0b2349..c727c571 100644 --- a/tests/android/test_artifact_dumpsys_accessibility.py +++ b/tests/android/test_artifact_dumpsys_accessibility.py @@ -23,7 +23,7 @@ class TestDumpsysAccessibilityArtifact: assert len(da.results) == 4 assert da.results[0]["package_name"] == "com.android.settings" assert ( - da.results[0]["service"] + da.results[0]["component"] == "com.android.settings/com.samsung.android.settings.development.gpuwatch.GPUWatchInterceptor" ) @@ -37,7 +37,9 @@ class TestDumpsysAccessibilityArtifact: da.parse(data) assert len(da.results) == 1 assert da.results[0]["package_name"] == "com.malware.accessibility" - assert da.results[0]["service"] == "com.malware.service.malwareservice" + assert da.results[0]["service_name"] == "com.malware.service.malwareservice" + assert da.results[0]["enabled"] is True + assert da.results[0]["installed"] is False def test_accessibility_service_alert(self): da = DumpsysAccessibilityArtifact() @@ -52,6 +54,22 @@ class TestDumpsysAccessibilityArtifact: assert da.alertstore.alerts[0].level == AlertLevel.MEDIUM assert da.alertstore.alerts[0].event == da.results[0] + def test_same_component_is_kept_for_each_user(self): + da = DumpsysAccessibilityArtifact() + da.parse( + """User state[attributes:{id=0 + installed services: { + 0 : com.example/.Service + } +User state[attributes:{id=10 + installed services: { + 0 : com.example/.Service + } +""" + ) + + assert [result["user_id"] for result in da.results] == [0, 10] + def test_ioc_check(self, indicator_file): da = DumpsysAccessibilityArtifact() file = get_artifact("android_data/dumpsys_accessibility.txt") From b904414bb08e27aac484cb862c225d7ac8e705a9 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:23 +0200 Subject: [PATCH 034/109] Parse ADB binary XML keys --- src/mvt/android/artifacts/dumpsys_adb.py | 42 +++++++++++++++++++++- tests/android/test_artifact_dumpsys_adb.py | 13 +++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/mvt/android/artifacts/dumpsys_adb.py b/src/mvt/android/artifacts/dumpsys_adb.py index e5438c9b..cbd80e21 100644 --- a/src/mvt/android/artifacts/dumpsys_adb.py +++ b/src/mvt/android/artifacts/dumpsys_adb.py @@ -6,6 +6,7 @@ import base64 import binascii import hashlib +import re from .artifact import AndroidArtifact @@ -98,6 +99,34 @@ class DumpsysADBArtifact(AndroidArtifact): return keystore + def parse_binary_xml(self, data: bytes) -> list[dict]: + """Recover ADB key records from Android binary XML (ABX). + + Some dumpstate implementations embed ABX in a text stream and replace + binary token bytes. The public key remains intact, while unavailable + numeric metadata is represented as ``None`` rather than corrupt text. + """ + keystore = [] + seen = set() + for match in re.finditer( + rb"(? dict: if b" " in user_key: @@ -118,7 +147,7 @@ class DumpsysADBArtifact(AndroidArtifact): return { "user": user.decode("utf-8"), "fingerprint": key_fingerprint_colon, - "key": key_base64, + "key": key_base64.decode("ascii", errors="replace"), } def check_indicators(self) -> None: @@ -175,12 +204,23 @@ class DumpsysADBArtifact(AndroidArtifact): # Keystore is in XML format on some devices and we need to parse it if keystore_data and keystore_data.startswith(b" Date: Sat, 22 Aug 2026 14:16:23 +0200 Subject: [PATCH 035/109] Retain AppOps UID and event details --- src/mvt/android/artifacts/dumpsys_appops.py | 203 +++++++++++------- tests/android/test_artifact_dumpsys_appops.py | 29 ++- 2 files changed, 150 insertions(+), 82 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_appops.py b/src/mvt/android/artifacts/dumpsys_appops.py index 492eac7a..611ab5f2 100644 --- a/src/mvt/android/artifacts/dumpsys_appops.py +++ b/src/mvt/android/artifacts/dumpsys_appops.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ from datetime import datetime +import re from typing import Any from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult @@ -27,14 +28,14 @@ class DumpsysAppopsArtifact(AndroidArtifact): continue for entry in perm["entries"]: - if "timestamp" in entry: + if entry.get("timestamp"): records.append( { "timestamp": entry["timestamp"], "module": self.__class__.__name__, - "event": entry["access"], + "event": entry["event"], "data": f"{result['package_name']} access to " - f"{perm['name']}: {entry['access']}", + f"{perm['name']}: {entry['event']}", } ) @@ -51,7 +52,7 @@ class DumpsysAppopsArtifact(AndroidArtifact): continue # We use a placeholder entry to create a basic alert even without permission entries. - placeholder_entry = {"access": "Unknown", "timestamp": ""} + placeholder_entry = {"event": "unknown", "timestamp": ""} for perm in result["permissions"]: if ( @@ -60,12 +61,12 @@ class DumpsysAppopsArtifact(AndroidArtifact): ): for entry in sorted( perm["entries"] or [placeholder_entry], - key=lambda x: x["timestamp"], + key=lambda x: x.get("timestamp") or "", ): 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']}", + f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['event']}' at {entry['timestamp']}", entry["timestamp"], cleaned_result, ) @@ -73,111 +74,151 @@ class DumpsysAppopsArtifact(AndroidArtifact): elif result["package_name"] in RISKY_PACKAGES: for entry in sorted( perm["entries"] or [placeholder_entry], - key=lambda x: x["timestamp"], + key=lambda x: x.get("timestamp") or "", ): 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']}", + f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['event']}' at {entry['timestamp']}", entry["timestamp"], cleaned_result, ) def parse(self, output: str) -> None: - # self.results: List[Dict[str, Any]] = [] - perm: dict[str, Any] = {} - package: dict[str, Any] = {} - entry: dict[str, Any] = {} - uid = None + self.results: list[dict[str, Any]] = [] + permission: dict[str, Any] | None = None + package: dict[str, Any] | None = None + uid: str | None = None + uid_details: dict[str, Any] = {} + attribution: str | None = None in_packages = False + def finish_permission() -> None: + nonlocal permission + if package is not None and permission is not None: + package["permissions"].append(permission) + permission = None + + def finish_package() -> None: + nonlocal package + finish_permission() + if package is not None: + self.results.append(package) + package = None + for line in output.splitlines(): - if line.startswith(" Uid 0:"): + uid_match = re.match(r"^ Uid ([^:]+):$", line) + if uid_match: in_packages = True + finish_package() + uid = uid_match.group(1) + uid_details = { + "uid_state": None, + "capability": None, + "app_widget_visible": None, + "default_modes": {}, + } + continue if not in_packages: continue - if line.startswith(" Uid "): - uid = line[6:-1] - if entry: - perm["entries"].append(entry) - entry = {} - if package: - if perm: - package["permissions"].append(perm) + uid_property = re.match( + r"^ (state|capability|appWidgetVisible)=(.*)$", line + ) + if uid_property: + key = { + "state": "uid_state", + "appWidgetVisible": "app_widget_visible", + }.get(uid_property.group(1), uid_property.group(1)) + value: Any = uid_property.group(2) + if value in ("true", "false"): + value = value == "true" + uid_details[key] = value + continue - perm = {} - self.results.append(package) - package = {} + default_mode = re.match(r"^ ([A-Z0-9_]+): mode=([^\s]+)", line) + if default_mode and package is None: + uid_details["default_modes"][default_mode.group(1)] = ( + default_mode.group(2) + ) continue if line.startswith(" Package "): - if entry: - perm["entries"].append(entry) - entry = {} - - if package: - if perm: - package["permissions"].append(perm) - - perm = {} - self.results.append(package) - + finish_package() package = { "package_name": line[12:-1], "permissions": [], "uid": uid, + **uid_details, } continue - if package and line.startswith(" ") and line[6] != " ": - if entry: - perm["entries"].append(entry) - entry = {} - if perm: - package["permissions"].append(perm) - perm = {} - - perm["name"] = line.split()[0] - perm["entries"] = [] - if len(line.split()) > 1: - perm["access"] = line.split()[1][1:-2] - + operation_match = re.match( + r"^ ([A-Z0-9_]+)(?: \(([^)]+)\))?:\s*$", line + ) + if package is not None and operation_match: + finish_permission() + permission = { + "name": operation_match.group(1), + "mode": operation_match.group(2), + "entries": [], + } + attribution = None continue - if line.startswith(" "): - # Permission entry like: - # Reject: [fg-s]2021-05-19 22:02:52.054 (-314d1h25m2s33ms) - access_type = line.split(":")[0].strip() - if access_type not in ["Access", "Reject"]: - # Skipping invalid access type. Some entries are not in the format we expect - continue + attribution_match = re.match(r"^\s{8,}([^=]+)=\[$", line) + if attribution_match: + attribution = attribution_match.group(1).strip() + continue + if line.strip() == "]": + attribution = None + continue - if entry: - perm["entries"].append(entry) - entry = {} - - entry["access"] = access_type - entry["type"] = line[line.find("[") + 1 : line.find("]")] - - try: - entry["timestamp"] = convert_datetime_to_iso( - datetime.strptime( - line[line.find("]") + 1 : line.find("(")].strip(), - "%Y-%m-%d %H:%M:%S.%f", - ) + if permission is None: + continue + event_match = re.match( + r"^\s*(Access|Reject):\s*\[([^]]+)\]\s*" + r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s*" + r"(\([^)]*\))?(?:\s+duration=([^\s]+))?", + line, + ) + running_match = re.match( + r"^\s*Running start at:\s*(\S+(?: \S+)?)", + line, + ) + if event_match: + entry = { + "event": event_match.group(1).lower(), + "access": event_match.group(1), + "uid_state": event_match.group(2), + "timestamp": convert_datetime_to_iso( + datetime.strptime(event_match.group(3), "%Y-%m-%d %H:%M:%S.%f") + ), + "relative_time": event_match.group(4), + "duration": event_match.group(5), + "attribution": attribution, + } + permission["entries"].append(entry) + elif running_match: + raw_start = running_match.group(1) + timestamp = None + if re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", raw_start): + timestamp = convert_datetime_to_iso( + datetime.strptime(raw_start, "%Y-%m-%d %H:%M:%S.%f") ) - except ValueError: - # Invalid date format - pass + permission["entries"].append( + { + "event": "running", + "access": "Running", + "uid_state": None, + "timestamp": timestamp, + "relative_time": raw_start + if raw_start.startswith("+") + else None, + "duration": None, + "attribution": attribution, + } + ) - if line.strip() == "": - break - - if entry: - perm["entries"].append(entry) - if perm: - package["permissions"].append(perm) - if package: - self.results.append(package) + finish_package() diff --git a/tests/android/test_artifact_dumpsys_appops.py b/tests/android/test_artifact_dumpsys_appops.py index 862b8721..a86084f5 100644 --- a/tests/android/test_artifact_dumpsys_appops.py +++ b/tests/android/test_artifact_dumpsys_appops.py @@ -25,10 +25,37 @@ class TestDumpsysAppopsArtifact: assert da.results[0]["uid"] == "0" assert len(da.results[0]["permissions"]) == 1 assert da.results[0]["permissions"][0]["name"] == "MANAGE_IPSEC_TUNNELS" - assert da.results[0]["permissions"][0]["access"] == "allow" + assert da.results[0]["permissions"][0]["mode"] == "allow" assert da.results[6]["package_name"] == "com.sec.factory.camera" assert len(da.results[6]["permissions"][1]["entries"]) == 1 assert len(da.results[11]["permissions"]) == 4 + wake_lock = next( + permission + for permission in da.results[11]["permissions"] + if permission["name"] == "WAKE_LOCK" + ) + assert wake_lock["entries"][0]["duration"] == "+126ms" + + def test_running_and_attribution_are_retained(self): + da = DumpsysAppopsArtifact() + da.parse( + """ Uid 0: + state=cch + Package com.example: + CAMERA (allow): + camera=[ + Access: [fg-s] 2025-01-01 00:00:00.000 (-1s) duration=+2ms + ] + RECORD_AUDIO (allow): + Running start at: +3s +""" + ) + + camera = da.results[0]["permissions"][0]["entries"][0] + running = da.results[0]["permissions"][1]["entries"][0] + assert camera["attribution"] == "camera" + assert running["event"] == "running" + assert running["relative_time"] == "+3s" def test_ioc_check(self, indicator_file): da = DumpsysAppopsArtifact() From 87a27f6e3746d73b5208a4e93421626642267fe3 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:23 +0200 Subject: [PATCH 036/109] Normalize battery daily update records --- .../artifacts/dumpsys_battery_daily.py | 58 ++++++++++++------- .../test_artifact_dumpsys_battery_daily.py | 23 ++++++-- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_battery_daily.py b/src/mvt/android/artifacts/dumpsys_battery_daily.py index c0f2c5e0..2ccb6b70 100644 --- a/src/mvt/android/artifacts/dumpsys_battery_daily.py +++ b/src/mvt/android/artifacts/dumpsys_battery_daily.py @@ -18,18 +18,18 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: action = record.get("action", "update") package_name = record["package_name"] - vers = record["vers"] + vers = record["version_code"] - if vers == "0": + if vers == 0: data = f"Recorded uninstall of package {package_name} (vers 0)" elif action == "downgrade": - prev_vers = record.get("previous_vers", "unknown") + prev_vers = record.get("previous_version_code", "unknown") data = f"Recorded downgrade of package {package_name} from vers {prev_vers} to vers {vers}" else: data = f"Recorded update of package {package_name} with vers {vers}" return { - "timestamp": record["from"], + "timestamp": record["period_start"], "module": self.__class__.__name__, "event": "battery_daily", "data": data, @@ -48,6 +48,7 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): continue def parse(self, output: str) -> None: + self.results = [] daily = None daily_updates: list[dict[str, Any]] = [] records: list[dict[str, Any]] = [] @@ -59,7 +60,10 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): timeframe = line[13:].strip() date_from, date_to = timeframe.strip(":").split(" to ", 1) - daily = {"from": date_from[0:10], "to": date_to[0:10]} + daily = { + "period_start": self._format_daily_timestamp(date_from), + "period_end": self._format_daily_timestamp(date_to), + } continue if not daily: @@ -70,21 +74,30 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): line = line.strip().replace("Update ", "") package_name, vers = line.split(" ", 1) - vers_nr = vers.split("=", 1)[1] + vers_raw = vers.split("=", 1)[1] + try: + version_code: int | str = int(vers_raw) + except ValueError: + version_code = vers_raw already_seen = False for update in daily_updates: - if package_name == update["package_name"] and vers_nr == update["vers"]: + if ( + package_name == update["package_name"] + and version_code == update["version_code"] + ): + update["occurrences"] += 1 already_seen = True break if not already_seen: update_record: dict[str, Any] = { "action": "update", - "from": daily["from"], - "to": daily["to"], + "period_start": daily["period_start"], + "period_end": daily["period_end"], "package_name": package_name, - "vers": vers_nr, + "version_code": version_code, + "occurrences": 1, } daily_updates.append(update_record) @@ -95,26 +108,31 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): self._detect_uninstalls_and_downgrades(records) self.results.extend(records) - def _detect_uninstalls_and_downgrades( - self, records: list[dict[str, Any]] - ) -> None: + @staticmethod + def _format_daily_timestamp(value: str) -> str: + if len(value) >= 19 and value[10] == "-": + return f"{value[:10]} {value[11:].replace('-', ':')}" + return value + + 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["period_start"], + record["period_end"], record["package_name"], ), ): package_name = record["package_name"] - vers_nr = record["vers"] + vers_nr = record["version_code"] - if vers_nr == "0": + if vers_nr == 0: + record["action"] = "uninstall" self.alertstore.medium( f"Detected uninstall of package {package_name} (vers 0)", - record["from"], + record["period_start"], record, ) package_versions.pop(package_name, None) @@ -128,11 +146,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): 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) + record["previous_version_code"] = previous_vers self.alertstore.medium( f"Detected downgrade of package {package_name} " f"from vers {previous_vers} to vers {current_vers}", - record["from"], + record["period_start"], record, ) diff --git a/tests/android/test_artifact_dumpsys_battery_daily.py b/tests/android/test_artifact_dumpsys_battery_daily.py index dbb9c36b..5f7f997c 100644 --- a/tests/android/test_artifact_dumpsys_battery_daily.py +++ b/tests/android/test_artifact_dumpsys_battery_daily.py @@ -57,18 +57,19 @@ class TestDumpsysBatteryDailyArtifact: 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_time == "2022-08-16 15:56:39" assert uninstall_alert.event["package_name"] == "com.example.removed" - assert uninstall_alert.event["vers"] == "0" + assert uninstall_alert.event["version_code"] == 0 + assert uninstall_alert.event["action"] == "uninstall" 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_time == "2022-08-17 15:56:39" assert downgrade_alert.event["package_name"] == "com.example.app" assert downgrade_alert.event["action"] == "downgrade" - assert downgrade_alert.event["previous_vers"] == "10" + assert downgrade_alert.event["previous_version_code"] == 10 def test_newest_first_update_is_not_reported_as_downgrade(self): dba = DumpsysBatteryDailyArtifact() @@ -107,7 +108,19 @@ class TestDumpsysBatteryDailyArtifact: 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" + assert downgrade_alert.event["previous_version_code"] == 102 + + def test_duplicate_updates_retain_occurrence_count(self): + dba = DumpsysBatteryDailyArtifact() + dba.parse( + """ Daily from 2026-01-10-01-02-03 to 2026-01-11-04-05-06: + Update com.example.app vers=12 + Update com.example.app vers=12 +""" + ) + + assert dba.results[0]["occurrences"] == 2 + assert dba.results[0]["period_start"] == "2026-01-10 01:02:03" def test_reinstall_after_uninstall_is_not_reported_as_downgrade(self): dba = DumpsysBatteryDailyArtifact() From 288b3136498c0746ae0dffa979c71fe6c07966dc Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:23 +0200 Subject: [PATCH 037/109] Parse complete battery history events --- .../artifacts/dumpsys_battery_history.py | 225 ++++++++++++------ .../test_artifact_dumpsys_battery_history.py | 21 ++ 2 files changed, 174 insertions(+), 72 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_battery_history.py b/src/mvt/android/artifacts/dumpsys_battery_history.py index 77d9d3c1..6cb36f18 100644 --- a/src/mvt/android/artifacts/dumpsys_battery_history.py +++ b/src/mvt/android/artifacts/dumpsys_battery_history.py @@ -1,96 +1,177 @@ # Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. +# 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 datetime +import re + +from mvt.common.utils import convert_datetime_to_iso + from .artifact import AndroidArtifact class DumpsysBatteryHistoryArtifact(AndroidArtifact): - """ - Parser for dumpsys dattery history events. - """ + """Parser for package-related events in dumpsys batterystats history.""" def check_indicators(self) -> None: if not self.indicators: return - for result in self.results: - ioc_match = self.indicators.check_app_id(result["package_name"]) + package_name = result.get("package_name") + if not package_name: + continue + ioc_match = self.indicators.check_app_id(package_name) if ioc_match: self.alertstore.critical( ioc_match.message, "", result, matched_indicator=ioc_match.ioc ) - continue + + @staticmethod + def _parse_wall_time(value: str) -> datetime.datetime | None: + if re.fullmatch(r"\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", value): + value = f"1900-{value}" + for date_format in ( + "%Y-%m-%d-%H-%M-%S-%f", + "%Y-%m-%d-%H-%M-%S", + "%Y-%m-%d %H:%M:%S.%f", + ): + try: + return datetime.datetime.strptime(value, date_format) + except ValueError: + pass + return None + + @staticmethod + def _elapsed_seconds(value: str) -> float | None: + if not value.startswith("+"): + return None + units = {"d": 86400, "h": 3600, "m": 60, "s": 1, "ms": 0.001} + total = 0.0 + for number, unit in re.findall(r"(\d+)(ms|d|h|m|s)", value): + total += int(number) * units[unit] + return total + + @staticmethod + def _package_from_name(name: str) -> str | None: + clean = name.removeprefix("*walarm*:").removeprefix("*alarm*:") + slash_parts = clean.split("/") + if len(slash_parts) > 1: + first = slash_parts[0].lstrip("@") + if first.startswith(("com.", "org.", "net.")): + return first + for part in reversed(slash_parts[1:]): + candidate = part.lstrip("@").split(":", 1)[0] + if candidate.startswith(("com.", "org.", "net.")): + return candidate + parts = clean.split(".") + package_parts = [] + for part in parts: + if part and part[0].islower(): + package_parts.append(part) + else: + break + return ".".join(package_parts) if len(package_parts) >= 2 else None + + @staticmethod + def _normalize_service(service: str) -> str: + # WorkManager decorates jobs with one or more scheduler prefixes. + if "@" in service: + candidates = [part for part in service.split("@") if "/" in part] + if candidates: + return candidates[-1] + return service def parse(self, data: str) -> None: + self.results: list[dict[str, str | None]] = [] + anchor_time: datetime.datetime | None = None + anchor_elapsed = 0.0 + has_history_heading = any( + line.startswith("Battery History") for line in data.splitlines() + ) + in_history = not has_history_heading + for line in data.splitlines(): - if line.startswith("Battery History "): + stripped = line.strip() + if line.startswith("Battery History"): + if in_history: + break + in_history = True continue - - if line.strip() == "": + if not in_history: + continue + if has_history_heading and not stripped: break - - 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" - 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" - 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: - 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() - ) - if service == "" or "/" not in service: - continue - - package_name = service.split("/")[0] - elif (line.find("+top=") > 0) or (line.find("-top") > 0): - if line.find("+top=") > 0: - event = "start_top" - top_pos = line.find("+top=") - else: - event = "end_top" - top_pos = line.find("-top=") - colon_pos = top_pos + line[top_pos:].find(":") - uid = line[top_pos + 5 : colon_pos] - service = "" - package_name = line[colon_pos + 1 :].strip('"') - else: + reset_match = re.search(r"(?:RESET:)?TIME:\s*(\S+)", stripped) + if reset_match: + parsed_time = self._parse_wall_time(reset_match.group(1)) + if parsed_time is not None: + elapsed_token = stripped.split()[0] + anchor_elapsed = self._elapsed_seconds(elapsed_token) or 0.0 + anchor_time = parsed_time continue - self.results.append( - { - "time_elapsed": time_elapsed, - "event": event, - "uid": uid, - "package_name": package_name, - "service": service, - } - ) + fields = stripped.split() + if not fields: + continue + if len(fields) > 1 and re.fullmatch(r"\d{2}-\d{2}", fields[0]): + time_elapsed = " ".join(fields[:2]) + line_time = self._parse_wall_time(time_elapsed) + elapsed = None + else: + time_elapsed = fields[0] + elapsed = self._elapsed_seconds(time_elapsed) + line_time = None + + timestamp = line_time + if timestamp is None and anchor_time is not None and elapsed is not None: + timestamp = anchor_time + datetime.timedelta( + seconds=elapsed - anchor_elapsed + ) + + def add( + event: str, uid: str, service: str, package_name: str | None + ) -> None: + self.results.append( + { + "time_elapsed": time_elapsed, + "timestamp": convert_datetime_to_iso(timestamp) + if timestamp + else None, + "event": event, + "uid": uid, + "package_name": package_name, + "service": service, + } + ) + + for sign, uid, raw_service in re.findall( + r"([+-])job=([^:\s]+):\"([^\"]+)\"", line + ): + service = self._normalize_service(raw_service) + add( + "start_job" if sign == "+" else "end_job", + uid, + service, + self._package_from_name(service) or service.split("/", 1)[0], + ) + + for sign, uid, package_name in re.findall( + r"([+-])top=([^:\s]+):\"([^\"]+)\"", line + ): + add( + "start_top" if sign == "+" else "end_top", + uid, + "", + package_name, + ) + + wake_match = re.search(r"\+wake_lock=([^:\s]+):\"([^\"]+)\"", line) + if wake_match: + wake_name = wake_match.group(2) + add( + "wake", + wake_match.group(1), + wake_name, + self._package_from_name(wake_name), + ) diff --git a/tests/android/test_artifact_dumpsys_battery_history.py b/tests/android/test_artifact_dumpsys_battery_history.py index dd0b1201..091ce55c 100644 --- a/tests/android/test_artifact_dumpsys_battery_history.py +++ b/tests/android/test_artifact_dumpsys_battery_history.py @@ -54,6 +54,7 @@ class TestDumpsysBatteryHistoryArtifact: assert len(dba.results) == 2 assert dba.results[0] == { "time_elapsed": "07-15 20:27:39.431", + "timestamp": "1900-07-15 20:27:39.431000", "event": "start_job", "uid": "u0a123", "package_name": "com.example", @@ -61,3 +62,23 @@ class TestDumpsysBatteryHistoryArtifact: } assert dba.results[1]["event"] == "end_job" assert dba.results[1]["uid"] == "u0a123" + + def test_wake_lock_without_component_is_retained(self): + dba = DumpsysBatteryHistoryArtifact() + dba.parse( + "Battery History:\n" + " 0 (2) 100 RESET:TIME: 2025-09-05-01-04-52-139\n" + ' +1s (2) 100 +running +wake_lock=1000:"*alarm*:TIME_TICK"\n' + ' +2s (2) 100 +running +wake_lock=u0a1:"*walarm*:com.whatsapp.MessageHandler.LOGOUT_ACTION"\n' + "\n" + ) + + assert [record["event"] for record in dba.results] == ["wake", "wake"] + assert dba.results[0]["package_name"] is None + assert dba.results[1]["package_name"] == "com.whatsapp" + + def test_decorated_sync_job_uses_component_package(self): + dba = DumpsysBatteryHistoryArtifact() + dba.parse('+1s (2) 100 +job=u0a1:"@SyncManager@gmail-ls/com.google:android"\n') + + assert dba.results[0]["package_name"] == "com.google" From e9bf197ec61a75be966df689d4405e758de4b22a Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:23 +0200 Subject: [PATCH 038/109] Parse all database operation details --- src/mvt/android/artifacts/dumpsys_dbinfo.py | 36 +++++++++++++------ tests/android/test_artifact_dumpsys_dbinfo.py | 25 ++++++++++++- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_dbinfo.py b/src/mvt/android/artifacts/dumpsys_dbinfo.py index 050582df..0972b438 100644 --- a/src/mvt/android/artifacts/dumpsys_dbinfo.py +++ b/src/mvt/android/artifacts/dumpsys_dbinfo.py @@ -29,16 +29,27 @@ class DumpsysDBInfoArtifact(AndroidArtifact): def parse(self, output: str) -> None: rxp = re.compile( - 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 + r"^\s*\d+:\s*\[((?:\d{4}-)?\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\]\s*" + r"(?:\[Pid:\((\d+)\)\])?\s*([\w-]+) took (\d+)ms - ([^,]+)," + r"\s*sql=\"(.*)\"(?:, path=(.*))?$" + ) - pool = None + pool: str | None = None + connection_number: int | None = None + is_primary: bool | None = None in_operations = False for line in output.splitlines(): if line.startswith("Connection pool for "): pool = line.replace("Connection pool for ", "").rstrip(":") + in_operations = False + + connection_match = re.match(r"\s+Connection #(\d+):", line) + if connection_match: + connection_number = int(connection_match.group(1)) + is_primary = None + + if line.strip().startswith("isPrimaryConnection:"): + is_primary = line.strip().split(":", 1)[1].strip() == "true" if not pool: continue @@ -52,7 +63,6 @@ class DumpsysDBInfoArtifact(AndroidArtifact): if not line.startswith(" "): in_operations = False - pool = None continue match = rxp.match(line) @@ -60,11 +70,15 @@ class DumpsysDBInfoArtifact(AndroidArtifact): continue result = { - "isodate": match.group(1), + "timestamp": match.group(1), + "pid": int(match.group(2)) if match.group(2) else None, "action": match.group(3), - "sql": match.group(4), - "path": pool, + "duration_ms": int(match.group(4)), + "status": match.group(5), + "sql": match.group(6), + "path": match.group(7) or pool, + "pool_path": pool, + "connection_number": connection_number, + "is_primary": is_primary, } - if match.group(2): - result["pid"] = match.group(2) self.results.append(result) diff --git a/tests/android/test_artifact_dumpsys_dbinfo.py b/tests/android/test_artifact_dumpsys_dbinfo.py index 691d43c4..0dcc0bc3 100644 --- a/tests/android/test_artifact_dumpsys_dbinfo.py +++ b/tests/android/test_artifact_dumpsys_dbinfo.py @@ -53,9 +53,32 @@ Connection pool for /data/user/0/com.example/databases/current.db: assert dbi.results == [ { - "isodate": "07-15 20:27:39.431", + "timestamp": "07-15 20:27:39.431", + "pid": None, "action": "executeForCursorWindow", + "duration_ms": 1, + "status": "succeeded", "sql": "SELECT 1", "path": "/data/user/0/com.example/databases/current.db", + "pool_path": "/data/user/0/com.example/databases/current.db", + "connection_number": None, + "is_primary": None, } ] + + def test_parses_operations_from_multiple_connections(self): + dbi = DumpsysDBInfoArtifact() + dbi.parse( + """Connection pool for /data/example.db: + Connection #0: + isPrimaryConnection: true + Most recently executed operations: + 0: [2025-01-01 00:00:00.000] execute took 1ms - succeeded, sql="SELECT 1", path=/data/example.db + Connection #1: + isPrimaryConnection: false + Most recently executed operations: + 0: [2025-01-01 00:00:01.000] execute took 2ms - succeeded, sql="SELECT 2", path=/data/example.db +""" + ) + + assert [record["connection_number"] for record in dbi.results] == [0, 1] From a3c8b56102e38a004f1ee605702091cfea562f47 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:23 +0200 Subject: [PATCH 039/109] Parse typed multi-user package details --- src/mvt/android/artifacts/dumpsys_packages.py | 388 +++++++++--------- .../modules/bugreport/dumpsys_packages.py | 5 +- .../android/test_artifact_dumpsys_packages.py | 61 ++- 3 files changed, 234 insertions(+), 220 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_packages.py b/src/mvt/android/artifacts/dumpsys_packages.py index bef77444..1194a75d 100644 --- a/src/mvt/android/artifacts/dumpsys_packages.py +++ b/src/mvt/android/artifacts/dumpsys_packages.py @@ -1,10 +1,10 @@ # Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. +# 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 re -from typing import Any, Dict, List, Optional +from typing import Any from mvt.android.utils import ROOT_PACKAGES from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult @@ -12,222 +12,244 @@ from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult from .artifact import AndroidArtifact +def _value(raw: str) -> Any: + if raw == "null": + return None + if raw in ("true", "false"): + return raw == "true" + try: + return int(raw) + except ValueError: + return raw + + class DumpsysPackagesArtifact(AndroidArtifact): def check_indicators(self) -> None: alerted_root_packages = set() for result in self.results: - if result["package_name"] in ROOT_PACKAGES: - if result["package_name"] in alerted_root_packages: - continue - alerted_root_packages.add(result["package_name"]) + package_name = result["package_name"] + if ( + package_name in ROOT_PACKAGES + and package_name not in alerted_root_packages + ): + alerted_root_packages.add(package_name) self.alertstore.medium( - f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"', + f'Found an installed package related to rooting/jailbreaking: "{package_name}"', "", result, ) - continue - if not self.indicators: continue - - ioc_match = self.indicators.check_app_id(result.get("package_name", "")) + ioc_match = self.indicators.check_app_id(package_name) if ioc_match: self.alertstore.critical( ioc_match.message, "", result, matched_indicator=ioc_match.ioc ) def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: - records = [] timestamps = [ - {"event": "package_install", "timestamp": record["timestamp"]}, + ("package_install", record.get("timestamp")), + ("package_last_update", record.get("last_update_time")), + ] + timestamps.extend( + ("package_first_install", user.get("first_install_time")) + for user in record.get("users", []) + ) + return [ { - "event": "package_first_install", - "timestamp": record["first_install_time"], - }, - {"event": "package_last_update", "timestamp": record["last_update_time"]}, + "timestamp": timestamp, + "module": self.__class__.__name__, + "event": event, + "data": f"Install or update of package {record['package_name']}", + } + for event, timestamp in timestamps + if timestamp ] - for timestamp in timestamps: - records.append( - { - "timestamp": timestamp["timestamp"], - "module": self.__class__.__name__, - "event": timestamp["event"], - "data": f"Install or update of package {record['package_name']}", - } - ) - - return records - @staticmethod - def parse_dumpsys_package_for_details(output: str) -> Dict[str, Any]: - """ - Parse one entry of a dumpsys package information - """ - details: Dict[str, Any] = { - "uid": "", - "version_name": "", - "version_code": "", - "timestamp": "", - "first_install_time": "", - "last_update_time": "", - "installer": "", - "system": False, - "permissions": list(), - "requested_permissions": list(), + def _permission(line: str, permission_type: str) -> dict: + name, _, details = line.strip().partition(":") + granted_match = re.search(r"granted=(true|false)", details) + flags_match = re.search(r"flags=\[\s*([^]]*)\]", details) + return { + "name": name, + "type": permission_type, + "granted": granted_match.group(1) == "true" if granted_match else None, + "flags": [ + flag.strip() + for flag in (flags_match.group(1).split("|") if flags_match else []) + if flag.strip() + ], } - 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]]] = {} + + @classmethod + def parse_dumpsys_package_for_details(cls, output: str) -> dict[str, Any]: + details: dict[str, Any] = { + "app_id": None, + "version_name": None, + "version_code": None, + "min_sdk": None, + "target_sdk": None, + "timestamp": None, + "last_update_time": None, + "installer": None, + "system": False, + "permissions": [], + "requested_permissions": [], + "users": [], + } + permission_section: str | None = None + current_user: dict[str, Any] | None = None + legacy_first_install: str | None = None + for line in output.splitlines(): - user_match = re.match(r"User (\d+):", line.strip()) + stripped = line.strip() + user_match = re.match(r"User (\d+):\s*(.*)", stripped) if user_match: - current_user = int(user_match.group(1)) + current_user = {"user_id": int(user_match.group(1)), "permissions": []} + for key, raw in re.findall(r"(\w+)=([^\s]+)", user_match.group(2)): + clean_key = { + "notLaunched": "not_launched", + "installReason": "install_reason", + "uninstallReason": "uninstall_reason", + "dataDir": "data_dir", + }.get(key, re.sub(r"(? List[Dict[str, Any]]: - """ - Parse the dumpsys package service data - """ - pkg_rxp = re.compile(r" Package \[(.+?)\].*") - - results = [] - package_name = None - package = {} - lines: list[str] = [] - for line in output.splitlines(): - if line.startswith(" Package ["): - if len(lines) > 0: - details = self.parse_dumpsys_package_for_details("\n".join(lines)) - package.update(details) - results.append(package) - lines = [] - package = {} - - matches = pkg_rxp.findall(line) - if not matches: + if current_user is not None: + user_property = re.match( + r"(installReason|uninstallReason|dataDir|firstInstallTime)=(.*)", + stripped, + ) + if user_property: + key = { + "installReason": "install_reason", + "uninstallReason": "uninstall_reason", + "dataDir": "data_dir", + "firstInstallTime": "first_install_time", + }[user_property.group(1)] + current_user[key] = _value(user_property.group(2)) continue - package_name = matches[0] - package["package_name"] = package_name + if permission_section == "requested" and line.startswith(" "): + details["requested_permissions"].append(stripped) + continue + if permission_section in ("declared", "install") and line.startswith( + " " + ): + details["permissions"].append( + cls._permission(stripped, permission_section) + ) + continue + if ( + permission_section == "runtime" + and line.startswith(" ") + and current_user is not None + ): + current_user["permissions"].append(cls._permission(stripped, "runtime")) continue - if not package_name: + simple_match = re.match( + r"(appId|userId|versionName|timeStamp|lastUpdateTime|installerPackageName)=(.*)", + stripped, + ) + if simple_match: + key = { + "appId": "app_id", + "userId": "app_id", + "versionName": "version_name", + "timeStamp": "timestamp", + "lastUpdateTime": "last_update_time", + "installerPackageName": "installer", + }[simple_match.group(1)] + raw_value = simple_match.group(2) + details[key] = ( + _value(raw_value) + if key == "app_id" or raw_value == "null" + else raw_value + ) continue + if stripped.startswith("pkgFlags="): + details["system"] = "SYSTEM" in stripped.split("=", 1)[1].split() + continue + version_match = re.match( + r"versionCode=([^\s]+)(?:\s+minSdk=([^\s]+))?(?:\s+targetSdk=([^\s]+))?", + stripped, + ) + if version_match: + details["version_code"] = _value(version_match.group(1)) + details["min_sdk"] = ( + _value(version_match.group(2)) if version_match.group(2) else None + ) + details["target_sdk"] = ( + _value(version_match.group(3)) if version_match.group(3) else None + ) + elif stripped.startswith("firstInstallTime="): + legacy_first_install = stripped.split("=", 1)[1] - lines.append(line) + if legacy_first_install: + user_zero = next( + (user for user in details["users"] if user["user_id"] == 0), None + ) + if user_zero is None: + user_zero = {"user_id": 0, "permissions": []} + details["users"].append(user_zero) + user_zero.setdefault("first_install_time", legacy_first_install) + return details - if len(lines) > 0: - details = self.parse_dumpsys_package_for_details("\n".join(lines)) - package.update(details) - results.append(package) + def parse(self, content: str) -> None: + self.results: list[dict[str, Any]] = [] + category: str | None = None + package: dict[str, Any] | None = None + block: list[str] = [] - return results + def finish() -> None: + nonlocal package, block + if package is not None: + package.update(self.parse_dumpsys_package_for_details("\n".join(block))) + self.results.append(package) + package = None + block = [] - def parse(self, content: str): - """ - Parse the Dumpsys Package section for activities - Adds results to self.results - - :param content: content of the package section (string) - """ - self.results = [] - package = [] - - in_package_list = False for line in content.splitlines(): - if line.startswith("Packages:"): - in_package_list = True + if line == "Packages:": + finish() + category = "active" continue - - if not in_package_list: + if line == "Hidden system packages:": + finish() + category = "hidden_system" continue - - if line.strip() == "": - break - - package.append(line) - - self.results = self.parse_dumpsys_packages("\n".join(package)) + package_match = re.match(r"^ Package \[([^]]+)\]", line) + if package_match and category: + finish() + package = { + "package_name": package_match.group(1), + "package_type": category, + } + continue + if ( + category + and line + and not line.startswith(" ") + and not line.endswith(" overlay paths:") + ): + finish() + category = None + continue + if package is not None: + block.append(line) + finish() diff --git a/src/mvt/android/modules/bugreport/dumpsys_packages.py b/src/mvt/android/modules/bugreport/dumpsys_packages.py index aebec4c2..ff944e3e 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_packages.py +++ b/src/mvt/android/modules/bugreport/dumpsys_packages.py @@ -50,7 +50,10 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule): for result in self.results: dangerous_permissions_count = 0 - for perm in result["permissions"]: + permissions = list(result["permissions"]) + for user in result.get("users", []): + permissions.extend(user.get("permissions", [])) + for perm in permissions: if perm["name"] in DANGEROUS_PERMISSIONS: dangerous_permissions_count += 1 diff --git a/tests/android/test_artifact_dumpsys_packages.py b/tests/android/test_artifact_dumpsys_packages.py index bd5fb753..f22cc67f 100644 --- a/tests/android/test_artifact_dumpsys_packages.py +++ b/tests/android/test_artifact_dumpsys_packages.py @@ -25,7 +25,11 @@ 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]["version_code"] == 500700000 + assert dpa.results[0]["min_sdk"] == 28 + assert dpa.results[0]["target_sdk"] == 28 + assert dpa.results[0]["package_type"] == "active" + assert dpa.results[0]["users"][0]["user_id"] == 0 assert dpa.results[0]["system"] is True def test_parsing_system_flag(self): @@ -60,42 +64,27 @@ class TestDumpsysPackagesArtifact: dpa.check_indicators() 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 + def test_hidden_packages_and_per_user_state(self): + dpa = DumpsysPackagesArtifact() + dpa.parse( + """Packages: + Package [com.example.active]: + appId=10001 + versionCode=12 minSdk=29 targetSdk=35 + User 0: installed=true hidden=false + firstInstallTime=2025-01-01 01:02:03 + User 10: installed=false hidden=true + firstInstallTime=2025-01-02 01:02:03 +Hidden system packages: + Package [com.example.hidden]: + appId=10002 + installerPackageName=null """ ) - assert details["first_install_time"] == "2024-01-10 09:19:39" - runtime_permissions = [ - permission - for permission in details["permissions"] - if permission["type"] == "runtime" + assert [record["package_type"] for record in dpa.results] == [ + "active", + "hidden_system", ] - 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" + assert len(dpa.results[0]["users"]) == 2 + assert dpa.results[1]["installer"] is None From 832e46604d40dd14532cb3ad560ae4f6414e94d3 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:23 +0200 Subject: [PATCH 040/109] Parse all platform compatibility overrides --- .../artifacts/dumpsys_platform_compat.py | 50 +++++++++++++------ .../bugreport/dumpsys_platform_compat.py | 4 +- .../test_artifact_dumpsys_platform_compat.py | 3 ++ 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_platform_compat.py b/src/mvt/android/artifacts/dumpsys_platform_compat.py index c01fbfd8..08cb46e3 100644 --- a/src/mvt/android/artifacts/dumpsys_platform_compat.py +++ b/src/mvt/android/artifacts/dumpsys_platform_compat.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 re + from .artifact import AndroidArtifact @@ -25,19 +27,37 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact): def parse(self, data: str) -> None: for line in data.splitlines(): - if not line.startswith("ChangeId(168419799; name=DOWNSCALED;"): + match = re.match(r"ChangeId\((\d+);\s*(.*)\)$", line.strip()) + if not match or "rawOverrides={" not in line: continue - - if line.strip() == "": - break - - # Look for rawOverrides field - if "rawOverrides={" in line: - # Extract the content inside the braces for rawOverrides - overrides_field = line.split("rawOverrides={", 1)[1].split("};", 1)[0] - - for entry in overrides_field.split(", "): - # Extract app name - uninstall_app = entry.split("=")[0].strip() - - self.results.append({"package_name": uninstall_app}) + body = match.group(2) + name_match = re.search(r"(?:^|;\s*)name=([^;]+)", body) + state = ( + "enabled" + if re.search(r"(?:^|;\s*)enabled(?:;|$)", body) + else "disabled" + ) + overridable = bool(re.search(r"(?:^|;\s*)overridable(?:;|$)", body)) + overrides_field = body.split("rawOverrides={", 1)[1].split("}", 1)[0] + for entry in overrides_field.split(","): + package_name, separator, raw_value = entry.strip().partition("=") + if not separator: + continue + value: bool | int | str + if raw_value in ("true", "false"): + value = raw_value == "true" + else: + try: + value = int(raw_value) + except ValueError: + value = raw_value + self.results.append( + { + "change_id": int(match.group(1)), + "change_name": name_match.group(1) if name_match else None, + "change_state": state, + "overridable": overridable, + "package_name": package_name, + "override_value": value, + } + ) diff --git a/src/mvt/android/modules/bugreport/dumpsys_platform_compat.py b/src/mvt/android/modules/bugreport/dumpsys_platform_compat.py index 968bc25e..dd06b40b 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_platform_compat.py +++ b/src/mvt/android/modules/bugreport/dumpsys_platform_compat.py @@ -13,7 +13,7 @@ from mvt.common.module_types import ModuleResults class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule): - """This module extracts details on uninstalled apps.""" + """This module extracts raw per-package compatibility overrides.""" def __init__( self, @@ -48,4 +48,4 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule): ) self.parse(content) - self.log.info("Found %d uninstalled apps", len(self.results)) + self.log.info("Found %d package compatibility overrides", len(self.results)) diff --git a/tests/android/test_artifact_dumpsys_platform_compat.py b/tests/android/test_artifact_dumpsys_platform_compat.py index c8c59b3d..c202645a 100644 --- a/tests/android/test_artifact_dumpsys_platform_compat.py +++ b/tests/android/test_artifact_dumpsys_platform_compat.py @@ -22,6 +22,9 @@ class TestDumpsysPlatformCompatArtifact: assert len(dbi.results) == 2 assert dbi.results[0]["package_name"] == "org.torproject.torbrowser" assert dbi.results[1]["package_name"] == "org.article19.circulo.next" + assert dbi.results[0]["change_id"] == 168419799 + assert dbi.results[0]["change_name"] == "DOWNSCALED" + assert dbi.results[0]["override_value"] is False def test_ioc_check(self, indicator_file): dbi = DumpsysPlatformCompatArtifact() From f747751b09a11f702c8215238dfb3c449e61a285 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:24 +0200 Subject: [PATCH 041/109] Add timezone provenance to file timestamps --- .../modules/bugreport/fs_timestamps.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/mvt/android/modules/bugreport/fs_timestamps.py b/src/mvt/android/modules/bugreport/fs_timestamps.py index 5a2ca485..3c7e3248 100644 --- a/src/mvt/android/modules/bugreport/fs_timestamps.py +++ b/src/mvt/android/modules/bugreport/fs_timestamps.py @@ -4,12 +4,15 @@ # https://license.mvt.re/1.1/ import logging +import datetime from typing import Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 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 +from mvt.android.artifacts.getprop import GetProp class BugReportTimestamps(FileTimestampsArtifact, BugReportModule): @@ -38,15 +41,44 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule): def run(self) -> None: filesystem_files = self._get_files_by_pattern("FS/*") + timezone_name = None + dumpstate = self._get_dumpstate_file() + if dumpstate: + section = self.extract_command_section( + dumpstate.decode("utf-8", errors="replace"), + "------ SYSTEM PROPERTIES", + ) + properties = GetProp() + properties.parse(section) + timezone_name = properties.get_device_timezone() + timezone = None + if timezone_name: + try: + timezone = ZoneInfo(timezone_name) + except ZoneInfoNotFoundError: + self.log.warning("Unknown device timezone %s", timezone_name) + self.results = [] for file in filesystem_files: # Only the modification time is available in the zip file metadata. # The timezone is the local timezone of the machine the phone. modification_time = self._get_file_modification_time(file) + utc_time = None + if timezone is not None: + utc_time = convert_datetime_to_iso( + modification_time.replace(tzinfo=timezone).astimezone( + datetime.timezone.utc + ) + ) self.results.append( { "path": file, "modified_time": convert_datetime_to_iso(modification_time), + "modified_time_utc": utc_time, + "timezone": timezone_name, + "timestamp_source": ( + "zip_metadata" if self.zip_archive else "filesystem_metadata" + ), } ) From 8b85972cc310d6d7ba2f8f82eb3573d89ff57b96 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:24 +0200 Subject: [PATCH 042/109] Add bugreport process table parser --- src/mvt/android/artifacts/processes.py | 127 ++++++++++-------- .../android/modules/bugreport/processes.py | 22 +++ tests/android/test_artifact_processes.py | 13 +- 3 files changed, 107 insertions(+), 55 deletions(-) create mode 100644 src/mvt/android/modules/bugreport/processes.py diff --git a/src/mvt/android/artifacts/processes.py b/src/mvt/android/artifacts/processes.py index 5471eb29..97e27a90 100644 --- a/src/mvt/android/artifacts/processes.py +++ b/src/mvt/android/artifacts/processes.py @@ -1,72 +1,91 @@ # Mobile Verification Toolkit (MVT) -# Copyright (c) 2021-2023 The MVT Authors. +# 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/ from .artifact import AndroidArtifact +FIELD_NAMES = { + "LABEL": "label", + "USER": "user", + "PID": "pid", + "TID": "tid", + "PPID": "ppid", + "VSZ": "virtual_memory_size", + "RSS": "resident_set_size", + "WCHAN": "wchan", + "ADDR": "address", + "S": "state", + "PRI": "priority", + "NI": "nice", + "RTPRIO": "realtime_priority", + "SCH": "scheduler", + "PCY": "policy", + "TIME": "cpu_time", + "CMD": "command", + "NAME": "command", +} +INTEGER_FIELDS = { + "pid", + "tid", + "ppid", + "virtual_memory_size", + "resident_set_size", + "priority", + "nice", +} + + class Processes(AndroidArtifact): def parse(self, entry: str) -> None: - for line in entry.splitlines()[1:]: - proc = line.split() + self.results = [] + lines = [line for line in entry.splitlines() if line.strip()] + if not lines: + return + headers = lines[0].split() + if not all(header in FIELD_NAMES for header in headers): + return - # Skip empty lines - if len(proc) == 0: + for line in lines[1:]: + values = line.split(None, len(headers) - 1) + if len(values) != len(headers): continue - - # Sometimes WCHAN is empty. - if len(proc) == 8: - proc = proc[:5] + [""] + proc[5:] - - # Sometimes there is the security label. - if proc[0].startswith("u:r"): - label = proc[0] - proc = proc[1:] - else: - label = "" - - # Sometimes there is no WCHAN. - if len(proc) < 9: - proc = proc[:5] + [""] + proc[5:] - - self.results.append( - { - "user": proc[0], - "pid": int(proc[1]), - "ppid": int(proc[2]), - "virtual_memory_size": int(proc[3]), - "resident_set_size": int(proc[4]), - "wchan": proc[5], - "aprocress": proc[6], - "stat": proc[7], - "proc_name": proc[8].strip("[]"), - "label": label, - } - ) + result = {} + valid = True + for header, raw in zip(headers, values): + key = FIELD_NAMES[header] + value: str | int = raw.strip("[]") if key == "command" else raw + if key in INTEGER_FIELDS: + try: + value = int(value) + except ValueError: + valid = False + break + result[key] = value + if valid: + self.results.append(result) def check_indicators(self) -> None: if not self.indicators: return - for result in self.results: - proc_name = result.get("proc_name", "") - if not proc_name: + command = result.get("command", "") + if not isinstance(command, str): continue - - # Skipping this process because of false positives. - if result["proc_name"] == "gatekeeperd": + process_name = command.rsplit("/", 1)[-1] + if not process_name or process_name == "gatekeeperd": continue - - 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_match = self.indicators.check_process(proc_name) - if ioc_match: - self.alertstore.critical( - ioc_match.message, "", result, matched_indicator=ioc_match.ioc - ) + for checker in ( + self.indicators.check_app_id, + self.indicators.check_process, + ): + ioc_match = checker(process_name) + if ioc_match: + self.alertstore.critical( + ioc_match.message, + "", + result, + matched_indicator=ioc_match.ioc, + ) + break diff --git a/src/mvt/android/modules/bugreport/processes.py b/src/mvt/android/modules/bugreport/processes.py new file mode 100644 index 00000000..4f9b3347 --- /dev/null +++ b/src/mvt/android/modules/bugreport/processes.py @@ -0,0 +1,22 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2026 The MVT Authors. + +from mvt.android.artifacts.processes import Processes as ProcessesArtifact + +from .base import BugReportModule + + +class Processes(ProcessesArtifact, BugReportModule): + """Extract the process and thread table from dumpstate.""" + + def run(self) -> None: + data = self._get_dumpstate_file() + if not data: + self.log.error("Unable to find dumpstate file") + return + section = self.extract_command_section( + data.decode("utf-8", errors="replace"), + "------ PROCESSES AND THREADS", + ) + self.parse(section) + self.log.info("Identified %d running process threads", len(self.results)) diff --git a/tests/android/test_artifact_processes.py b/tests/android/test_artifact_processes.py index 2806472d..4d510377 100644 --- a/tests/android/test_artifact_processes.py +++ b/tests/android/test_artifact_processes.py @@ -20,7 +20,7 @@ class TestProcessesArtifact: assert len(p.results) == 0 p.parse(data) assert len(p.results) == 17 - assert p.results[0]["proc_name"] == "init" + assert p.results[0]["command"] == "init" def test_ioc_check(self, indicator_file): p = Processes() @@ -36,3 +36,14 @@ class TestProcessesArtifact: assert len(p.alertstore.alerts) == 0 p.check_indicators() assert len(p.alertstore.alerts) == 1 + + def test_bugreport_thread_columns(self): + p = Processes() + p.parse( + "LABEL USER PID TID PPID VSZ RSS WCHAN ADDR S PRI NI RTPRIO SCH PCY TIME CMD\n" + "u:r:init:s0 root 1 2 0 100 20 0 0 S 19 0 - 0 fg 00:00:01 init\n" + ) + + assert p.results[0]["label"] == "u:r:init:s0" + assert p.results[0]["tid"] == 2 + assert p.results[0]["command"] == "init" From 627ab32d427fb9d43a4bbc552a586f0a06ae22d9 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:24 +0200 Subject: [PATCH 043/109] Add bugreport settings parser --- src/mvt/android/artifacts/settings.py | 24 +++++++++++++++++++ src/mvt/android/modules/bugreport/settings.py | 22 +++++++++++++++++ tests/android_androidqf/test_settings.py | 15 ++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 src/mvt/android/modules/bugreport/settings.py diff --git a/src/mvt/android/artifacts/settings.py b/src/mvt/android/artifacts/settings.py index 17c3bf2c..e0cd5f0b 100644 --- a/src/mvt/android/artifacts/settings.py +++ b/src/mvt/android/artifacts/settings.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 re + from .artifact import AndroidArtifact ANDROID_DANGEROUS_SETTINGS = [ @@ -60,6 +62,28 @@ ANDROID_DANGEROUS_SETTINGS = [ class Settings(AndroidArtifact): + def parse(self, content: str) -> None: + self.results: dict[str, dict[str, str]] = {} + namespace: str | None = None + for line in content.splitlines(): + heading = re.match( + r"^(CONFIG|GLOBAL|SECURE|SYSTEM) SETTINGS \(user (\d+)\)$", + line.strip(), + ) + if heading: + namespace = f"{heading.group(1).lower()}:user_{heading.group(2)}" + self.results[namespace] = {} + continue + if namespace is None or not line.startswith("_id:"): + continue + setting = re.match( + r"^_id:\S+\s+name:(.*?)\s+pkg:.*?\s+value:(.*?)" + r"(?:\s+default:.*\s+defaultSystemSet:(?:true|false))?$", + line, + ) + if setting: + self.results[namespace][setting.group(1)] = setting.group(2) + def check_indicators(self) -> None: for namespace, settings in self.results.items(): for key, value in settings.items(): diff --git a/src/mvt/android/modules/bugreport/settings.py b/src/mvt/android/modules/bugreport/settings.py new file mode 100644 index 00000000..20180adf --- /dev/null +++ b/src/mvt/android/modules/bugreport/settings.py @@ -0,0 +1,22 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2026 The MVT Authors. + +from mvt.android.artifacts.settings import Settings as SettingsArtifact + +from .base import BugReportModule + + +class Settings(SettingsArtifact, BugReportModule): + """Extract all SettingsProvider namespaces and users.""" + + def run(self) -> None: + data = self._get_dumpstate_file() + if not data: + self.log.error("Unable to find dumpstate file") + return + section = self.extract_dumpsys_section( + data.decode("utf-8", errors="replace"), "DUMP OF SERVICE settings:" + ) + self.parse(section) + count = sum(len(settings) for settings in self.results.values()) + self.log.info("Identified %d Android settings", count) diff --git a/tests/android_androidqf/test_settings.py b/tests/android_androidqf/test_settings.py index ce14460f..6edfec81 100644 --- a/tests/android_androidqf/test_settings.py +++ b/tests/android_androidqf/test_settings.py @@ -6,12 +6,27 @@ from pathlib import Path from mvt.android.modules.androidqf.aqf_settings import AQFSettings +from mvt.android.artifacts.settings import Settings from mvt.common.module import run_module from ..utils import get_android_androidqf, list_files class TestSettingsModule: + def test_bugreport_settings_format(self): + settings = Settings() + settings.parse( + "GLOBAL SETTINGS (user 0)\n" + "_id:1 name:adb_wifi_enabled pkg:android value:0 default:0 defaultSystemSet:true\n" + "SECURE SETTINGS (user 10)\n" + "_id:2 name:accessibility_enabled pkg:android value:1\n" + ) + + assert settings.results == { + "global:user_0": {"adb_wifi_enabled": "0"}, + "secure:user_10": {"accessibility_enabled": "1"}, + } + def test_parsing(self): data_path = get_android_androidqf() m = AQFSettings(target_path=data_path) From 45fdf8579083dac618d57150429cc1d1149cd2b9 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:24 +0200 Subject: [PATCH 044/109] Add bugreport mountinfo parser --- src/mvt/android/artifacts/mounts.py | 45 +++++++++++++++++++++ src/mvt/android/modules/bugreport/mounts.py | 41 +++++++++++++++++++ tests/android_androidqf/test_mounts.py | 13 ++++++ 3 files changed, 99 insertions(+) create mode 100644 src/mvt/android/modules/bugreport/mounts.py diff --git a/src/mvt/android/artifacts/mounts.py b/src/mvt/android/artifacts/mounts.py index a35d889f..5a5537b7 100644 --- a/src/mvt/android/artifacts/mounts.py +++ b/src/mvt/android/artifacts/mounts.py @@ -117,6 +117,51 @@ class Mounts(AndroidArtifact): # Skip lines that don't match expected format continue + @staticmethod + def parse_mountinfo(entry: str, process_id: int) -> list[dict[str, Any]]: + """Parse Linux /proc/PID/mountinfo records.""" + results = [] + for line in entry.splitlines(): + fields = line.split() + if "-" not in fields: + continue + separator = fields.index("-") + if separator < 6 or len(fields) < separator + 4: + continue + try: + mount_id = int(fields[0]) + parent_id = int(fields[1]) + except ValueError: + continue + mount_options = fields[5].split(",") + super_options = fields[separator + 3].split(",") + options = list(dict.fromkeys(mount_options + super_options)) + mount_point = fields[4].replace("\\040", " ") + device = fields[separator + 2].replace("\\040", " ") + filesystem_type = fields[separator + 1] + is_system = mount_point in SUSPICIOUS_MOUNT_POINTS or any( + mount_point.startswith(f"{prefix}/") + for prefix in SUSPICIOUS_MOUNT_POINTS + ) + results.append( + { + "mount_id": mount_id, + "parent_id": parent_id, + "major_minor": fields[2], + "root": fields[3].replace("\\040", " "), + "mount_point": mount_point, + "device": device, + "filesystem_type": filesystem_type, + "mount_options": ",".join(options), + "options_list": options, + "optional_fields": fields[6:separator], + "is_system_partition": is_system, + "is_read_write": "rw" in options, + "process_ids": [process_id], + } + ) + return results + def check_indicators(self) -> None: """ Check for suspicious mount configurations that may indicate root access diff --git a/src/mvt/android/modules/bugreport/mounts.py b/src/mvt/android/modules/bugreport/mounts.py new file mode 100644 index 00000000..ecef40c0 --- /dev/null +++ b/src/mvt/android/modules/bugreport/mounts.py @@ -0,0 +1,41 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2026 The MVT Authors. + +import re + +from mvt.android.artifacts.mounts import Mounts as MountsArtifact + +from .base import BugReportModule + + +class Mounts(MountsArtifact, BugReportModule): + """Extract and deduplicate process mount namespaces from mountinfo.""" + + def run(self) -> None: + mount_files = self._get_files_by_pattern("FS/proc/*/mountinfo") + if not mount_files: + mount_files = self._get_files_by_pattern("*/proc/*/mountinfo") + unique: dict[tuple, dict] = {} + for file_path in mount_files: + pid_match = re.search(r"/proc/(\d+)/mountinfo$", file_path) + if not pid_match: + continue + entries = self.parse_mountinfo( + self._get_file_content(file_path).decode("utf-8", errors="replace"), + int(pid_match.group(1)), + ) + for entry in entries: + identity = ( + entry["major_minor"], + entry["root"], + entry["mount_point"], + entry["device"], + entry["filesystem_type"], + entry["mount_options"], + ) + if identity in unique: + unique[identity]["process_ids"].extend(entry["process_ids"]) + else: + unique[identity] = entry + self.results = list(unique.values()) + self.log.info("Extracted %d unique mount records", len(self.results)) diff --git a/tests/android_androidqf/test_mounts.py b/tests/android_androidqf/test_mounts.py index d66925d9..5f471769 100644 --- a/tests/android_androidqf/test_mounts.py +++ b/tests/android_androidqf/test_mounts.py @@ -13,6 +13,19 @@ from ..utils import get_android_androidqf, list_files class TestAndroidqfMountsArtifact: + def test_parse_proc_mountinfo(self): + from mvt.android.artifacts.mounts import Mounts as MountsArtifact + + results = MountsArtifact.parse_mountinfo( + "41 40 254:13 / / ro,relatime shared:1 - erofs /dev/block/dm-13 ro,seclabel\n", + 123, + ) + + assert results[0]["mount_id"] == 41 + assert results[0]["mount_point"] == "/" + assert results[0]["filesystem_type"] == "erofs" + assert results[0]["process_ids"] == [123] + def test_parse_mounts_token_checks(self): """ Test the artifact-level `parse` method using tolerant token checks. From 3a43f6fcc7d5d4dd3a0adf15e97894494ebeb84e Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:24 +0200 Subject: [PATCH 045/109] Merge plaintext and protobuf tombstones --- .../android/artifacts/tombstone_crashes.py | 32 ++++++-- .../android/modules/bugreport/tombstones.py | 81 ++++++++++++++++--- tests/android/test_artifact_tombstones.py | 7 +- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/src/mvt/android/artifacts/tombstone_crashes.py b/src/mvt/android/artifacts/tombstone_crashes.py index bcfb3899..d22e65af 100644 --- a/src/mvt/android/artifacts/tombstone_crashes.py +++ b/src/mvt/android/artifacts/tombstone_crashes.py @@ -131,6 +131,13 @@ class TombstoneCrashArtifact(AndroidArtifact): self, file_name: str, file_timestamp: datetime.datetime, data: bytes ) -> None: """Parse Android tombstone crash files from a protobuf object.""" + self.results.append(self.parse_protobuf_record(file_name, file_timestamp, data)) + + def parse_protobuf_record( + self, file_name: str, file_timestamp: datetime.datetime, data: bytes + ) -> dict: + if not data: + raise ValueError("empty protobuf tombstone") tombstone_pb = Tombstone().parse(data) tombstone_dict = tombstone_pb.to_dict( casing=betterproto2.Casing.SNAKE, include_default_values=True @@ -143,20 +150,31 @@ class TombstoneCrashArtifact(AndroidArtifact): tombstone_dict["file_name"] = file_name tombstone_dict["file_timestamp"] = convert_datetime_to_iso(file_timestamp) tombstone_dict["process_name"] = self._proccess_name_from_thread(tombstone_dict) + if isinstance(tombstone_dict.get("selinux_label"), str): + tombstone_dict["selinux_label"] = tombstone_dict["selinux_label"].rstrip( + "\x00" + ) # Confirm the tombstone is valid, and matches the output model tombstone = TombstoneCrashResult.model_validate(tombstone_dict) - self.results.append(tombstone.model_dump()) + return tombstone.model_dump() def parse( self, file_name: str, file_timestamp: datetime.datetime, content: bytes ) -> None: """Parse text Android tombstone crash files.""" + self.results.append(self.parse_text_record(file_name, file_timestamp, content)) + + def parse_text_record( + self, file_name: str, file_timestamp: datetime.datetime, content: bytes + ) -> dict: + if not content: + raise ValueError("empty plaintext tombstone") tombstone_dict = { "file_name": file_name, "file_timestamp": convert_datetime_to_iso(file_timestamp), } - lines = content.decode("utf-8").splitlines() + lines = content.decode("utf-8", errors="replace").splitlines() for line_num, line in enumerate(lines, 1): if not line.strip() or TOMBSTONE_DELIMITER in line: continue @@ -171,7 +189,7 @@ class TombstoneCrashArtifact(AndroidArtifact): # Validate the tombstone and add it to the results tombstone = TombstoneCrashResult.model_validate(tombstone_dict) - self.results.append(tombstone.model_dump()) + return tombstone.model_dump() def _parse_tombstone_line( self, line: str, key: str, destination_key: str, tombstone: dict @@ -195,7 +213,9 @@ class TombstoneCrashArtifact(AndroidArtifact): if line_key != key: raise ValueError(f"Expected key {key}, got {line_key}") - value_clean = value.strip().strip("'") + value_clean = value.strip() + if len(value_clean) >= 2 and value_clean[0] == value_clean[-1] == "'": + value_clean = value_clean[1:-1] if destination_key == "uid": tombstone[destination_key] = int(value_clean) elif destination_key == "process_uptime": @@ -269,9 +289,7 @@ class TombstoneCrashArtifact(AndroidArtifact): @staticmethod def _parse_timestamp_string(timestamp: str) -> str: timestamp_parsed = parser.parse(timestamp) - # 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) + return convert_datetime_to_iso(timestamp_parsed) @staticmethod def _proccess_name_from_thread(tombstone_dict: dict) -> str: diff --git a/src/mvt/android/modules/bugreport/tombstones.py b/src/mvt/android/modules/bugreport/tombstones.py index c4a7afb4..4d121a14 100644 --- a/src/mvt/android/modules/bugreport/tombstones.py +++ b/src/mvt/android/modules/bugreport/tombstones.py @@ -43,21 +43,76 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule): ) return - for tombstone_file in sorted(tombstone_files): - tombstone_filename = tombstone_file.split("/")[-1] - modification_time = self._get_file_modification_time(tombstone_file) - tombstone_data = self._get_file_content(tombstone_file) + grouped: dict[str, dict[str, str]] = {} + for file_path in tombstone_files: + file_name = file_path.rsplit("/", 1)[-1] + source = "protobuf" if file_name.endswith(".pb") else "text" + crash_id = file_name.removesuffix(".pb") + grouped.setdefault(crash_id, {})[source] = file_path - try: - if tombstone_file.endswith(".pb"): - self.parse_protobuf( - tombstone_filename, modification_time, tombstone_data + for crash_id, paths in sorted(grouped.items()): + parsed_sources: dict[str, dict] = {} + source_records: dict[str, dict] = {} + for source in ("text", "protobuf"): + file_path = paths.get(source) + if file_path is None: + continue + file_name = file_path.rsplit("/", 1)[-1] + file_timestamp = self._get_file_modification_time(file_path) + source_info = { + "file_name": file_name, + "file_timestamp": file_timestamp.isoformat(), + "parsed": False, + "error": None, + "record": None, + } + try: + data = self._get_file_content(file_path) + if source == "protobuf": + record = self.parse_protobuf_record( + file_name, file_timestamp, data + ) + else: + record = self.parse_text_record(file_name, file_timestamp, data) + source_info["parsed"] = True + source_info["record"] = record + source_records[source] = record + except Exception as exc: + source_info["error"] = str(exc) + self.log.error( + "Error parsing tombstone file %s: %s", file_path, exc ) - else: - self.parse(tombstone_filename, modification_time, tombstone_data) - except ValueError as e: - # Catch any exceptions raised during parsing or validation. - self.log.error(f"Error parsing tombstone file {tombstone_file}: {e}") + parsed_sources[source] = source_info + + if not source_records: + continue + preferred = source_records.get("protobuf") or source_records["text"] + canonical = dict(preferred) + text_record = source_records.get("text") + if text_record: + for key, value in text_record.items(): + if canonical.get(key) in (None, "", [], {}): + canonical[key] = value + + differences = {} + protobuf_record = source_records.get("protobuf") + if text_record and protobuf_record: + for key in text_record.keys() & protobuf_record.keys(): + if key in ("file_name", "file_timestamp"): + continue + if text_record[key] != protobuf_record[key]: + differences[key] = { + "text": text_record[key], + "protobuf": protobuf_record[key], + } + canonical.update( + { + "crash_id": crash_id, + "sources": parsed_sources, + "differences": differences, + } + ) + self.results.append(canonical) self.log.info( "Extracted a total of %d tombstone files", diff --git a/tests/android/test_artifact_tombstones.py b/tests/android/test_artifact_tombstones.py index f88a9c26..e9159bdd 100644 --- a/tests/android/test_artifact_tombstones.py +++ b/tests/android/test_artifact_tombstones.py @@ -128,8 +128,5 @@ class TestTombstoneCrashArtifact: assert tombstone_result.get("pid") == 25541 assert tombstone_result.get("process_name") == "mtk.ape.decoder" - # With Android logs we want to keep timestamps as device local time for consistency. - # We often don't know the time offset for a log entry and so can't convert everything to UTC. - # MVT should output the local time only: - # So original 2023-04-12 12:32:40.518290770+0200 -> 2023-04-12 12:32:40.000000 - assert tombstone_result.get("timestamp") == "2023-04-12 12:32:40.518290" + # Tombstones include an explicit offset, so normalize them to UTC. + assert tombstone_result.get("timestamp") == "2023-04-12 10:32:40.518290" From 7ef26779bf2edb01efe0aefb9f987be54b314454 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:24 +0200 Subject: [PATCH 046/109] Register added bugreport parsers --- src/mvt/android/modules/bugreport/__init__.py | 10 ++++++++- tests/android_bugreport/test_bugreport.py | 22 ++++++++++++------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/mvt/android/modules/bugreport/__init__.py b/src/mvt/android/modules/bugreport/__init__.py index 1594af9c..e23bf5a9 100644 --- a/src/mvt/android/modules/bugreport/__init__.py +++ b/src/mvt/android/modules/bugreport/__init__.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/ +from mvt.common.module import MVTModule + from .dumpsys_accessibility import DumpsysAccessibility from .dumpsys_activities import DumpsysActivities from .dumpsys_appops import DumpsysAppops @@ -16,8 +18,11 @@ from .dumpsys_receivers import DumpsysReceivers from .dumpsys_adb_state import DumpsysADBState from .fs_timestamps import BugReportTimestamps from .tombstones import Tombstones +from .mounts import Mounts +from .processes import Processes +from .settings import Settings -BUGREPORT_MODULES = [ +BUGREPORT_MODULES: list[type[MVTModule]] = [ DumpsysAccessibility, DumpsysActivities, DumpsysAppops, @@ -31,4 +36,7 @@ BUGREPORT_MODULES = [ DumpsysADBState, BugReportTimestamps, Tombstones, + Processes, + Settings, + Mounts, ] diff --git a/tests/android_bugreport/test_bugreport.py b/tests/android_bugreport/test_bugreport.py index 75df1061..413bb180 100644 --- a/tests/android_bugreport/test_bugreport.py +++ b/tests/android_bugreport/test_bugreport.py @@ -54,10 +54,11 @@ class TestBugreportAnalysis: == "com.samsung.android.provider.filterprovider" ) assert m.results[1]["package_name"] == "com.instagram.android" - assert m.results[0]["installer"] == "" + assert m.results[0]["installer"] is None assert m.results[1]["installer"] == "com.android.vending" assert len(m.results[0]["permissions"]) == 4 - assert len(m.results[1]["permissions"]) == 32 + assert len(m.results[1]["permissions"]) == 20 + assert len(m.results[1]["users"][0]["permissions"]) == 19 def test_getprop_module(self): m = self.launch_bug_report_module(DumpsysGetProp) @@ -66,29 +67,34 @@ class TestBugreportAnalysis: def test_receivers_match_exact_package_name(self, indicators_factory): intent = "android.intent.action.PHONE_STATE" false_positive = { + "resolver_type": "non_data_action", + "key": intent, "package_name": "com.android.phone", - "receiver": ( + "component": ( "com.android.phone/" "com.android.services.telephony.sip.SipIncomingCallReceiver" ), + "filter_count": 1, } malicious_receiver = { + "resolver_type": "non_data_action", + "key": intent, "package_name": "com.android.services", - "receiver": "com.android.services/com.example.SomeReceiver", + "component": "com.android.services/com.example.SomeReceiver", + "filter_count": 1, } - module = DumpsysReceivers( - results={intent: [false_positive, malicious_receiver]} - ) + module = DumpsysReceivers(results=[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.event == 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 assert m.results[1]["pid"] == 3559 + assert m.results[0]["sources"]["text"]["parsed"] is True From fd27fe33784991592114711070a4d6381b550e82 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sat, 22 Aug 2026 14:16:24 +0200 Subject: [PATCH 047/109] Fix module registry type annotations --- src/mvt/android/modules/androidqf/__init__.py | 4 +++- src/mvt/android/modules/backup/__init__.py | 4 +++- src/mvt/android/modules/intrusion_logs/__init__.py | 4 +++- src/mvt/ios/modules/backup/__init__.py | 9 ++++++++- src/mvt/ios/modules/fs/__init__.py | 4 +++- src/mvt/ios/modules/mixed/__init__.py | 4 +++- 6 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/mvt/android/modules/androidqf/__init__.py b/src/mvt/android/modules/androidqf/__init__.py index 1d8d6195..05e7cde9 100644 --- a/src/mvt/android/modules/androidqf/__init__.py +++ b/src/mvt/android/modules/androidqf/__init__.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/ +from mvt.common.module import MVTModule + from .aqf_files import AQFFiles from .aqf_getprop import AQFGetProp from .aqf_log_timestamps import AQFLogTimestamps @@ -12,7 +14,7 @@ from .aqf_settings import AQFSettings from .mounts import Mounts from .root_binaries import RootBinaries -ANDROIDQF_MODULES = [ +ANDROIDQF_MODULES: list[type[MVTModule]] = [ AQFPackages, AQFProcesses, AQFGetProp, diff --git a/src/mvt/android/modules/backup/__init__.py b/src/mvt/android/modules/backup/__init__.py index 47a5e1cf..683a3839 100644 --- a/src/mvt/android/modules/backup/__init__.py +++ b/src/mvt/android/modules/backup/__init__.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/ +from mvt.common.module import MVTModule + from .sms import SMS -BACKUP_MODULES = [SMS] +BACKUP_MODULES: list[type[MVTModule]] = [SMS] diff --git a/src/mvt/android/modules/intrusion_logs/__init__.py b/src/mvt/android/modules/intrusion_logs/__init__.py index f8be973f..a0e7bdd7 100644 --- a/src/mvt/android/modules/intrusion_logs/__init__.py +++ b/src/mvt/android/modules/intrusion_logs/__init__.py @@ -3,11 +3,13 @@ # 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 MVTModule + from .connect_event import ConnectEvent from .dns_event import DnsEvent from .security_event import SecurityEvent -INTRUSION_LOGS_MODULES = [ +INTRUSION_LOGS_MODULES: list[type[MVTModule]] = [ DnsEvent, ConnectEvent, SecurityEvent, diff --git a/src/mvt/ios/modules/backup/__init__.py b/src/mvt/ios/modules/backup/__init__.py index 0fb833ae..ce8499bb 100644 --- a/src/mvt/ios/modules/backup/__init__.py +++ b/src/mvt/ios/modules/backup/__init__.py @@ -3,9 +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/ +from mvt.common.module import MVTModule + from .backup_info import BackupInfo from .configuration_profiles import ConfigurationProfiles from .manifest import Manifest from .profile_events import ProfileEvents -BACKUP_MODULES = [BackupInfo, ConfigurationProfiles, Manifest, ProfileEvents] +BACKUP_MODULES: list[type[MVTModule]] = [ + BackupInfo, + ConfigurationProfiles, + Manifest, + ProfileEvents, +] diff --git a/src/mvt/ios/modules/fs/__init__.py b/src/mvt/ios/modules/fs/__init__.py index 06dba246..b00ee35f 100644 --- a/src/mvt/ios/modules/fs/__init__.py +++ b/src/mvt/ios/modules/fs/__init__.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/ +from mvt.common.module import MVTModule + from .analytics import Analytics from .analytics_ios_versions import AnalyticsIOSVersions from .cache_files import CacheFiles @@ -15,7 +17,7 @@ from .webkit_indexeddb import WebkitIndexedDB from .webkit_localstorage import WebkitLocalStorage from .webkit_safariviewservice import WebkitSafariViewService -FS_MODULES = [ +FS_MODULES: list[type[MVTModule]] = [ CacheFiles, Filesystem, Netusage, diff --git a/src/mvt/ios/modules/mixed/__init__.py b/src/mvt/ios/modules/mixed/__init__.py index a846bf2e..027dce09 100644 --- a/src/mvt/ios/modules/mixed/__init__.py +++ b/src/mvt/ios/modules/mixed/__init__.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/ +from mvt.common.module import MVTModule + from .applications import Applications from .calendar import Calendar from .calls import Calls @@ -28,7 +30,7 @@ from .webkit_session_resource_log import WebkitSessionResourceLog from .whatsapp import Whatsapp from .whatsapp_contacts import WhatsappContacts -MIXED_MODULES = [ +MIXED_MODULES: list[type[MVTModule]] = [ Calls, ChromeFavicon, ChromeHistory, From 65df48325828f317268750c205c39a22518bbf35 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Tue, 25 Aug 2026 19:19:47 +0200 Subject: [PATCH 048/109] Parse multiline Android properties --- src/mvt/android/artifacts/getprop.py | 21 +++++++++------------ tests/android/test_artifact_getprop.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/mvt/android/artifacts/getprop.py b/src/mvt/android/artifacts/getprop.py index a03491b7..094eb5dc 100644 --- a/src/mvt/android/artifacts/getprop.py +++ b/src/mvt/android/artifacts/getprop.py @@ -28,19 +28,16 @@ INTERESTING_PROPERTIES = [ class GetProp(AndroidArtifact): def parse(self, entry: str) -> None: self.results: List[Dict[str, str]] = [] - rxp = re.compile(r"^\[([^]]+)\]: \[(.*)\]$") + # A property value may span several lines: persist.sys.boot.reason.history + # prints one boot per line. Matching the whole section instead of line by + # line lets a value run to the first closing bracket that ends a line. + rxp = re.compile( + r"^[ \t]*\[([^]]+)\]: \[(.*?)\][ \t\r]*$", + re.MULTILINE | re.DOTALL, + ) - for line in entry.splitlines(): - line = line.strip() - if line == "": - continue - - match = rxp.match(line) - if not match: - continue - - prop_entry = {"name": match.group(1), "value": match.group(2)} - self.results.append(prop_entry) + for name, value in rxp.findall(entry): + self.results.append({"name": name, "value": value}) def get_device_timezone(self) -> str | None: """ diff --git a/tests/android/test_artifact_getprop.py b/tests/android/test_artifact_getprop.py index 7815fbd1..0fbe742e 100644 --- a/tests/android/test_artifact_getprop.py +++ b/tests/android/test_artifact_getprop.py @@ -48,3 +48,20 @@ class TestGetPropArtifact: {"name": "empty", "value": ""}, {"name": "valid", "value": "value"}, ] + + def test_multiline_value(self): + gp = GetProp() + gp.parse( + "[persist.sys.boot.reason.history]: [" + "reboot,ota,1697044974\n" + "reboot,watchdog,1696958574]\n" + "[ro.build.version.sdk]: [35]\n" + ) + + assert gp.results == [ + { + "name": "persist.sys.boot.reason.history", + "value": "reboot,ota,1697044974\nreboot,watchdog,1696958574", + }, + {"name": "ro.build.version.sdk", "value": "35"}, + ] From 3c8a581fd027f4caccbc7174549791994263695d Mon Sep 17 00:00:00 2001 From: "va@resident" Date: Tue, 25 Aug 2026 21:18:36 +0300 Subject: [PATCH 049/109] Do not end the whole androidqf run on an encrypted backup.ab (#891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit from_ab() raises InvalidAndroidBackup instead of exiting when it runs as a sub-command, which check-androidqf catches to skip the backup modules. The two password branches still called sys.exit(1) unconditionally, and since run_backup_cmd() runs inside finish(), that ended the parent run before the intrusion-logs command and before the timeline, alerts, urls, info and run manifest were stored — leaving an output directory that looks complete but has no alerts.json. Also drop "as backup.ab is malformed" from the skip warning: it covers a missing or wrong password too. --- src/mvt/android/cmd_check_androidqf.py | 4 +- src/mvt/android/cmd_check_backup.py | 4 ++ .../test_check_backup_optional_failure.py | 39 +++++++++++++++++++ tests/test_check_android_androidqf.py | 2 +- 4 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 tests/android/test_check_backup_optional_failure.py diff --git a/src/mvt/android/cmd_check_androidqf.py b/src/mvt/android/cmd_check_androidqf.py index ffa3e13a..99ac5a3f 100644 --- a/src/mvt/android/cmd_check_androidqf.py +++ b/src/mvt/android/cmd_check_androidqf.py @@ -292,9 +292,7 @@ class CmdAndroidCheckAndroidQF(Command): try: cmd.from_ab(backup) except InvalidAndroidBackup as exc: - self.log.warning( - "Skipping backup modules as backup.ab is malformed: %s", exc - ) + self.log.warning("Skipping backup modules: %s", exc) return False cmd.run() diff --git a/src/mvt/android/cmd_check_backup.py b/src/mvt/android/cmd_check_backup.py index b75bb346..94be4ee2 100644 --- a/src/mvt/android/cmd_check_backup.py +++ b/src/mvt/android/cmd_check_backup.py @@ -87,11 +87,15 @@ class CmdAndroidCheckBackup(Command): if header["encryption"] != "none": password = prompt_or_load_android_backup_password(log, self.module_options) if not password: + if self.sub_command: + raise InvalidAndroidBackup("No backup password provided") log.critical("No backup password provided.") sys.exit(1) try: tardata = parse_backup_file(ab_file_bytes, password=password) except InvalidBackupPassword: + if self.sub_command: + raise InvalidAndroidBackup("Invalid backup password") log.critical("Invalid backup password") sys.exit(1) except AndroidBackupParsingError as exc: diff --git a/tests/android/test_check_backup_optional_failure.py b/tests/android/test_check_backup_optional_failure.py new file mode 100644 index 00000000..72dae426 --- /dev/null +++ b/tests/android/test_check_backup_optional_failure.py @@ -0,0 +1,39 @@ +# 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/ +"""An encrypted backup.ab must not take the whole check-androidqf run with it. + +`CmdAndroidCheckBackup.from_ab()` already raises `InvalidAndroidBackup` instead +of exiting when it runs as a sub-command (`check-androidqf` catches that and +skips the backup modules), for a wrong file format and for a parse error. The +password branches used to call `sys.exit(1)` unconditionally, which ends the +parent run inside `finish()` — before the intrusion-logs command and before the +timeline, alerts, urls, info and run-manifest are written. +""" + +import pytest + +from mvt.android.cmd_check_backup import CmdAndroidCheckBackup, InvalidAndroidBackup + +ENCRYPTED_AB_HEADER = b"ANDROID BACKUP\n5\n0\nAES-256\n" + b"\x00" * 64 + + +class TestCheckBackupOptionalFailure: + def _cmd(self, tmp_path, sub_command): + return CmdAndroidCheckBackup( + target_path=None, + results_path=str(tmp_path), + module_options={"interactive": False}, + sub_command=sub_command, + ) + + def test_missing_password_raises_when_nested(self, tmp_path): + cmd = self._cmd(tmp_path, sub_command=True) + with pytest.raises(InvalidAndroidBackup): + cmd.from_ab(ENCRYPTED_AB_HEADER) + + def test_missing_password_still_exits_on_its_own_command(self, tmp_path): + cmd = self._cmd(tmp_path, sub_command=False) + with pytest.raises(SystemExit): + cmd.from_ab(ENCRYPTED_AB_HEADER) diff --git a/tests/test_check_android_androidqf.py b/tests/test_check_android_androidqf.py index 2253a509..4e6ef984 100644 --- a/tests/test_check_android_androidqf.py +++ b/tests/test_check_android_androidqf.py @@ -155,7 +155,7 @@ class TestCheckAndroidqfCommand: 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 "Skipping backup modules: Invalid backup format" in caplog.text assert not any( record.levelname in {"CRITICAL", "FATAL"} for record in caplog.records ) From 874f75bb4cf7a8bd3860bab6e8b56afa7c52f6f6 Mon Sep 17 00:00:00 2001 From: "va@resident" Date: Tue, 25 Aug 2026 22:35:33 +0300 Subject: [PATCH 050/109] Do not discard the whole tombstone on a "Caused by:" line (#892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keys are matched as bare prefixes, so `Caused by:` inside an abort message reaches the `Cause` key, fails the key comparison and raises — and the per-line loop turns that into an error that drops the entire text tombstone, stack trace included. A key mismatch means "this line is not that key", not "this file is broken": decline the line and let the remaining keys have their turn. A line with no colon is declined the same way instead of raising on the unpack. The same trap has a second form in the field, HiSilicon/Huawei tombstones printing `code around pc:` against the `code` key. --- .../android/artifacts/tombstone_crashes.py | 11 ++-- .../test_artifact_tombstone_caused_by.py | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 tests/android/test_artifact_tombstone_caused_by.py diff --git a/src/mvt/android/artifacts/tombstone_crashes.py b/src/mvt/android/artifacts/tombstone_crashes.py index bcfb3899..1b6e115a 100644 --- a/src/mvt/android/artifacts/tombstone_crashes.py +++ b/src/mvt/android/artifacts/tombstone_crashes.py @@ -191,9 +191,14 @@ class TombstoneCrashArtifact(AndroidArtifact): def _load_key_value_line( self, line: str, key: str, destination_key: str, tombstone: dict ) -> bool: - line_key, value = line.split(":", 1) - if line_key != key: - raise ValueError(f"Expected key {key}, got {line_key}") + # The caller matched the key as a bare prefix, so a longer word starting + # with it arrives here: `Caused by: …` inside an abort message reaches + # the `Cause` key. That is a different line, not a broken file — say so + # by declining it, and let the remaining keys have their turn. Raising + # here discarded the whole tombstone, crash and stack trace included. + line_key, separator, value = line.partition(":") + if not separator or line_key != key: + return False value_clean = value.strip().strip("'") if destination_key == "uid": diff --git a/tests/android/test_artifact_tombstone_caused_by.py b/tests/android/test_artifact_tombstone_caused_by.py new file mode 100644 index 00000000..9a2df850 --- /dev/null +++ b/tests/android/test_artifact_tombstone_caused_by.py @@ -0,0 +1,54 @@ +# 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/ +"""A `Caused by:` line must not discard the whole text tombstone. + +Keys are matched as bare prefixes, so `Caused by: …` — an ordinary line inside +an abort message — reached the `Cause` key, failed the key comparison and +raised, which `Tombstones.run()` logged while dropping the entire crash record. +Seen on a 1.6 MB tombstone whose protobuf twin was zero bytes: the crash then +had no representation at all. +""" + +import datetime + +from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact + +TOMBSTONE = b"""\ +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +Build fingerprint: 'Xiaomi/vili_eea/vili:13/TKQ1.220829.002/V14.0.10.0:user/release-keys' +Revision: '0' +ABI: 'arm64' +Timestamp: 2023-08-24 14:54:47.999124034+0300 +Process uptime: 12199s +Cmdline: com.example.game +pid: 8044, tid: 26222, name: UnityMain >>> com.example.game <<< +uid: 10235 +signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr -------- +Abort message: 'No pending exception expected: java.lang.SecurityException: listen + at void android.os.Parcel.readException() (Parcel.java:2920) +Caused by: android.os.RemoteException: Remote stack trace: +\tat com.android.server.TelephonyRegistry.listen(TelephonyRegistry.java:1096) +""" + +WITH_CAUSE = TOMBSTONE + b"Cause: null pointer dereference\n" + + +class TestTombstoneCausedBy: + def _parse(self, content): + artifact = TombstoneCrashArtifact() + artifact.results = [] + artifact.parse("tombstone_23", datetime.datetime(2023, 8, 24), content) + return artifact.results + + def test_caused_by_line_does_not_discard_the_tombstone(self): + results = self._parse(TOMBSTONE) + assert len(results) == 1 + assert results[0]["pid"] == 8044 + assert results[0]["process_name"] == "UnityMain" + assert results[0]["uid"] == 10235 + + def test_the_real_cause_key_is_still_parsed(self): + results = self._parse(WITH_CAUSE) + assert results[0]["cause"] == "null pointer dereference" From 65b8114469cd8db65a4ac24c2fbd1624123da210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:13 +0200 Subject: [PATCH 051/109] Move development documentation under docs/development (#893) The Development section holds more than one page and will grow further, so give it its own directory. development.md becomes development/index.md, which keeps its published URL, and the custom CLI command page moves alongside it. Update the nav, the cross-link between the two pages, and the README link to the custom command documentation, which is now published at /development/custom_commands/. --- README.md | 2 +- docs/{ => development}/custom_commands.md | 2 +- docs/{development.md => development/index.md} | 0 mkdocs.yml | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) rename docs/{ => development}/custom_commands.md (97%) rename docs/{development.md => development/index.md} (100%) diff --git a/README.md b/README.md index 65fcdeee..d4ac9b4f 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ 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/) +[custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/) for the plugin entry points and `--load-command` interface. diff --git a/docs/custom_commands.md b/docs/development/custom_commands.md similarity index 97% rename from docs/custom_commands.md rename to docs/development/custom_commands.md index ab89fd90..764c70b8 100644 --- a/docs/custom_commands.md +++ b/docs/development/custom_commands.md @@ -1,7 +1,7 @@ # 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): +Custom commands are different from [custom forensic modules](index.md#custom-modules): commands add new CLI operations, while modules add analysis steps to existing `check-*` commands. diff --git a/docs/development.md b/docs/development/index.md similarity index 100% rename from docs/development.md rename to docs/development/index.md diff --git a/mkdocs.yml b/mkdocs.yml index a7b22acb..11a91dc7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,6 @@ nav: - Check Android Intrusion Logs: "android/intrusion_logs.md" - Indicators of Compromise: "iocs.md" - Development: - - Development Instructions: "development.md" - - Custom CLI Commands: "custom_commands.md" + - Development Instructions: "development/index.md" + - Custom CLI Commands: "development/custom_commands.md" - License: "license.md" From 097766a63b5edcb8364c12368b4141288f1f5a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:13 +0200 Subject: [PATCH 052/109] Add namespaced plugin configuration support (#894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add namespaced plugin configuration support Plugin packages need somewhere to keep their own settings, but MVT rewrites its config.yaml with only the fields it knows about, so any foreign section is dropped. Add MVTPluginSettings, a pydantic-settings base class that gives each plugin its own file under the MVT config folder and its own MVT_PLUGIN__ environment variable namespace. Settings resolve from constructor arguments, then the environment, then the plugin file, then the field defaults. Saving skips the values the environment currently supplies, so credentials passed as environment variables are not copied to disk, and writes through a private temporary file so a settings file is never partially written or briefly readable by other users. * Add per-plugin data folders Plugins had no sanctioned place to keep the data they persist, so the plugin configuration documentation suggested a CACHE_FOLDER setting defaulting to ~/.cache/example-plugin. That is a Linux convention which is wrong on macOS, nothing expands or creates it, and it turns a path into a setting a user can be asked to configure. Add plugin_data_folder(), which returns the folder a plugin should use for caches, downloaded artifacts, synchronization state or anything else it writes to disk, and creates it if it is missing. The plugin name is validated before anything is created, so a name holding a path separator raises an error and leaves no folder behind, and calling the function again returns the same folder with its contents untouched. The folder sits under plugin-data rather than under the plugins folder which holds the settings files. On macOS the configuration folder and the data folder are the same directory, so reusing the plugins name would leave each plugin's data folder in among the settings files. Both the plugin-data folder and the folder of each plugin are created with 0700 permissions. MVT is a forensic tool, and what a plugin keeps there, such as API responses or sample metadata, is private by default. The path is resolved on every call, as the configuration folder already is, so it follows the current environment rather than whatever it was when MVT was imported. The documentation now points plugins at the helper, and the example settings class carries a plain integer setting in place of its cache folder. * Derive the data folder of a plugin from its settings class A plugin with a settings class already names itself in `plugin_name`; passing the name again to plugin_data_folder() repeats it and can drift. Add a `data_folder()` class method on MVTPluginSettings which returns plugin_data_folder() for the class's validated plugin name (works on the class and on an instance); plugin_data_folder(name) stays as the function underneath for plugins without a settings class. --------- Co-authored-by: Donncha Ó Cearbhaill --- docs/development/custom_commands.md | 4 + docs/development/index.md | 4 + docs/development/plugin_configuration.md | 149 +++++++++ mkdocs.yml | 1 + src/mvt/common/plugin_config.py | 299 +++++++++++++++++ tests/common/test_plugin_config.py | 391 +++++++++++++++++++++++ 6 files changed, 848 insertions(+) create mode 100644 docs/development/plugin_configuration.md create mode 100644 src/mvt/common/plugin_config.py create mode 100644 tests/common/test_plugin_config.py diff --git a/docs/development/custom_commands.md b/docs/development/custom_commands.md index 764c70b8..1d012f90 100644 --- a/docs/development/custom_commands.md +++ b/docs/development/custom_commands.md @@ -56,6 +56,10 @@ pipx inject mvt my-mvt-plugin When MVT is installed in an active virtual environment, install the plugin with `pip` in that environment. +Command packages that need their own settings, such as an API key, should store +them in a namespaced [plugin configuration file](plugin_configuration.md) +rather than in MVT's own `config.yaml`. + ## Load a Command File For local commands that are not packaged, create a Python file that exports one diff --git a/docs/development/index.md b/docs/development/index.md index 53a8f612..487127f7 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -160,6 +160,10 @@ For a `pipx` installation of MVT, inject the package into MVT's environment: pipx inject mvt mvt-plugin-amnesty-custom ``` +Module packages that need their own settings, such as an API key, should store +them in a namespaced [plugin configuration file](plugin_configuration.md) +rather than in MVT's own `config.yaml`. + ### Naming module packages Name module packages `mvt-plugin-` (import package `mvt_plugin_`), diff --git a/docs/development/plugin_configuration.md b/docs/development/plugin_configuration.md new file mode 100644 index 00000000..e41f4e43 --- /dev/null +++ b/docs/development/plugin_configuration.md @@ -0,0 +1,149 @@ +# Plugin Configuration + +Plugin packages that add [custom CLI commands](custom_commands.md) or +[modules](index.md#installed-module-packages) often need to store their +own settings, such as an API key, a server URL or the timestamp of the last +synchronization. MVT provides a namespaced settings base class so each plugin +keeps its configuration in its own file, and a data folder for anything else a +plugin needs to keep on disk. + +!!! warning + + Do not write plugin settings to MVT's own `config.yaml`. MVT rewrites that + file with the settings it knows about every time it starts, so any other + section is deleted. + +## Where Settings Are Stored + +Each plugin gets one YAML file in a `plugins` folder next to MVT's own +configuration: + +``` +~/.config/mvt/plugins/.yaml +``` + +The exact parent folder follows the platform convention used for MVT's +`config.yaml` (for example `~/Library/Application Support/mvt` on macOS). Use +`mvt.common.plugin_config.plugin_config_path()` instead of building the path by +hand. + +Plugin names must be lowercase and may only contain letters, digits and dashes, +matching the `mvt-plugin-` package naming convention. MVT creates the +`plugins` folder with `0700` permissions and writes the settings files with +`0600` permissions, because they commonly hold credentials. Files are written +through a temporary file and moved into place, so an interrupted save never +leaves a partially written settings file behind. + +## Plugin Data Folder + +Everything else a plugin keeps on disk, such as a cache, a downloaded artifact +or synchronization state, belongs in the folder returned by the `data_folder()` +class method of the plugin's settings class, or by +`mvt.common.plugin_config.plugin_data_folder()` called with the plugin name if +the plugin has no settings class: + +``` +~/.local/share/mvt/plugin-data// # Linux +~/Library/Application Support/mvt/plugin-data// # macOS +``` + +The folder sits beside MVT's own data, such as the downloaded indicators. It is +created if it is missing, with `0700` permissions. Asking for it again returns +the same path and leaves the contents alone, so a plugin can ask for it every +time it needs the folder. `ExamplePluginSettings` below is the settings class +defined in the next section: + +```python +import os + + +def cache_path() -> str: + folder = ExamplePluginSettings.data_folder() + return os.path.join(folder, "virustotal_lookups_cache.json") +``` + +A plugin which has no settings class calls +`plugin_data_folder("example-plugin")` instead. + +Do not fall back on a path of your own such as `~/.cache/example-plugin`: it +is a Linux-only convention, and MVT will not create it for you. + +## Defining Plugin Settings + +Subclass `MVTPluginSettings`, set `plugin_name` and declare typed fields with +defaults: + +```python +from typing import Optional + +from mvt.common.plugin_config import MVTPluginSettings + + +class ExamplePluginSettings(MVTPluginSettings): + plugin_name = "example-plugin" + + API_KEY: Optional[str] = None + MAX_RESULTS: int = 25 + LAST_SYNC: Optional[str] = None +``` + +`load()` returns the current settings and `save()` writes them back: + +```python +from datetime import datetime, timezone + +import click + + +def sync(): + settings = ExamplePluginSettings.load() + if not settings.API_KEY: + raise click.ClickException( + "No API key configured. Set MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY or " + "run 'example-plugin configure'." + ) + + settings.LAST_SYNC = datetime.now(timezone.utc).isoformat() + settings.save() +``` + +A missing settings file is not an error: the plugin then runs on the field +defaults and on whatever the environment provides. `save()` only persists the +values that differ from the defaults, and it never touches MVT's `config.yaml`. +A settings file that cannot be parsed, or that does not hold a mapping of +setting names to values, raises a `PluginConfigLoadError` naming the file. + +Every subclass that sets its own `plugin_name` gets its own file and its own +environment namespace. A subclass that does not redefine `plugin_name` inherits +it, and therefore shares the file and the environment variables of its parent +class. + +## Environment Variables + +Every field can also be set with an environment variable. The prefix is +`MVT_PLUGIN_`, followed by the plugin name upper-cased with dashes replaced by +underscores, followed by the field name. For the example above: + +```bash +export MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY=... +export MVT_PLUGIN_EXAMPLE_PLUGIN_MAX_RESULTS=50 +``` + +Settings resolve in this order, from highest to lowest priority: + +1. Arguments passed to the settings class directly, such as + `ExamplePluginSettings(API_KEY="...")` +2. Environment variables +3. The plugin's YAML file +4. The field defaults declared on the settings class + +!!! tip + + On shared or multi-user machines, prefer passing API keys through + environment variables rather than saving them to the plugin file. `save()` + skips every value that the environment currently supplies, so a credential + provided that way is not copied into the settings file when a plugin saves + an unrelated setting. + +Unknown keys in a plugin's YAML file are ignored, so a settings file written by +a newer version of a plugin does not break an older one. diff --git a/mkdocs.yml b/mkdocs.yml index 11a91dc7..6250cdce 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,4 +52,5 @@ nav: - Development: - Development Instructions: "development/index.md" - Custom CLI Commands: "development/custom_commands.md" + - Plugin Configuration: "development/plugin_configuration.md" - License: "license.md" diff --git a/src/mvt/common/plugin_config.py b/src/mvt/common/plugin_config.py new file mode 100644 index 00000000..f5e20995 --- /dev/null +++ b/src/mvt/common/plugin_config.py @@ -0,0 +1,299 @@ +# 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 contextlib +import json +import os +import re +import tempfile +from typing import Any, ClassVar, Dict, List, Tuple, Type, TypeVar + +import yaml +from appdirs import user_config_dir, user_data_dir +from pydantic import ValidationError +from pydantic_settings import ( + BaseSettings, + EnvSettingsSource, + PydanticBaseSettingsSource, + SettingsConfigDict, + YamlConfigSettingsSource, +) + +PLUGIN_CONFIG_FOLDER_NAME = "plugins" +# Not "plugins": on macOS the configuration and data folders are the same +# directory, and that name already holds the settings files. +PLUGIN_DATA_FOLDER_NAME = "plugin-data" +PLUGIN_ENV_PREFIX = "MVT_PLUGIN_" + +PLUGIN_NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*") + +PluginSettingsType = TypeVar("PluginSettingsType", bound="MVTPluginSettings") + + +class PluginConfigLoadError(Exception): + pass + + +def validate_plugin_name(plugin_name: Any) -> str: + """ + Check that a plugin name is safe to use in a file name and an env variable. + + :param plugin_name: Name to validate. + :returns: The validated plugin name. + """ + if not isinstance(plugin_name, str): + raise TypeError( + f"Plugin name must be a string, not {type(plugin_name).__name__}" + ) + if not PLUGIN_NAME_PATTERN.fullmatch(plugin_name): + raise ValueError( + f"Invalid plugin name {plugin_name!r}: plugin names must start with a " + "lowercase letter or a digit and may only contain lowercase letters, " + "digits and dashes" + ) + return plugin_name + + +def plugin_config_folder() -> str: + """ + Return the folder where plugins store their configuration files. + + The path is resolved on every call so it always reflects the current + environment. + """ + return os.path.join(user_config_dir("mvt"), PLUGIN_CONFIG_FOLDER_NAME) + + +def plugin_config_path(plugin_name: str) -> str: + """ + Return the path of the configuration file of a given plugin. + + :param plugin_name: Name of the plugin. + """ + return os.path.join( + plugin_config_folder(), f"{validate_plugin_name(plugin_name)}.yaml" + ) + + +def plugin_data_folder(plugin_name: str) -> str: + """ + Return the folder where a given plugin stores its data, creating it. + + Plugins should keep whatever they persist, such as caches or downloaded + artifacts, in this folder. It is created with owner-only permissions. The + path is resolved on every call so it always reflects the current + environment. A plugin with a settings class calls + `MVTPluginSettings.data_folder()` instead, which passes `plugin_name` here. + + :param plugin_name: Name of the plugin. + :returns: The path of the data folder of the plugin. + """ + # Validate the name before anything is created, so an unsafe name cannot + # leave a folder behind. + name = validate_plugin_name(plugin_name) + + # makedirs() applies its mode only to the last folder of the path, so + # MVT's own data folder keeps the default permissions while the two + # plugin folders are private. + data_folder = os.path.join(user_data_dir("mvt"), PLUGIN_DATA_FOLDER_NAME) + os.makedirs(data_folder, mode=0o700, exist_ok=True) + + folder = os.path.join(data_folder, name) + os.makedirs(folder, mode=0o700, exist_ok=True) + return folder + + +def plugin_env_prefix(plugin_name: str) -> str: + """ + Return the environment variable prefix used by a given plugin. + + Dashes are replaced by underscores. Plugin names cannot contain underscores, + so two different plugin names never share an environment namespace. + + :param plugin_name: Name of the plugin. + """ + name = validate_plugin_name(plugin_name).upper().replace("-", "_") + return f"{PLUGIN_ENV_PREFIX}{name}_" + + +def _settings_plugin_name(settings_cls: Type[BaseSettings]) -> str: + plugin_name = getattr(settings_cls, "plugin_name", None) + if plugin_name is None: + raise TypeError( + f"{settings_cls.__name__} must set a 'plugin_name' class attribute to " + "namespace its configuration file and environment variables" + ) + return validate_plugin_name(plugin_name) + + +def _plugin_yaml_source( + settings_cls: Type[BaseSettings], config_path: str +) -> YamlConfigSettingsSource: + """ + Build the YAML settings source of a plugin, reporting unusable files. + + A missing file is not an error, but a file which cannot be parsed or which + does not hold a mapping of setting names is reported with its path. + """ + try: + return YamlConfigSettingsSource(settings_cls, config_path) + except yaml.YAMLError as exc: + raise PluginConfigLoadError( + f"Invalid plugin configuration file {config_path}: {exc}" + ) from exc + except (TypeError, ValueError) as exc: + raise PluginConfigLoadError( + f"Invalid plugin configuration file {config_path}: the file must " + "contain a mapping of setting names to values" + ) from exc + + +class MVTPluginSettings(BaseSettings): + """ + Base class for plugin-namespaced settings. + + Subclass with typed fields and set `plugin_name`. Values resolve from + constructor arguments, then environment variables (MVT_PLUGIN__*), + then the plugin's YAML file (~/.config/mvt/plugins/.yaml), then field + defaults. + + Plugins must not store their settings in MVT's own configuration file: MVT + rewrites it with the fields it knows about, dropping anything else. + + `data_folder()` returns the folder the plugin keeps its data in. + """ + + model_config = SettingsConfigDict(extra="ignore") + + plugin_name: ClassVar[str] + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + plugin_name = _settings_plugin_name(cls) + # Namespace the environment variables of this plugin. Each pydantic + # model gets its own configuration dictionary, so this does not leak + # into other plugins. + cls.model_config["env_prefix"] = plugin_env_prefix(plugin_name) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: Type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> Tuple[PydanticBaseSettingsSource, ...]: + config_path = plugin_config_path(_settings_plugin_name(settings_cls)) + yaml_source = _plugin_yaml_source(settings_cls, config_path) + # Explicit arguments take precedence over environment variables, which + # in turn take precedence over the configuration file. + return (init_settings, env_settings, yaml_source) + + @classmethod + def load(cls: Type[PluginSettingsType]) -> PluginSettingsType: + """ + Load the settings of the plugin. + + A missing configuration file is not an error: the settings then come + from the environment and from the field defaults. + """ + return cls() + + @classmethod + def data_folder(cls) -> str: + """ + Return the data folder of the plugin, creating it. + + The folder is the one plugin_data_folder() returns for `plugin_name`, + so a plugin with a settings class does not repeat its name. + """ + return plugin_data_folder(_settings_plugin_name(cls)) + + def _environment_values(self) -> Dict[str, Any]: + """ + Return the settings values currently supplied by the environment. + + Values are validated by the model, so they can be compared with the + values held by this instance. + """ + settings_cls = type(self) + raw_values = EnvSettingsSource(settings_cls)() + names = [name for name in raw_values if name in settings_cls.model_fields] + if not names: + return {} + + # Fall back on the current values for the fields the environment does + # not set, so that required fields do not fail validation here. + current_values = json.loads(self.model_dump_json()) + try: + from_environment = settings_cls.model_validate( + {**current_values, **raw_values} + ) + except ValidationError: + # An environment variable which the model cannot validate must not + # stop the other environment values from being recognised, or a + # credential would be written to the configuration file. + return self._environment_values_by_field(current_values, raw_values, names) + return {name: getattr(from_environment, name) for name in names} + + def _environment_values_by_field( + self, + current_values: Dict[str, Any], + raw_values: Dict[str, Any], + names: List[str], + ) -> Dict[str, Any]: + """ + Validate each environment value on its own, skipping unusable ones. + + :param current_values: Serialized values held by this instance. + :param raw_values: Values supplied by the environment. + :param names: Names of the fields set by the environment. + """ + settings_cls = type(self) + values = {} + for name in names: + try: + from_environment = settings_cls.model_validate( + {**current_values, name: raw_values[name]} + ) + except ValidationError: + continue + values[name] = getattr(from_environment, name) + return values + + def save(self) -> None: + """ + Save the current settings to the configuration file of the plugin. + + Only values which differ from the field defaults are persisted. Values + which come from the environment are not written to disk, so credentials + passed as environment variables stay out of the configuration file. + MVT's own configuration file is never modified. + """ + config_folder = plugin_config_folder() + if not os.path.isdir(config_folder): + os.makedirs(config_folder, mode=0o700, exist_ok=True) + + values = json.loads(self.model_dump_json(exclude_defaults=True)) + for name, environment_value in self._environment_values().items(): + if name in values and getattr(self, name, None) == environment_value: + del values[name] + + # Settings files can hold credentials, so write them through a private + # temporary file and move it in place. The file is then never partially + # written and never briefly readable by other users. + config_path = plugin_config_path(self.plugin_name) + descriptor, temporary_path = tempfile.mkstemp( + dir=config_folder, prefix=f".{self.plugin_name}-", suffix=".yaml" + ) + try: + with os.fdopen(descriptor, "w") as config_file: + config_file.write(yaml.dump(values, default_flow_style=False)) + os.replace(temporary_path, config_path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(temporary_path) + raise diff --git a/tests/common/test_plugin_config.py b/tests/common/test_plugin_config.py new file mode 100644 index 00000000..9bcf9ebf --- /dev/null +++ b/tests/common/test_plugin_config.py @@ -0,0 +1,391 @@ +# 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 os +import stat +import sys +from typing import Optional + +import pytest +import yaml + +from mvt.common.plugin_config import ( + MVTPluginSettings, + PluginConfigLoadError, + plugin_config_folder, + plugin_config_path, + plugin_data_folder, + plugin_env_prefix, +) + + +class ExamplePluginSettings(MVTPluginSettings): + plugin_name = "example-plugin" + + API_KEY: Optional[str] = None + CACHE_FOLDER: str = "cache" + MAX_RESULTS: int = 25 + + +class OtherPluginSettings(MVTPluginSettings): + plugin_name = "other-plugin" + + API_KEY: Optional[str] = None + + +@pytest.fixture +def config_folder(tmp_path, monkeypatch): + monkeypatch.setattr( + "mvt.common.plugin_config.user_config_dir", + lambda *args, **kwargs: str(tmp_path), + ) + return tmp_path + + +@pytest.fixture +def data_folder(tmp_path, monkeypatch): + folder = tmp_path / "data" + monkeypatch.setattr( + "mvt.common.plugin_config.user_data_dir", + lambda *args, **kwargs: str(folder), + ) + return folder + + +def _write_plugin_file(plugin_name, values): + config_path = plugin_config_path(plugin_name) + os.makedirs(os.path.dirname(config_path), exist_ok=True) + content = values if isinstance(values, str) else yaml.dump(values) + with open(config_path, "w") as config_file: + config_file.write(content) + return config_path + + +def test_plugin_paths_and_prefixes_are_namespaced(config_folder): + assert plugin_config_folder() == str(config_folder / "plugins") + assert plugin_config_path("example-plugin") == str( + config_folder / "plugins" / "example-plugin.yaml" + ) + assert plugin_env_prefix("example-plugin") == "MVT_PLUGIN_EXAMPLE_PLUGIN_" + assert plugin_env_prefix("other-plugin") == "MVT_PLUGIN_OTHER_PLUGIN_" + + +def test_defaults_are_used_without_file_or_environment(config_folder): + settings = ExamplePluginSettings.load() + + assert settings.API_KEY is None + assert settings.CACHE_FOLDER == "cache" + assert settings.MAX_RESULTS == 25 + assert not os.path.exists(plugin_config_path("example-plugin")) + + +def test_values_are_loaded_from_the_plugin_file(config_folder): + _write_plugin_file("example-plugin", {"API_KEY": "from-file", "MAX_RESULTS": 5}) + + settings = ExamplePluginSettings.load() + + assert settings.API_KEY == "from-file" + assert settings.MAX_RESULTS == 5 + assert settings.CACHE_FOLDER == "cache" + + +def test_environment_overrides_the_plugin_file(config_folder, monkeypatch): + _write_plugin_file("example-plugin", {"API_KEY": "from-file", "MAX_RESULTS": 5}) + monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "from-environment") + + settings = ExamplePluginSettings.load() + + assert settings.API_KEY == "from-environment" + assert settings.MAX_RESULTS == 5 + + +def test_arguments_override_the_environment_and_the_plugin_file( + config_folder, monkeypatch +): + _write_plugin_file("example-plugin", {"API_KEY": "from-file", "MAX_RESULTS": 5}) + monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "from-environment") + monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_MAX_RESULTS", "10") + + settings = ExamplePluginSettings(API_KEY="from-argument") + + assert settings.API_KEY == "from-argument" + assert settings.MAX_RESULTS == 10 + + +def test_save_and_load_round_trip(config_folder): + settings = ExamplePluginSettings.load() + settings.API_KEY = "saved-key" + settings.MAX_RESULTS = 100 + + settings.save() + + config_path = plugin_config_path("example-plugin") + assert os.path.isfile(config_path) + with open(config_path) as config_file: + assert yaml.safe_load(config_file) == { + "API_KEY": "saved-key", + "MAX_RESULTS": 100, + } + + reloaded = ExamplePluginSettings.load() + assert reloaded.API_KEY == "saved-key" + assert reloaded.MAX_RESULTS == 100 + assert reloaded.CACHE_FOLDER == "cache" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="POSIX file permissions are not available" +) +def test_saved_file_is_only_readable_by_the_user(config_folder): + settings = ExamplePluginSettings.load() + settings.API_KEY = "saved-key" + + settings.save() + + config_path = plugin_config_path("example-plugin") + assert stat.S_IMODE(os.stat(config_path).st_mode) == 0o600 + folder_mode = stat.S_IMODE(os.stat(plugin_config_folder()).st_mode) + assert folder_mode & 0o077 == 0 + + +@pytest.mark.skipif( + sys.platform == "win32", reason="POSIX file permissions are not available" +) +def test_save_restricts_the_permissions_of_an_existing_file(config_folder): + config_path = _write_plugin_file("example-plugin", {"API_KEY": "from-file"}) + os.chmod(config_path, 0o644) + + settings = ExamplePluginSettings.load() + settings.MAX_RESULTS = 100 + settings.save() + + assert stat.S_IMODE(os.stat(config_path).st_mode) == 0o600 + assert os.listdir(plugin_config_folder()) == ["example-plugin.yaml"] + + +def test_save_only_persists_non_default_values(config_folder): + settings = ExamplePluginSettings.load() + settings.CACHE_FOLDER = "another-cache" + + settings.save() + + with open(plugin_config_path("example-plugin")) as config_file: + assert yaml.safe_load(config_file) == {"CACHE_FOLDER": "another-cache"} + + +def test_save_does_not_persist_values_coming_from_the_environment( + config_folder, monkeypatch +): + monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "environment-secret") + + settings = ExamplePluginSettings.load() + assert settings.API_KEY == "environment-secret" + settings.MAX_RESULTS = 100 + settings.save() + + with open(plugin_config_path("example-plugin")) as config_file: + assert yaml.safe_load(config_file) == {"MAX_RESULTS": 100} + + +def test_an_invalid_environment_variable_still_protects_the_other_values( + config_folder, monkeypatch +): + monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "environment-secret") + monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_MAX_RESULTS", "not-an-int") + + settings = ExamplePluginSettings(MAX_RESULTS=100) + assert settings.API_KEY == "environment-secret" + settings.save() + + with open(plugin_config_path("example-plugin")) as config_file: + saved_values = yaml.safe_load(config_file) + assert saved_values == {"MAX_RESULTS": 100} + assert "API_KEY" not in saved_values + + +def test_save_persists_values_which_differ_from_the_environment( + config_folder, monkeypatch +): + monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "environment-secret") + + settings = ExamplePluginSettings.load() + settings.API_KEY = "chosen-key" + settings.save() + + with open(plugin_config_path("example-plugin")) as config_file: + assert yaml.safe_load(config_file) == {"API_KEY": "chosen-key"} + + +def test_save_does_not_write_the_mvt_configuration_file(config_folder): + settings = ExamplePluginSettings.load() + settings.API_KEY = "saved-key" + + settings.save() + + assert os.listdir(config_folder) == ["plugins"] + + +def test_unknown_keys_in_the_plugin_file_are_ignored(config_folder): + _write_plugin_file( + "example-plugin", + {"API_KEY": "from-file", "UNKNOWN_SETTING": "ignored"}, + ) + + settings = ExamplePluginSettings.load() + + assert settings.API_KEY == "from-file" + assert not hasattr(settings, "UNKNOWN_SETTING") + + +def test_unparsable_plugin_file_is_reported_with_its_path(config_folder): + config_path = _write_plugin_file("example-plugin", "API_KEY: [unclosed\n") + + with pytest.raises(PluginConfigLoadError) as raised: + ExamplePluginSettings.load() + + assert config_path in str(raised.value) + + +def test_plugin_file_which_is_not_a_mapping_is_reported_with_its_path(config_folder): + config_path = _write_plugin_file("example-plugin", "- one\n- two\n") + + with pytest.raises(PluginConfigLoadError) as raised: + ExamplePluginSettings.load() + + assert config_path in str(raised.value) + assert "mapping of setting names" in str(raised.value) + + +def test_plugins_do_not_interfere_with_each_other(config_folder, monkeypatch): + _write_plugin_file("other-plugin", {"API_KEY": "other-file-key"}) + monkeypatch.setenv("MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY", "example-environment-key") + + example_settings = ExamplePluginSettings.load() + other_settings = OtherPluginSettings.load() + + assert example_settings.API_KEY == "example-environment-key" + assert other_settings.API_KEY == "other-file-key" + + example_settings.MAX_RESULTS = 100 + example_settings.save() + assert sorted(os.listdir(plugin_config_folder())) == [ + "example-plugin.yaml", + "other-plugin.yaml", + ] + with open(plugin_config_path("other-plugin")) as config_file: + assert yaml.safe_load(config_file) == {"API_KEY": "other-file-key"} + + +def test_subclass_without_plugin_name_is_rejected(): + with pytest.raises(TypeError, match="plugin_name"): + + class MissingNameSettings(MVTPluginSettings): + API_KEY: Optional[str] = None + + +def test_subclass_with_invalid_plugin_name_is_rejected(): + with pytest.raises(ValueError, match="Invalid plugin name"): + + class InvalidNameSettings(MVTPluginSettings): + plugin_name = "Bad/Name" + + +def test_underscores_are_not_allowed_in_plugin_names(): + # Underscores are replaced by dashes in the environment prefix, so allowing + # both would let two plugin names share one environment namespace. + with pytest.raises(ValueError, match="Invalid plugin name"): + + class UnderscoreNameSettings(MVTPluginSettings): + plugin_name = "under_score" + + with pytest.raises(ValueError, match="Invalid plugin name"): + plugin_config_path("under_score") + with pytest.raises(ValueError, match="Invalid plugin name"): + plugin_env_prefix("under_score") + + +@pytest.mark.parametrize( + "plugin_name", ["../escape", "folder/name", "UPPER", "-dash", ""] +) +def test_unsafe_plugin_names_have_no_configuration_path(plugin_name): + with pytest.raises(ValueError, match="Invalid plugin name"): + plugin_config_path(plugin_name) + + +def test_data_folder_is_namespaced_and_created(data_folder): + folder = plugin_data_folder("example-plugin") + + assert folder == str(data_folder / "plugin-data" / "example-plugin") + assert os.path.isdir(folder) + + +def test_data_folder_can_be_requested_repeatedly(data_folder): + folder = plugin_data_folder("example-plugin") + with open(os.path.join(folder, "kept.json"), "w") as data_file: + data_file.write("{}") + + assert plugin_data_folder("example-plugin") == folder + assert os.listdir(folder) == ["kept.json"] + + +@pytest.mark.skipif( + sys.platform == "win32", reason="POSIX file permissions are not available" +) +def test_data_folder_is_only_accessible_by_the_user(data_folder): + folder = plugin_data_folder("example-plugin") + + assert stat.S_IMODE(os.stat(folder).st_mode) & 0o077 == 0 + parent_mode = stat.S_IMODE(os.stat(os.path.dirname(folder)).st_mode) + assert parent_mode & 0o077 == 0 + + +def test_plugins_get_their_own_data_folder(data_folder): + example_folder = plugin_data_folder("example-plugin") + other_folder = plugin_data_folder("other-plugin") + + assert example_folder != other_folder + assert sorted(os.listdir(data_folder / "plugin-data")) == [ + "example-plugin", + "other-plugin", + ] + + +def test_data_folder_does_not_touch_the_configuration_folder( + config_folder, data_folder +): + plugin_data_folder("example-plugin") + + assert not os.path.exists(plugin_config_folder()) + + +@pytest.mark.parametrize( + "plugin_name", ["../escape", "folder/name", "UPPER", "-dash", ""] +) +def test_unsafe_plugin_names_have_no_data_folder(data_folder, plugin_name): + with pytest.raises(ValueError, match="Invalid plugin name"): + plugin_data_folder(plugin_name) + + assert not os.path.exists(data_folder) + + +def test_settings_class_knows_its_data_folder(data_folder): + folder = ExamplePluginSettings.data_folder() + + assert folder == plugin_data_folder("example-plugin") + assert os.path.isdir(folder) + assert OtherPluginSettings.data_folder() != folder + + +def test_settings_instance_uses_the_same_data_folder(config_folder, data_folder): + settings = ExamplePluginSettings.load() + + assert settings.data_folder() == ExamplePluginSettings.data_folder() + + +def test_subclass_without_its_own_name_shares_the_data_folder(data_folder): + class InheritingSettings(ExamplePluginSettings): + pass + + assert InheritingSettings.data_folder() == ExamplePluginSettings.data_folder() From 104ffb167f60a08e4aa7802442295060390f1d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:14 +0200 Subject: [PATCH 053/109] Skip modules with unavailable dependencies instead of aborting the run (#895) A module declaring a dependency its command does not provide made _ordered_modules() give up on the whole run, so a single wrong declaration in a module scoped to several commands turned a forensic analysis into zero executed modules with one warning to explain it. Drop only the modules that cannot run: the one with the unavailable dependency, and anything depending on it. Each gets its own warning naming the module missing a dependency and the dependency it is missing, and the remaining modules run in the same stable topological order as before. A cycle in the dependency graph is still a programming error and still stops the run. --- docs/development/index.md | 13 +++- src/mvt/common/command.py | 111 ++++++++++++++++++++++++++++++----- tests/common/test_command.py | 91 ++++++++++++++++++++++++++-- 3 files changed, 192 insertions(+), 23 deletions(-) diff --git a/docs/development/index.md b/docs/development/index.md index 487127f7..c2b84646 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -35,9 +35,16 @@ class DependentModule(MVTModule): 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. +Selecting a single module also runs its transitive dependencies. + +A module can only depend on modules the command it runs in also has. When a +declared dependency is not among them, the command logs a warning naming the +module and the missing dependency, skips that module and everything depending +on it, and runs the rest of the analysis. Selecting such a module with +`--module` therefore leaves nothing to run, which the warning explains. + +A cycle in the dependency graph is a programming error rather than a +configuration problem: the command logs a warning and runs no modules at all. ## Custom modules diff --git a/src/mvt/common/command.py b/src/mvt/common/command.py index 6111dc73..f2f211d0 100644 --- a/src/mvt/common/command.py +++ b/src/mvt/common/command.py @@ -317,6 +317,81 @@ class Command: console.print("") console.print(panel) + def _skipped_modules( + self, + required: list[type[MVTModule]], + module_indexes: dict[type[MVTModule], int], + ) -> dict[type[MVTModule], tuple[type[MVTModule], type[MVTModule]]]: + """Return the modules to drop because a dependency is unavailable. + + A module declaring a dependency this command cannot provide is unable + to run, and so is every module depending on it. Dropping only those + keeps a single wrong declaration - in a module scoped to several + commands, for example - from silencing an entire analysis. + + The returned mapping gives, for each skipped module, the module which + is missing a dependency and the dependency it is missing. + """ + skipped: dict[type[MVTModule], tuple[type[MVTModule], type[MVTModule]]] = {} + # Skipping the one module a run was asked for leaves nothing to run, + # which the caller reports instead. + remainder = ( + "" if self.module_name else " The rest of the analysis will still run." + ) + + # Skipping one module can skip the modules depending on it, which the + # pass over the module list may already have gone past, so repeat the + # pass until nothing changes. + changed = True + while changed: + changed = False + for module in required: + if module in skipped: + continue + + for dependency in module.dependencies: + if dependency not in module_indexes: + skipped[module] = (module, dependency) + changed = True + self.log.warning( + "Module %s will be SKIPPED: it depends on module " + "%s, which is not available in this command.%s", + module.__name__, + dependency.__name__, + remainder, + ) + break + + if dependency in skipped: + root, missing = skipped[dependency] + skipped[module] = (root, missing) + changed = True + if dependency is root: + self.log.warning( + "Module %s will be SKIPPED: it depends on " + "module %s, itself skipped for depending on " + "unavailable module %s.%s", + module.__name__, + dependency.__name__, + missing.__name__, + remainder, + ) + else: + self.log.warning( + "Module %s will be SKIPPED: it depends on " + "skipped module %s, in a chain starting at " + "module %s, which depends on unavailable " + "module %s.%s", + module.__name__, + dependency.__name__, + root.__name__, + missing.__name__, + remainder, + ) + break + + return skipped + def _ordered_modules(self) -> Optional[list[type[MVTModule]]]: """Return enabled modules in stable topological order.""" modules = self._available_modules() @@ -329,30 +404,34 @@ class Command: else: selected = [module for module in modules if module.enabled] - required = set(selected) + required: set[type[MVTModule]] = set() pending = list(selected) while pending: module = pending.pop() + if module in required: + continue + required.add(module) 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) + # Unavailable dependencies are reported by _skipped_modules(). + if dependency in module_indexes: pending.append(dependency) + ordered_required = sorted(required, key=lambda module: module_indexes[module]) + skipped = self._skipped_modules(ordered_required, module_indexes) + runnable = [module for module in ordered_required if module not in skipped] + if skipped and not runnable: + self.log.warning( + "Every selected module was skipped for an unavailable " + "dependency. No modules will be run." + ) + dependents: dict[type[MVTModule], list[type[MVTModule]]] = { - module: [] for module in required + module: [] for module in runnable } - indegree = {module: 0 for module in required} - for module in required: + indegree = {module: 0 for module in runnable} + for module in runnable: for dependency in module.dependencies: - if dependency not in required: + if dependency not in indegree: continue dependents[dependency].append(module) indegree[module] += 1 @@ -371,7 +450,7 @@ class Command: if indegree[dependent] == 0: heappush(ready, (module_indexes[dependent], dependent)) - if len(ordered) != len(required): + if len(ordered) != len(runnable): cyclic_modules = sorted( (module.__name__ for module, count in indegree.items() if count > 0) ) diff --git a/tests/common/test_command.py b/tests/common/test_command.py index 4dbfe1a9..4835fd32 100644 --- a/tests/common/test_command.py +++ b/tests/common/test_command.py @@ -157,7 +157,7 @@ class TestCommand: assert not hasattr(cmd, "initialized") assert "Circular module dependency detected" in caplog.text - def test_unavailable_dependency_warns_and_stops(self, caplog): + def test_unavailable_dependency_only_skips_the_dependent_module(self, caplog): class UnavailableModule(RecordingModule): pass @@ -165,14 +165,97 @@ class TestCommand: dependencies = (UnavailableModule,) cmd = RecordingCommand() - cmd.modules = [DependentModule] + cmd.modules = [DependentModule, IndependentModule, FirstModule] + + with caplog.at_level(logging.WARNING): + cmd.run() + + assert RecordingModule.run_order == ["IndependentModule", "FirstModule"] + assert cmd.initialized + assert "Module DependentModule will be SKIPPED" in caplog.text + assert "depends on module UnavailableModule" in caplog.text + + def test_modules_depending_on_a_skipped_module_are_skipped_too(self, caplog): + class UnavailableModule(RecordingModule): + pass + + class SkippedModule(RecordingModule): + dependencies = (UnavailableModule,) + + class DependsOnSkippedModule(RecordingModule): + dependencies = (SkippedModule,) + + class DependsOnTheChain(RecordingModule): + dependencies = (DependsOnSkippedModule,) + + cmd = RecordingCommand() + cmd.modules = [ + DependsOnTheChain, + DependsOnSkippedModule, + SkippedModule, + IndependentModule, + ] + + with caplog.at_level(logging.WARNING): + cmd.run() + + assert RecordingModule.run_order == ["IndependentModule"] + skip_warnings = [ + record.getMessage() + for record in caplog.records + if "will be SKIPPED" in record.getMessage() + ] + assert len(skip_warnings) == 3 + assert [warning.split()[1] for warning in skip_warnings] == [ + "SkippedModule", + "DependsOnSkippedModule", + "DependsOnTheChain", + ] + # Every warning names the root cause: the module missing a dependency + # and the dependency it is missing. + assert all("UnavailableModule" in warning for warning in skip_warnings) + assert all("module SkippedModule" in warning for warning in skip_warnings[1:]) + + def test_explicitly_selected_module_with_missing_dependency_runs_nothing( + self, caplog + ): + class UnavailableModule(RecordingModule): + pass + + class DependentModule(RecordingModule): + dependencies = (UnavailableModule,) + + cmd = RecordingCommand(module_name="DependentModule") + cmd.modules = [DependentModule, IndependentModule] 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 + assert "Module DependentModule will be SKIPPED" in caplog.text + assert "No modules will be run" in caplog.text + # Nothing else was selected, so the warning must not promise that the + # analysis continues right before saying that it does not. + assert "The rest of the analysis will still run" not in caplog.text + + def test_unaffected_dependency_chains_keep_their_order(self, caplog): + class UnavailableModule(RecordingModule): + pass + + class SkippedModule(RecordingModule): + dependencies = (UnavailableModule,) + + cmd = RecordingCommand() + cmd.modules = [ThirdModule, SkippedModule, SecondModule, FirstModule] + + with caplog.at_level(logging.WARNING): + cmd.run() + + assert RecordingModule.run_order == [ + "FirstModule", + "SecondModule", + "ThirdModule", + ] def test_custom_modules_are_filtered_before_ordering(self): cmd = RecordingCommand() From 24645cb718dad02efa2477cc22e13b16cbb9d81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:14 +0200 Subject: [PATCH 054/109] Register installed CLI plugins at program start (#896) register_cli_plugins() ran while mvt.ios and mvt.android were being imported, so importing any part of MVT executed the entry points of every installed command package. That made plugin loading depend on import order: a plugin importing from MVT while MVT was still initializing got an ImportError and was quietly demoted to a broken command, and the same plugin worked when MVT happened to be imported first. Move the call into a main() function in each CLI module and point the console scripts at it, so registration happens once when the program starts and importing MVT no longer runs third-party code. For packagers: mvt.ios:cli and mvt.android:cli stay importable, but a wrapper invoking cli() directly no longer registers the installed plugin commands and should call main() instead. --- pyproject.toml | 4 +- src/mvt/android/__init__.py | 2 +- src/mvt/android/cli.py | 21 +++- src/mvt/ios/__init__.py | 2 +- src/mvt/ios/cli.py | 21 +++- tests/plugin_fixtures.py | 85 +++++++++++++ tests/test_cli_entry_points.py | 215 +++++++++++++++++++++++++++++++++ 7 files changed, 336 insertions(+), 14 deletions(-) create mode 100644 tests/plugin_fixtures.py create mode 100644 tests/test_cli_entry_points.py diff --git a/pyproject.toml b/pyproject.toml index 151d4e79..ad92423d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,8 @@ homepage = "https://docs.mvt.re/en/latest/" repository = "https://github.com/mvt-project/mvt" [project.scripts] -mvt-ios = "mvt.ios:cli" -mvt-android = "mvt.android:cli" +mvt-ios = "mvt.ios:main" +mvt-android = "mvt.android:main" [dependency-groups] dev = [ diff --git a/src/mvt/android/__init__.py b/src/mvt/android/__init__.py index 2c05f568..6616bcbe 100644 --- a/src/mvt/android/__init__.py +++ b/src/mvt/android/__init__.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/ -from .cli import cli +from .cli import cli, main diff --git a/src/mvt/android/cli.py b/src/mvt/android/cli.py index ca7e3dc6..cabd0837 100644 --- a/src/mvt/android/cli.py +++ b/src/mvt/android/cli.py @@ -514,8 +514,19 @@ def download_indicators(): ioc_updates.update() -register_cli_plugins( - cli, - entry_point_group=ANDROID_CLI_PLUGIN_GROUP, - environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV, -) +# ============================================================================== +# Entry point of the mvt-android console script +# ============================================================================== +def main() -> None: + """Register the external commands and run the mvt-android CLI. + + External commands are registered here rather than when this module is + imported, so that importing MVT never runs third-party code and a plugin + importing from MVT cannot re-enter a module that is still initializing. + """ + register_cli_plugins( + cli, + entry_point_group=ANDROID_CLI_PLUGIN_GROUP, + environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV, + ) + cli() diff --git a/src/mvt/ios/__init__.py b/src/mvt/ios/__init__.py index 2c05f568..6616bcbe 100644 --- a/src/mvt/ios/__init__.py +++ b/src/mvt/ios/__init__.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/ -from .cli import cli +from .cli import cli, main diff --git a/src/mvt/ios/cli.py b/src/mvt/ios/cli.py index c338fa54..52a1c293 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -532,8 +532,19 @@ def download_iocs(): ioc_updates.update() -register_cli_plugins( - cli, - entry_point_group=IOS_CLI_PLUGIN_GROUP, - environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV, -) +# ============================================================================== +# Entry point of the mvt-ios console script +# ============================================================================== +def main() -> None: + """Register the external commands and run the mvt-ios CLI. + + External commands are registered here rather than when this module is + imported, so that importing MVT never runs third-party code and a plugin + importing from MVT cannot re-enter a module that is still initializing. + """ + register_cli_plugins( + cli, + entry_point_group=IOS_CLI_PLUGIN_GROUP, + environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV, + ) + cli() diff --git a/tests/plugin_fixtures.py b/tests/plugin_fixtures.py new file mode 100644 index 00000000..ab3359b8 --- /dev/null +++ b/tests/plugin_fixtures.py @@ -0,0 +1,85 @@ +# 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/ + +"""Helpers building throwaway plugin distributions for the tests. + +Some plugin behaviour only shows up in a fresh interpreter: what an import +executes, and what a plugin sees when MVT is imported before or after it. +These helpers write an importable distribution with a real entry point and +run a script against it in a subprocess, with a temporary home so that the +subprocess cannot touch the configuration of whoever runs the tests. +""" + +import os +import subprocess +import sys +from pathlib import Path +from typing import Optional + +FIXTURE_COMMAND_NAME = "fixture-plugin" +FIXTURE_MODULE_NAME = "fixture_cli_plugin" +FIXTURE_DISTRIBUTION_NAME = "fixture-cli-plugin" + + +def write_cli_plugin_distribution( + site_path: Path, + entry_point_group: str, + module_source: str, +) -> Path: + """Write a distribution registering a CLI plugin entry point. + + :param site_path: Folder to write the distribution into, to be added to + the import path of the interpreter loading it. + :param entry_point_group: Entry-point group to register the command in. + :param module_source: Source of the plugin module, which must define a + Click command named `cli`. + :returns: The folder the distribution was written to. + """ + site_path.mkdir(parents=True, exist_ok=True) + (site_path / f"{FIXTURE_MODULE_NAME}.py").write_text( + module_source, encoding="utf-8" + ) + + dist_info = ( + site_path / f"{FIXTURE_DISTRIBUTION_NAME.replace('-', '_')}-1.0.dist-info" + ) + dist_info.mkdir(exist_ok=True) + (dist_info / "METADATA").write_text( + f"Metadata-Version: 2.1\nName: {FIXTURE_DISTRIBUTION_NAME}\nVersion: 1.0\n", + encoding="utf-8", + ) + (dist_info / "entry_points.txt").write_text( + f"[{entry_point_group}]\n{FIXTURE_COMMAND_NAME} = {FIXTURE_MODULE_NAME}:cli\n", + encoding="utf-8", + ) + return site_path + + +def run_isolated_python( + script: str, + home: Path, + site_path: Optional[Path] = None, + **environment: str, +) -> subprocess.CompletedProcess: + """Run a script in a fresh interpreter with its own configuration folder. + + Importing MVT writes its configuration file, so the subprocess gets a + temporary home and no MVT environment variables from the test session. + """ + isolated_environment = { + key: value for key, value in os.environ.items() if not key.startswith("MVT_") + } + isolated_environment["HOME"] = str(home) + isolated_environment["XDG_CONFIG_HOME"] = str(home / "config") + if site_path is not None: + isolated_environment["PYTHONPATH"] = str(site_path) + isolated_environment.update(environment) + + return subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + env=isolated_environment, + ) diff --git a/tests/test_cli_entry_points.py b/tests/test_cli_entry_points.py new file mode 100644 index 00000000..f5834ace --- /dev/null +++ b/tests/test_cli_entry_points.py @@ -0,0 +1,215 @@ +# 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 sys +from types import SimpleNamespace + +import click +import pytest + +import mvt.android +import mvt.ios +from mvt.android.cli import cli as android_cli +from mvt.android.cli import main as android_main +from mvt.common.cli_plugins import ANDROID_CLI_PLUGIN_GROUP, IOS_CLI_PLUGIN_GROUP +from mvt.ios.cli import cli as ios_cli +from mvt.ios.cli import main as ios_main + +from .plugin_fixtures import ( + FIXTURE_COMMAND_NAME, + run_isolated_python, + write_cli_plugin_distribution, +) + +MARKER_PLUGIN_TEMPLATE = """ +import os + +import click + +# Touched when this module is imported, so a test can tell whether loading MVT +# executed the plugin. +open(os.environ["FIXTURE_PLUGIN_MARKER"], "a").close() + + +@click.command() +def cli(): + click.echo("fixture plugin ran") +""" + +PROGRAMS = { + "mvt-ios": (mvt.ios, ios_cli, IOS_CLI_PLUGIN_GROUP), + "mvt-android": (mvt.android, android_cli, ANDROID_CLI_PLUGIN_GROUP), +} + + +@pytest.fixture +def restore_cli_commands(): + """Undo the plugin registration main() performs on the shared CLI groups.""" + originals = { + program: dict(group.commands) for program, (_, group, _) in PROGRAMS.items() + } + yield + for program, (_, group, _) in PROGRAMS.items(): + group.commands.clear() + group.commands.update(originals[program]) + if hasattr(group, "_mvt_external_command_sources"): + delattr(group, "_mvt_external_command_sources") + + +def _install_fixture_entry_point(monkeypatch, entry_point_group, command): + def entry_points(*, group): + if group != entry_point_group: + return [] + return [ + SimpleNamespace( + name=FIXTURE_COMMAND_NAME, + value="fixture_cli_plugin:cli", + load=lambda: command, + dist=SimpleNamespace( + metadata={"Name": "fixture-cli-plugin"}, version="1.0" + ), + ) + ] + + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", entry_points + ) + + +def _offline_argv(program, *arguments): + """Build an argument list which keeps the CLI from checking for updates.""" + return [ + program, + "--disable-update-check", + "--disable-indicator-update-check", + *arguments, + ] + + +@pytest.mark.parametrize("program", sorted(PROGRAMS)) +def test_main_registers_installed_plugins_before_running_the_cli( + program, monkeypatch, capsys, restore_cli_commands +): + package, group, entry_point_group = PROGRAMS[program] + + @click.command() + def fixture_command(): + click.echo("fixture plugin ran") + + _install_fixture_entry_point(monkeypatch, entry_point_group, fixture_command) + monkeypatch.setattr(sys, "argv", _offline_argv(program, FIXTURE_COMMAND_NAME)) + + with pytest.raises(SystemExit) as exit_info: + package.main() + + assert exit_info.value.code == 0 + assert "fixture plugin ran" in capsys.readouterr().out + assert FIXTURE_COMMAND_NAME in group.commands + + +@pytest.mark.parametrize("program", sorted(PROGRAMS)) +def test_main_completes_plugin_command_names( + program, monkeypatch, capsys, restore_cli_commands +): + package, _, entry_point_group = PROGRAMS[program] + + @click.command() + def fixture_command(): + pass + + _install_fixture_entry_point(monkeypatch, entry_point_group, fixture_command) + complete_variable = f"_{program.upper().replace('-', '_')}_COMPLETE" + monkeypatch.setenv(complete_variable, "bash_complete") + monkeypatch.setenv("COMP_WORDS", f"{program} fixture") + monkeypatch.setenv("COMP_CWORD", "1") + monkeypatch.setattr(sys, "argv", [program]) + + with pytest.raises(SystemExit): + package.main() + + assert f"plain,{FIXTURE_COMMAND_NAME}" in capsys.readouterr().out + + +@pytest.mark.parametrize("program", sorted(PROGRAMS)) +def test_main_still_loads_commands_from_a_file( + program, monkeypatch, capsys, tmp_path, restore_cli_commands +): + package, _, entry_point_group = PROGRAMS[program] + command_path = tmp_path / "case_summary.py" + command_path.write_text( + "import click\n" + "\n" + "\n" + '@click.command("case-summary")\n' + "def cli():\n" + ' click.echo("case summary ran")\n', + encoding="utf-8", + ) + _install_fixture_entry_point( + monkeypatch, entry_point_group, click.Command("unused") + ) + monkeypatch.setattr( + sys, + "argv", + _offline_argv(program, "--load-command", str(command_path), "case-summary"), + ) + + with pytest.raises(SystemExit) as exit_info: + package.main() + + assert exit_info.value.code == 0 + assert "case summary ran" in capsys.readouterr().out + + +def test_the_console_script_targets_are_importable(): + # [project.scripts] points at these, so they must stay on the packages. + assert mvt.ios.main is ios_main + assert mvt.android.main is android_main + + +def test_importing_mvt_does_not_run_installed_plugins(tmp_path): + site_path = write_cli_plugin_distribution( + tmp_path / "site", IOS_CLI_PLUGIN_GROUP, MARKER_PLUGIN_TEMPLATE + ) + marker = tmp_path / "plugin-imported" + + result = run_isolated_python( + "import mvt.ios.cli\nimport mvt.android.cli\nprint('imported')", + home=tmp_path / "home", + site_path=site_path, + FIXTURE_PLUGIN_MARKER=str(marker), + ) + + assert result.returncode == 0, result.stderr + assert "imported" in result.stdout + assert not marker.exists() + + +def test_registering_the_plugins_runs_the_entry_point(tmp_path): + site_path = write_cli_plugin_distribution( + tmp_path / "site", IOS_CLI_PLUGIN_GROUP, MARKER_PLUGIN_TEMPLATE + ) + marker = tmp_path / "plugin-imported" + + result = run_isolated_python( + "import click\n" + "from mvt.common.cli_plugins import (\n" + " IOS_CLI_PLUGIN_GROUP,\n" + " BrokenPluginCommand,\n" + " register_installed_cli_commands,\n" + ")\n" + "group = click.Group()\n" + "register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)\n" + f"command = group.commands[{FIXTURE_COMMAND_NAME!r}]\n" + "assert not isinstance(command, BrokenPluginCommand), command.help\n" + "print('registered')\n", + home=tmp_path / "home", + site_path=site_path, + FIXTURE_PLUGIN_MARKER=str(marker), + ) + + assert result.returncode == 0, result.stderr + assert "registered" in result.stdout + assert marker.exists() From a78894aaa5c897ad2154435412e7f759e9de57b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:15 +0200 Subject: [PATCH 055/109] Add a platform-neutral mvt command (#897) * Add a platform-neutral mvt command Several MVT commands have nothing to do with the acquisition of one platform, yet they were reachable only through mvt-ios and mvt-android. Asking which version is installed or downloading the public indicators meant picking one of the two platform commands arbitrarily, and each of those tasks had to be written, documented and maintained twice. Add a third console script, mvt, hosting the commands which belong to no platform: version and download-iocs for now, with completion following in a later commit. Commands installed in the new mvt.cli_plugins entry-point group are registered on mvt, and on mvt only, so that a command package chooses the CLI each of its commands is added to: mvt.ios.cli_plugins for mvt-ios, mvt.android.cli_plugins for mvt-android and mvt.cli_plugins for mvt. A command wanted on both platform CLIs is registered in both platform groups; no group adds a command to every CLI. The MVT_CUSTOM_COMMANDS variable loads command files and folders into mvt the way the platform variables already do for mvt-ios and mvt-android. Run on its own, mvt prints the banner and its help instead of a usage error. The help text reminds that the forensic analysis of an acquisition runs through mvt-ios and mvt-android, so that the command which knows nothing about acquisitions says where they are analysed. version and download-iocs stay on mvt-ios and mvt-android for now, so that no documented invocation stops working. They are to be dropped from the platform CLIs in a later release, once mvt has been available long enough for the change to be announced. Unlike mvt-ios and mvt-android, which point at their subpackages, the console script points at mvt.cli:main and the mvt package re-exports nothing of it. Importing mvt has to stay cheap and free of side effects: it is the package plugins import from, and pulling in Click, the CLI and everything the commands import merely because something imported mvt would work against that. While here, give the version command of both platform CLIs the context settings every other command already has, so that "mvt-ios version -h" prints its help instead of failing on an unknown option. * Reduce indent for MVT CLI header * Move shell completion to the mvt command and generate one script for every MVT command Setting up shell completion had nothing to do with the acquisition of one platform, yet it was a command of mvt-ios and mvt-android, each generating the script of the program it ran under only. Completion now leaves the platform CLIs for mvt, which emits one script covering mvt, mvt-ios and mvt-android, installed as a single file loaded when the shell starts. Click names the completion function of each program after the program, so the three scripts concatenate without colliding, and fish takes the file in conf.d rather than one named after a single command. With one script there is nothing left for the platform commands to generate, so their completion command goes, and with it the banner suppression which was keyed on the command name: mvt-ios and mvt-android now print the banner for every command they have. The long help line says which commands the completion covers, so a short_help keeps "mvt --help" from truncating it. * Point the README and the command docs at plugin packages Loading a command by path is for local development; a package is how a command is distributed. The README's summary of what extends MVT now names plugin packages and nothing else, and the section on loading a command file says what it is for: keeping a command being written loadable without reinstalling its package after every change. --- README.md | 25 ++-- docs/command_completion.md | 44 +++---- docs/development/custom_commands.md | 60 +++++++-- docs/index.md | 2 +- docs/install.md | 6 +- docs/iocs.md | 2 +- pyproject.toml | 1 + src/mvt/android/cli.py | 52 +------- src/mvt/cli.py | 102 +++++++++++++++ src/mvt/common/cli_plugins.py | 13 ++ src/mvt/common/completion.py | 121 ++++++++++++----- src/mvt/common/help.py | 4 +- src/mvt/common/logo.py | 20 +-- src/mvt/ios/cli.py | 52 +------- tests/common/test_cli_plugins.py | 196 +++++++++++++++++++++++++++- tests/conftest.py | 35 +++++ tests/test_cli.py | 49 +++++++ tests/test_cli_entry_points.py | 121 +++++++++++++---- tests/test_completion.py | 66 ++++++---- 19 files changed, 727 insertions(+), 244 deletions(-) create mode 100644 src/mvt/cli.py create mode 100644 tests/test_cli.py diff --git a/README.md b/README.md index d4ac9b4f..5f00600e 100644 --- a/README.md +++ b/README.md @@ -58,34 +58,31 @@ For alternative installation options and known issues, please refer to the [docu ## Usage -MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/) +MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version`, `completion` and `download-iocs` (`version` and `download-iocs` remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [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: +MVT can generate a shell completion script for Bash, Zsh, and Fish which covers `mvt`, `mvt-ios` and `mvt-android`: ```bash -mvt-ios completion -mvt-android completion +mvt completion ``` -The commands print setup instructions by default. To generate a completion script directly, pass the shell name: +The command prints setup instructions by default. To generate the completion script directly, pass the shell name: ```bash -mvt-ios completion bash -mvt-android completion zsh +mvt completion bash ``` 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 +Plugin packages extend MVT with additional forensic modules, which run inside +the `check-*` commands, and with top-level commands on `mvt`, `mvt-ios` and +`mvt-android`. See the +[development documentation](https://docs.mvt.re/en/latest/development/) for +writing and installing them, and the [custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/) -for the plugin entry points and `--load-command` interface. +for the entry points a package registers commands in. ## License diff --git a/docs/command_completion.md b/docs/command_completion.md index 7204df50..a51cc7a0 100644 --- a/docs/command_completion.md +++ b/docs/command_completion.md @@ -6,61 +6,61 @@ Click provides tab completion support for Bash (version 4.4 and up), Zsh, and Fi 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. +`mvt completion` generates one script which covers `mvt`, `mvt-ios` and `mvt-android`. The following describes how to generate that script and add it to your shell configuration. > **Note: You will need to start a new shell for the changes to take effect.** ### For Bash ```bash -# Generate bash completion scripts -mvt-ios completion bash > ~/.mvt-ios-complete.bash -mvt-android completion bash > ~/.mvt-android-complete.bash +# Generate the bash completion script +mvt completion bash > ~/.mvt-complete.bash ``` Add the following to `~/.bashrc`: ```bash -# source mvt completion scripts -[ -f ~/.mvt-ios-complete.bash ] && . ~/.mvt-ios-complete.bash -[ -f ~/.mvt-android-complete.bash ] && . ~/.mvt-android-complete.bash +# source the mvt completion script +[ -f ~/.mvt-complete.bash ] && . ~/.mvt-complete.bash ``` ### For Zsh ```bash -# Generate zsh completion scripts -mvt-ios completion zsh > ~/.mvt-ios-complete.zsh -mvt-android completion zsh > ~/.mvt-android-complete.zsh +# Generate the zsh completion script +mvt completion zsh > ~/.mvt-complete.zsh ``` Add the following to `~/.zshrc`: ```bash -# source mvt completion scripts -[ -f ~/.mvt-ios-complete.zsh ] && . ~/.mvt-ios-complete.zsh -[ -f ~/.mvt-android-complete.zsh ] && . ~/.mvt-android-complete.zsh +# source the mvt completion script +[ -f ~/.mvt-complete.zsh ] && . ~/.mvt-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 +# Generate the fish completion script +mkdir -p ~/.config/fish/conf.d +mvt completion fish > ~/.config/fish/conf.d/mvt-completion.fish ``` -Fish loads completion files from `~/.config/fish/completions` automatically. +Fish loads the files in `~/.config/fish/conf.d` 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 +mvt completion bash --install ``` -Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/completions`. +Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/conf.d` and changes no shell configuration. + +!!! note + + Earlier versions generated one script per command, with `mvt-ios completion` + and `mvt-android completion`. Files written by them keep working. When you + switch to the single script, remove the old files and the lines which load + them from your shell configuration. For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion). - diff --git a/docs/development/custom_commands.md b/docs/development/custom_commands.md index 1d012f90..19cdb85c 100644 --- a/docs/development/custom_commands.md +++ b/docs/development/custom_commands.md @@ -1,6 +1,8 @@ # Custom CLI Commands -MVT can load additional top-level commands into `mvt-ios` and `mvt-android`. +MVT can load additional top-level commands into `mvt`, `mvt-ios` and +`mvt-android`. A command package chooses which of the three each of its +commands is added to. Custom commands are different from [custom forensic modules](index.md#custom-modules): commands add new CLI operations, while modules add analysis steps to existing `check-*` commands. @@ -13,8 +15,8 @@ commands add new CLI operations, while modules add analysis steps to existing ## 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 packages can register a Click command or group on one or more of the MVT CLIs. +A minimal package can expose this command from `my_mvt_plugin.py`: ```python import click @@ -38,9 +40,13 @@ summarize = "my_mvt_plugin:summarize" 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: +Each entry-point group adds the command to one CLI: `mvt.ios.cli_plugins` to +`mvt-ios`, `mvt.android.cli_plugins` to `mvt-android` and `mvt.cli_plugins` to +`mvt`. Register the command in the group of every CLI which should offer it: a +platform-specific command belongs in one platform group, and a command which +handles acquisitions of both platforms, as above, in both. After installing the +package in the same environment as MVT, the command appears directly in those +CLIs: ```bash mvt-ios summarize ./ios-backup @@ -60,10 +66,32 @@ Command packages that need their own settings, such as an API key, should store them in a namespaced [plugin configuration file](plugin_configuration.md) rather than in MVT's own `config.yaml`. -## Load a Command File +### Commands on `mvt` -For local commands that are not packaged, create a Python file that exports one -Click command or group named `cli`: +The `mvt` command hosts what belongs to neither platform: `version`, +`completion` and `download-iocs`. A plugin command which is not about the +acquisition of one platform, such as one which configures the plugin or +synchronizes the indicators it uses, belongs there too, in the +`mvt.cli_plugins` group: + +```toml +[project.entry-points."mvt.cli_plugins"] +my-plugin = "my_mvt_plugin:my_plugin" +``` + +Commands in this group are added to `mvt` only, so this one is invoked as +`mvt my-plugin`. A command on `mvt` has nothing but its name to say which +plugin it belongs to, so name it after the plugin, and make it a Click group +when the plugin has several operations to offer, such as +`mvt my-plugin configure`. + +## Developing a Command Locally + +A package is how a command is distributed. While a command is being written, +MVT can load it straight from its file instead, so the package need not be +reinstalled after every change; an editable install of the package does the +same through its entry points. Create a Python file that exports one Click +command or group named `cli`: ```python import click @@ -86,10 +114,12 @@ mvt-ios --load-command ./case_summary.py case-summary ./ios-backup 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: +To load a file or folder on every invocation, set the environment variable of +the CLI the commands belong on. Like the entry-point groups, each variable adds +its commands to one CLI only: ```bash +export MVT_CUSTOM_COMMANDS=./commands export MVT_IOS_CUSTOM_COMMANDS=./ios_commands export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands ``` @@ -97,9 +127,11 @@ 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. +unique on each CLI; when installed packages or environment paths collide, MVT +keeps the first command and logs a warning. The environment path of a CLI is +registered before its installed packages, so a command loaded from there wins a +collision with a package. 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. diff --git a/docs/index.md b/docs/index.md index fada3163..8cd733ea 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,7 @@ Mobile Verification Toolkit (MVT) is a tool to facilitate the [consensual forens It has been developed and released by the [Amnesty International Security Lab](https://securitylab.amnesty.org) in July 2021 in the context of the [Pegasus Project](https://forbiddenstories.org/about-the-pegasus-project/) along with [a technical forensic methodology](https://www.amnesty.org/en/latest/research/2021/07/forensic-methodology-report-how-to-catch-nso-groups-pegasus/). It continues to be maintained by Amnesty International and other contributors. -In this documentation you will find instructions on how to install and run the `mvt-ios` and `mvt-android` commands, and guidance on how to interpret the extracted results. +In this documentation you will find instructions on how to install and run the `mvt-ios`, `mvt-android` and `mvt` commands, and guidance on how to interpret the extracted results. ## Resources diff --git a/docs/install.md b/docs/install.md index c08c75db..c024338c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -64,7 +64,7 @@ It is recommended to try installing and running MVT from [Windows Subsystem Linu pipx install mvt ``` -You now should have the `mvt-ios` and `mvt-android` utilities installed. If you run into problems with these commands not being found, ensure you have run `pipx ensurepath` and opened a new terminal window. +You now should have the `mvt`, `mvt-ios` and `mvt-android` utilities installed. If you run into problems with these commands not being found, ensure you have run `pipx ensurepath` and opened a new terminal window. ### Installing from PyPI directly into a virtual environment You can use `pipenv`, `poetry` etc. for your virtual environment, but the provided example is with the built-in `venv` tool: @@ -84,7 +84,7 @@ source env/bin/activate pip install mvt ``` -The `mvt-ios` and `mvt-android` utilities should now be available as commands whenever the virtual environment is active. +The `mvt`, `mvt-ios` and `mvt-android` utilities should now be available as commands whenever the virtual environment is active. ### Installing from git source with pipx If you want to have the latest features in development, you can install MVT directly from the source code in git. @@ -93,7 +93,7 @@ If you want to have the latest features in development, you can install MVT dire pipx install --force git+https://github.com/mvt-project/mvt.git ``` -You now should have the `mvt-ios` and `mvt-android` utilities installed. +You now should have the `mvt`, `mvt-ios` and `mvt-android` utilities installed. **Notes:** 1. The `--force` flag is necessary to force the reinstallation of the package. diff --git a/docs/iocs.md b/docs/iocs.md index c7586dea..5f50effd 100644 --- a/docs/iocs.md +++ b/docs/iocs.md @@ -71,7 +71,7 @@ So far MVT implements only a subset of [STIX2 specifications](https://docs.oasis - [This repository](https://github.com/Te-k/stalkerware-indicators) contains IOCs for Android stalkerware including [a STIX MVT-compatible file](https://raw.githubusercontent.com/Te-k/stalkerware-indicators/master/generated/stalkerware.stix2). - We are also maintaining [a list of IOCs](https://github.com/mvt-project/mvt-indicators) in STIX format from public spyware campaigns. -You can automaticallly download the latest public indicator files with the command `mvt-ios download-iocs` or `mvt-android download-iocs`. These commands download the list of indicators from the [mvt-indicators](https://github.com/mvt-project/mvt-indicators/blob/main/indicators.yaml) repository and store them in the [appdir](https://pypi.org/project/appdirs/) folder. They are then loaded automatically by MVT. +You can automatically download the latest public indicator files with the command `mvt download-iocs`. The per-platform forms `mvt-ios download-iocs` and `mvt-android download-iocs` do the same thing. These commands download the list of indicators from the [mvt-indicators](https://github.com/mvt-project/mvt-indicators/blob/main/indicators.yaml) repository and store them in the [appdir](https://pypi.org/project/appdirs/) folder. They are then loaded automatically by MVT. Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs. diff --git a/pyproject.toml b/pyproject.toml index ad92423d..fb90f8db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ homepage = "https://docs.mvt.re/en/latest/" repository = "https://github.com/mvt-project/mvt" [project.scripts] +mvt = "mvt.cli:main" mvt-ios = "mvt.ios:main" mvt-android = "mvt.android:main" diff --git a/src/mvt/android/cli.py b/src/mvt/android/cli.py index cabd0837..4716168e 100644 --- a/src/mvt/android/cli.py +++ b/src/mvt/android/cli.py @@ -15,12 +15,6 @@ from mvt.common.cli_plugins import ( 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_CHECK_ADB_REMOVED, @@ -31,7 +25,6 @@ from mvt.common.help import ( 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_HASHES, @@ -102,55 +95,20 @@ 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 - if ctx.invoked_subcommand != "completion": - logo( - disable_version_check=disable_update_check, - disable_indicator_check=disable_indicator_update_check, - ) + logo( + disable_version_check=disable_update_check, + disable_indicator_check=disable_indicator_update_check, + ) # ============================================================================== # Command: version # ============================================================================== -@cli.command("version", help=HELP_MSG_VERSION) +@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION) def version(): return -# ============================================================================== -# Command: completion -# ============================================================================== -@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION) -@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS)) -@click.option( - "--install", - is_flag=True, - help="Write completion files and update shell configuration.", -) -@click.pass_context -def completion(ctx, shell, install): - program_name = "mvt-android" - - if shell is None: - if install: - raise click.UsageError("A shell is required when using --install.") - click.echo(completion_instructions(program_name)) - return - - root_cli = ctx.find_root().command - - if install: - script_path = install_completion_script(root_cli, program_name, shell) - click.echo(f"Installed {shell} completion to {script_path}") - if shell in ("bash", "zsh"): - click.echo(f"Updated ~/.{shell}rc") - else: - click.echo("Fish loads completion files automatically.") - return - - click.echo(generate_completion_script(root_cli, program_name, shell)) - - # ============================================================================== # Command: check-adb (removed) # ============================================================================== diff --git a/src/mvt/cli.py b/src/mvt/cli.py new file mode 100644 index 00000000..a2e48dd6 --- /dev/null +++ b/src/mvt/cli.py @@ -0,0 +1,102 @@ +# 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 click + +from mvt.common.cli_plugins import ( + MVT_CUSTOM_COMMANDS_ENV, + NEUTRAL_CLI_PLUGIN_GROUP, + load_cli_commands_option, + register_cli_plugins, +) +from mvt.common.completion import completion +from mvt.common.help import ( + HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, + HELP_MSG_DISABLE_UPDATE_CHECK, + HELP_MSG_STIX2, + HELP_MSG_VERSION, +) +from mvt.common.logo import logo +from mvt.common.updates import IndicatorsUpdates +from mvt.common.utils import init_logging + +init_logging() + +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) + + +# ============================================================================== +# Main +# ============================================================================== +@click.group(invoke_without_command=True) +@load_cli_commands_option +@click.option( + "--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK +) +@click.option( + "--disable-indicator-update-check", + is_flag=True, + help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, +) +@click.pass_context +def cli(ctx, disable_update_check, disable_indicator_update_check): + """Mobile Verification Toolkit. + + mvt-ios and mvt-android run the forensic analysis of an acquisition: each + provides the check-* commands of its platform. This command hosts what + belongs to neither platform; run it without a command to see the installed + version and the list of what it offers. + """ + ctx.ensure_object(dict) + ctx.obj["disable_version_check"] = disable_update_check + ctx.obj["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, + ) + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +# ============================================================================== +# Command: download-iocs +# ============================================================================== +@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2) +def download_iocs(): + ioc_updates = IndicatorsUpdates() + ioc_updates.update() + + +# ============================================================================== +# Command: completion +# ============================================================================== +cli.add_command(completion) + + +# ============================================================================== +# Command: version +# ============================================================================== +@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION) +def version(): + return + + +# ============================================================================== +# Entry point of the mvt console script +# ============================================================================== +def main() -> None: + """Register the external commands and run the mvt CLI. + + External commands are registered here rather than when this module is + imported, so that importing MVT never runs third-party code and a plugin + importing from MVT cannot re-enter a module that is still initializing. + """ + register_cli_plugins( + cli, + entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP, + environment_variable=MVT_CUSTOM_COMMANDS_ENV, + ) + cli() diff --git a/src/mvt/common/cli_plugins.py b/src/mvt/common/cli_plugins.py index acf4c6dc..47352228 100644 --- a/src/mvt/common/cli_plugins.py +++ b/src/mvt/common/cli_plugins.py @@ -17,6 +17,9 @@ import click IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins" ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins" +# Commands in this group are registered on the platform-neutral mvt command only. +NEUTRAL_CLI_PLUGIN_GROUP = "mvt.cli_plugins" +MVT_CUSTOM_COMMANDS_ENV = "MVT_CUSTOM_COMMANDS" MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS" MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS" @@ -255,6 +258,16 @@ def register_cli_plugins( entry_point_group: str, environment_variable: str, ) -> None: + """Register the external commands of one CLI on its group. + + Each CLI has one entry-point group and one environment variable of its + own, so a command package chooses the CLIs its commands are added to. + + :param group: CLI group to register the external commands on. + :param entry_point_group: Entry-point group of the CLI. + :param environment_variable: Name of the environment variable holding a + path to load commands from. + """ environment_path = os.environ.get(environment_variable) if environment_path: register_cli_commands_from_path(group, environment_path) diff --git a/src/mvt/common/completion.py b/src/mvt/common/completion.py index 6466a6d4..0b72b4ed 100644 --- a/src/mvt/common/completion.py +++ b/src/mvt/common/completion.py @@ -9,34 +9,83 @@ import shlex import click from click.shell_completion import get_completion_class +from .help import HELP_MSG_COMPLETION + SUPPORTED_SHELLS = ("bash", "zsh", "fish") +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) +COMPLETION_INSTRUCTIONS = """Shell completion for mvt, mvt-ios and mvt-android -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 +Print one completion script covering the three commands: + mvt completion bash > ~/.mvt-complete.bash + mvt completion zsh > ~/.mvt-complete.zsh + mkdir -p ~/.config/fish/conf.d + mvt completion fish > ~/.config/fish/conf.d/mvt-completion.fish Load the generated Bash script from ~/.bashrc: - [ -f ~/.{program_name}-complete.bash ] && . ~/.{program_name}-complete.bash + [ -f ~/.mvt-complete.bash ] && . ~/.mvt-complete.bash Load the generated Zsh script from ~/.zshrc: - [ -f ~/.{program_name}-complete.zsh ] && . ~/.{program_name}-complete.zsh + [ -f ~/.mvt-complete.zsh ] && . ~/.mvt-complete.zsh -Fish loads completion files from ~/.config/fish/completions automatically. +Fish loads the files in ~/.config/fish/conf.d 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 +To write these files and update the Bash/Zsh shell configuration automatically: + mvt completion bash --install + mvt completion zsh --install + mvt completion fish --install """ +def _mvt_programs() -> list[tuple[str, click.Command]]: + """Return the console script name and CLI group of every MVT program. + + The three CLIs are imported here rather than at module level: mvt.cli + imports this module while it is being defined, and generating a completion + script should not make the start-up of `mvt` import the platform CLIs. + """ + from mvt.android.cli import cli as android_cli + from mvt.cli import cli as mvt_cli + from mvt.ios.cli import cli as ios_cli + + return [("mvt", mvt_cli), ("mvt-ios", ios_cli), ("mvt-android", android_cli)] + + +@click.command( + "completion", + context_settings=CONTEXT_SETTINGS, + help=HELP_MSG_COMPLETION, + short_help="Generate or install shell 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.", +) +def completion(shell, install): + if shell is None: + if install: + raise click.UsageError("A shell is required when using --install.") + click.echo(COMPLETION_INSTRUCTIONS) + return + + if install: + script_path = install_completion_script(shell) + click.echo( + f"Installed {shell} completion for mvt, mvt-ios and mvt-android " + f"to {script_path}" + ) + if shell in ("bash", "zsh"): + click.echo(f"Updated ~/.{shell}rc") + else: + click.echo("Fish loads the files in ~/.config/fish/conf.d automatically.") + return + + click.echo(generate_mvt_completion_script(shell)) + + def generate_completion_script(cli: click.Command, program_name: str, shell: str) -> str: completion_class = get_completion_class(shell) if completion_class is None: @@ -46,41 +95,49 @@ def generate_completion_script(cli: click.Command, program_name: str, shell: str 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) +def generate_mvt_completion_script(shell: str) -> str: + """Return one script completing every MVT command. + + Click names the completion function of each program after the program, so + the scripts of the three commands can simply be concatenated. + """ + scripts = [ + generate_completion_script(cli, program_name, shell).strip("\n") + for program_name, cli in _mvt_programs() + ] + return "\n\n".join(scripts) + + +def install_completion_script(shell: str) -> Path: + script = generate_mvt_completion_script(shell) + script_path = _completion_script_path(shell) script_path.parent.mkdir(parents=True, exist_ok=True) - script_path.write_text(script, encoding="utf-8") + script_path.write_text(f"{script}\n", encoding="utf-8") if shell in ("bash", "zsh"): - _install_shell_source_line(program_name, shell, script_path) + _install_shell_source_line(shell, script_path) return script_path -def _completion_script_path(program_name: str, shell: str) -> Path: +def _completion_script_path(shell: str) -> Path: home = Path.home() if shell == "fish": - return home / ".config" / "fish" / "completions" / f"{program_name}.fish" + # conf.d is sourced when the shell starts, unlike the completions + # folder, whose files fish loads on demand by command name. + return home / ".config" / "fish" / "conf.d" / "mvt-completion.fish" - return home / f".{program_name}-complete.{shell}" + return home / f".mvt-complete.{shell}" -def _install_shell_source_line(program_name: str, shell: str, script_path: Path) -> None: +def _install_shell_source_line(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" - ) + block = f"# MVT shell completion\n{source_line}\n" if shell_config_path.exists(): shell_config = shell_config_path.read_text(encoding="utf-8") diff --git a/src/mvt/common/help.py b/src/mvt/common/help.py index 75141135..4eccef14 100644 --- a/src/mvt/common/help.py +++ b/src/mvt/common/help.py @@ -21,7 +21,9 @@ 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" +HELP_MSG_COMPLETION = ( + "Generate or install shell completion for mvt, mvt-ios and mvt-android" +) # IOS Specific HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup" diff --git a/src/mvt/common/logo.py b/src/mvt/common/logo.py index 048ee228..862479f6 100644 --- a/src/mvt/common/logo.py +++ b/src/mvt/common/logo.py @@ -24,7 +24,7 @@ def check_updates( latest_version = mvt_updates.check() except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): rich_print( - "\t\t[bold]Note: Could not check for MVT updates.[/bold] " + "\t[bold]Note: Could not check for MVT updates.[/bold] " "You may be working offline. Please update MVT regularly." ) except Exception as e: @@ -34,7 +34,7 @@ def check_updates( else: if latest_version: rich_print( - f"\t\t[bold]Version {latest_version} is available! " + f"\t[bold]Version {latest_version} is available! " "Upgrade mvt with `pip3 install -U mvt` or with `pipx upgrade mvt`[/bold]" ) @@ -46,7 +46,7 @@ def check_updates( # If not, there's no point in proceeding with the updates check. if ioc_updates.get_latest_update() == 0: rich_print( - "\t\t[bold]You have not yet downloaded any indicators, check " + "\t[bold]You have not yet downloaded any indicators, check " "the `download-iocs` command![/bold]" ) return @@ -57,7 +57,7 @@ def check_updates( should_check, hours = ioc_updates.should_check() if not should_check: rich_print( - f"\t\tIndicators updates checked recently, next automatic check " + f"\tIndicators updates checked recently, next automatic check " f"in {int(hours)} hours" ) return @@ -66,7 +66,7 @@ def check_updates( ioc_to_update = ioc_updates.check() except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): rich_print( - "\t\t[bold]Note: Could not check for indicator updates.[/bold] " + "\t[bold]Note: Could not check for indicator updates.[/bold] " "You may be working offline. Please update MVT indicators regularly." ) except Exception as e: @@ -76,20 +76,20 @@ def check_updates( else: if ioc_to_update: rich_print( - "\t\t[bold]There are updates to your indicators files! " + "\t[bold]There are updates to your indicators files! " "Run the `download-iocs` command to update![/bold]" ) else: - rich_print("\t\tYour indicators files seem to be up to date.") + rich_print("\tYour indicators files seem to be up to date.") def logo( disable_version_check: bool = False, disable_indicator_check: bool = False ) -> None: rich_print("\n") - rich_print("\t[bold]MVT[/bold] - Mobile Verification Toolkit") - rich_print("\t\thttps://mvt.re") - rich_print(f"\t\tVersion: {MVT_VERSION}") + rich_print("\t[bold]MVT - Mobile Verification Toolkit[/bold]\n") + rich_print("\thttps://mvt.re") + rich_print(f"\tVersion: {MVT_VERSION}\n") check_updates(disable_version_check, disable_indicator_check) diff --git a/src/mvt/ios/cli.py b/src/mvt/ios/cli.py index 52a1c293..6f633eae 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -15,12 +15,6 @@ from mvt.common.cli_plugins import ( 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 @@ -51,7 +45,6 @@ from mvt.common.help import ( 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 @@ -106,55 +99,20 @@ 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 - if ctx.invoked_subcommand != "completion": - logo( - disable_version_check=disable_update_check, - disable_indicator_check=disable_indicator_update_check, - ) + logo( + disable_version_check=disable_update_check, + disable_indicator_check=disable_indicator_update_check, + ) # ============================================================================== # Command: version # ============================================================================== -@cli.command("version", help=HELP_MSG_VERSION) +@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION) 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 # ============================================================================== diff --git a/tests/common/test_cli_plugins.py b/tests/common/test_cli_plugins.py index ea7c7fa7..5abb2c98 100644 --- a/tests/common/test_cli_plugins.py +++ b/tests/common/test_cli_plugins.py @@ -3,9 +3,12 @@ from types import SimpleNamespace import click from click.testing import CliRunner +from mvt.cli import cli as mvt_cli from mvt.common.cli_plugins import ( ANDROID_CLI_PLUGIN_GROUP, IOS_CLI_PLUGIN_GROUP, + MVT_CUSTOM_COMMANDS_ENV, + NEUTRAL_CLI_PLUGIN_GROUP, BrokenPluginCommand, load_cli_commands_option, register_cli_commands_from_path, @@ -14,6 +17,9 @@ from mvt.common.cli_plugins import ( ) +# Keep the banner of the mvt group callback from checking for updates online. +OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"] + COMMAND_TEMPLATE = """ import click @@ -345,7 +351,11 @@ def test_platform_entry_point_groups_and_environment_paths_are_separate( 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)] + if group == ANDROID_CLI_PLUGIN_GROUP: + return [ + _entry_point("android-package", "android_plugin:cli", android_package) + ] + return [] monkeypatch.setattr( "mvt.common.cli_plugins.importlib.metadata.entry_points", @@ -369,3 +379,187 @@ def test_platform_entry_point_groups_and_environment_paths_are_separate( assert set(ios_group.commands) == {"ios-file", "ios-package"} assert set(android_group.commands) == {"android-file", "android-package"} + + +def test_neutral_entry_point_group_is_not_registered_on_the_platform_clis( + monkeypatch, +): + @click.command() + def neutral_package(): + pass + + def entry_points(*, group): + if group == NEUTRAL_CLI_PLUGIN_GROUP: + return [ + _entry_point("neutral-package", "neutral_plugin:cli", neutral_package) + ] + return [] + + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + entry_points, + ) + 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 not ios_group.commands + assert not android_group.commands + + +def test_environment_command_wins_collision_with_installed_command( + tmp_path, monkeypatch, caplog +): + command_path = _write_command( + tmp_path / "duplicate.py", + "duplicate", + message="environment command ran", + ) + + @click.command() + def installed_command(): + pass + + def entry_points(*, group): + if group == IOS_CLI_PLUGIN_GROUP: + return [ + _entry_point( + "duplicate", + "ios_plugin:cli", + installed_command, + distribution="ios-plugin", + ) + ] + return [] + + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + entry_points, + ) + monkeypatch.setenv("TEST_IOS_COMMANDS", str(command_path)) + group = click.Group() + + register_cli_plugins( + group, + entry_point_group=IOS_CLI_PLUGIN_GROUP, + environment_variable="TEST_IOS_COMMANDS", + ) + + assert group.commands["duplicate"] is not installed_command + result = CliRunner().invoke(group, ["duplicate"]) + assert result.exit_code == 0 + assert "environment command ran" in result.output + assert "the command name is already registered" in caplog.text + assert "ios-plugin 1.0 (ios_plugin:cli)" in caplog.text + + +def test_the_mvt_cli_gets_the_neutral_commands_and_no_platform_command( + monkeypatch, restore_cli_commands +): + @click.command() + def shared_package(): + click.echo("shared command ran") + + @click.command() + def ios_package(): + pass + + def entry_points(*, group): + if group == NEUTRAL_CLI_PLUGIN_GROUP: + return [_entry_point("shared-package", "shared_plugin:cli", shared_package)] + if group == IOS_CLI_PLUGIN_GROUP: + return [_entry_point("ios-package", "ios_plugin:cli", ios_package)] + return [] + + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + entry_points, + ) + + register_cli_plugins( + mvt_cli, + entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP, + environment_variable=MVT_CUSTOM_COMMANDS_ENV, + ) + + assert "ios-package" not in mvt_cli.commands + result = CliRunner().invoke(mvt_cli, [*OFFLINE, "shared-package"]) + assert result.exit_code == 0 + assert "shared command ran" in result.output + + +def test_builtin_mvt_command_wins_collision_with_neutral_command( + monkeypatch, caplog, restore_cli_commands +): + @click.command() + def neutral_version(): + pass + + def entry_points(*, group): + if group == NEUTRAL_CLI_PLUGIN_GROUP: + return [ + _entry_point( + "version", + "neutral_plugin:cli", + neutral_version, + distribution="neutral-plugin", + ) + ] + return [] + + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + entry_points, + ) + builtin_version = mvt_cli.commands["version"] + + register_cli_plugins( + mvt_cli, + entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP, + environment_variable=MVT_CUSTOM_COMMANDS_ENV, + ) + + assert mvt_cli.commands["version"] is builtin_version + assert "the command name is already registered" in caplog.text + assert "neutral-plugin 1.0 (neutral_plugin:cli)" in caplog.text + + +def test_broken_neutral_plugin_does_not_break_the_mvt_cli( + monkeypatch, restore_cli_commands +): + def entry_points(*, group): + if group == NEUTRAL_CLI_PLUGIN_GROUP: + return [ + _entry_point( + "broken", + "broken_plugin:cli", + exception=RuntimeError("missing dependency"), + distribution="broken-plugin", + ) + ] + return [] + + monkeypatch.setattr( + "mvt.common.cli_plugins.importlib.metadata.entry_points", + entry_points, + ) + + register_cli_plugins( + mvt_cli, + entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP, + environment_variable=MVT_CUSTOM_COMMANDS_ENV, + ) + + assert isinstance(mvt_cli.commands["broken"], BrokenPluginCommand) + result = CliRunner().invoke(mvt_cli, [*OFFLINE, "version"]) + assert result.exit_code == 0 diff --git a/tests/conftest.py b/tests/conftest.py index c89f629f..06a890a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,11 @@ import os import pytest +from mvt.common.cli_plugins import ( + MVT_ANDROID_CUSTOM_COMMANDS_ENV, + MVT_CUSTOM_COMMANDS_ENV, + MVT_IOS_CUSTOM_COMMANDS_ENV, +) from mvt.common.indicators import Indicators from .artifacts.generate_stix import generate_test_stix_file @@ -58,3 +63,33 @@ def indicators_factory(indicator_file): return ind return f + + +@pytest.fixture() +def restore_cli_commands(monkeypatch): + """Keep the external commands a test registers out of the next test. + + Each CLI group is a module-level object shared by every test, so a test + registering plugin or environment commands on one has to put it back. The + groups are imported here rather than at the top of the file, so that + collecting the tests does not import three CLIs for the sake of one + fixture. + """ + from mvt.android.cli import cli as android_cli + from mvt.cli import cli as neutral_cli + from mvt.ios.cli import cli as ios_cli + + groups = (neutral_cli, ios_cli, android_cli) + for variable in ( + MVT_CUSTOM_COMMANDS_ENV, + MVT_IOS_CUSTOM_COMMANDS_ENV, + MVT_ANDROID_CUSTOM_COMMANDS_ENV, + ): + monkeypatch.delenv(variable, raising=False) + originals = [dict(group.commands) for group in groups] + yield + for group, commands in zip(groups, originals): + group.commands.clear() + group.commands.update(commands) + if hasattr(group, "_mvt_external_command_sources"): + delattr(group, "_mvt_external_command_sources") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..7a5cb51e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,49 @@ +# 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/ + +from click.testing import CliRunner + +from mvt.cli import cli +from mvt.common.updates import IndicatorsUpdates +from mvt.common.version import MVT_VERSION + +# Keep the banner of the group callback from checking for updates online. +OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"] + + +class TestMvtCommand: + def test_running_mvt_alone_shows_the_logo_and_the_commands(self): + result = CliRunner().invoke(cli, OFFLINE) + + assert result.exit_code == 0 + logo_at = result.output.index("Mobile Verification Toolkit") + usage_at = result.output.index("Usage:") + assert logo_at < usage_at + assert "mvt-ios" in result.output and "mvt-android" in result.output + + def test_help_reminds_where_the_analysis_runs(self): + result = CliRunner().invoke(cli, ["--help"]) + + assert result.exit_code == 0 + assert "mvt-ios" in result.output + assert "mvt-android" in result.output + assert "check-*" in result.output + + def test_version_prints_the_installed_version(self): + result = CliRunner().invoke(cli, [*OFFLINE, "version"]) + + assert result.exit_code == 0 + assert f"Version: {MVT_VERSION}" in result.output + + def test_download_iocs_updates_the_indicators(self, monkeypatch): + updates = [] + monkeypatch.setattr( + IndicatorsUpdates, "update", lambda self: updates.append(self) + ) + + result = CliRunner().invoke(cli, [*OFFLINE, "download-iocs"]) + + assert result.exit_code == 0 + assert len(updates) == 1 diff --git a/tests/test_cli_entry_points.py b/tests/test_cli_entry_points.py index f5834ace..1b514138 100644 --- a/tests/test_cli_entry_points.py +++ b/tests/test_cli_entry_points.py @@ -10,10 +10,20 @@ import click import pytest import mvt.android +import mvt.cli import mvt.ios from mvt.android.cli import cli as android_cli from mvt.android.cli import main as android_main -from mvt.common.cli_plugins import ANDROID_CLI_PLUGIN_GROUP, IOS_CLI_PLUGIN_GROUP +from mvt.cli import cli as mvt_cli +from mvt.cli import main as mvt_main +from mvt.common.cli_plugins import ( + ANDROID_CLI_PLUGIN_GROUP, + IOS_CLI_PLUGIN_GROUP, + MVT_ANDROID_CUSTOM_COMMANDS_ENV, + MVT_CUSTOM_COMMANDS_ENV, + MVT_IOS_CUSTOM_COMMANDS_ENV, + NEUTRAL_CLI_PLUGIN_GROUP, +) from mvt.ios.cli import cli as ios_cli from mvt.ios.cli import main as ios_main @@ -39,23 +49,32 @@ def cli(): """ PROGRAMS = { - "mvt-ios": (mvt.ios, ios_cli, IOS_CLI_PLUGIN_GROUP), - "mvt-android": (mvt.android, android_cli, ANDROID_CLI_PLUGIN_GROUP), + "mvt": (mvt.cli, mvt_cli, NEUTRAL_CLI_PLUGIN_GROUP, MVT_CUSTOM_COMMANDS_ENV), + "mvt-ios": (mvt.ios, ios_cli, IOS_CLI_PLUGIN_GROUP, MVT_IOS_CUSTOM_COMMANDS_ENV), + "mvt-android": ( + mvt.android, + android_cli, + ANDROID_CLI_PLUGIN_GROUP, + MVT_ANDROID_CUSTOM_COMMANDS_ENV, + ), } +CASE_SUMMARY_COMMAND = """ +import click -@pytest.fixture -def restore_cli_commands(): - """Undo the plugin registration main() performs on the shared CLI groups.""" - originals = { - program: dict(group.commands) for program, (_, group, _) in PROGRAMS.items() - } - yield - for program, (_, group, _) in PROGRAMS.items(): - group.commands.clear() - group.commands.update(originals[program]) - if hasattr(group, "_mvt_external_command_sources"): - delattr(group, "_mvt_external_command_sources") + +@click.command("case-summary") +def cli(): + click.echo("case summary ran") +""" + +# The entry-point group of another program, for each program: no group may add +# its commands to a CLI other than its own. +OTHER_PROGRAMS_GROUP = { + "mvt": IOS_CLI_PLUGIN_GROUP, + "mvt-ios": NEUTRAL_CLI_PLUGIN_GROUP, + "mvt-android": NEUTRAL_CLI_PLUGIN_GROUP, +} def _install_fixture_entry_point(monkeypatch, entry_point_group, command): @@ -92,7 +111,7 @@ def _offline_argv(program, *arguments): def test_main_registers_installed_plugins_before_running_the_cli( program, monkeypatch, capsys, restore_cli_commands ): - package, group, entry_point_group = PROGRAMS[program] + package, group, entry_point_group, _ = PROGRAMS[program] @click.command() def fixture_command(): @@ -113,7 +132,7 @@ def test_main_registers_installed_plugins_before_running_the_cli( def test_main_completes_plugin_command_names( program, monkeypatch, capsys, restore_cli_commands ): - package, _, entry_point_group = PROGRAMS[program] + package, _, entry_point_group, _ = PROGRAMS[program] @click.command() def fixture_command(): @@ -136,17 +155,9 @@ def test_main_completes_plugin_command_names( def test_main_still_loads_commands_from_a_file( program, monkeypatch, capsys, tmp_path, restore_cli_commands ): - package, _, entry_point_group = PROGRAMS[program] + package, _, entry_point_group, _ = PROGRAMS[program] command_path = tmp_path / "case_summary.py" - command_path.write_text( - "import click\n" - "\n" - "\n" - '@click.command("case-summary")\n' - "def cli():\n" - ' click.echo("case summary ran")\n', - encoding="utf-8", - ) + command_path.write_text(CASE_SUMMARY_COMMAND, encoding="utf-8") _install_fixture_entry_point( monkeypatch, entry_point_group, click.Command("unused") ) @@ -163,12 +174,66 @@ def test_main_still_loads_commands_from_a_file( assert "case summary ran" in capsys.readouterr().out +@pytest.mark.parametrize("program", sorted(PROGRAMS)) +def test_main_loads_commands_from_the_environment_variable( + program, monkeypatch, capsys, tmp_path, restore_cli_commands +): + # Each CLI reads its own variable, so a main() reading another CLI's would + # go unnoticed without this. + package, _, _, environment_variable = PROGRAMS[program] + command_path = tmp_path / "case_summary.py" + command_path.write_text(CASE_SUMMARY_COMMAND, encoding="utf-8") + monkeypatch.setenv(environment_variable, str(command_path)) + monkeypatch.setattr(sys, "argv", _offline_argv(program, "case-summary")) + + with pytest.raises(SystemExit) as exit_info: + package.main() + + assert exit_info.value.code == 0 + assert "case summary ran" in capsys.readouterr().out + + +@pytest.mark.parametrize("program", sorted(PROGRAMS)) +def test_main_ignores_the_entry_point_groups_of_the_other_programs( + program, monkeypatch, capsys, restore_cli_commands +): + package, group, _, _ = PROGRAMS[program] + _install_fixture_entry_point( + monkeypatch, + OTHER_PROGRAMS_GROUP[program], + click.Command(FIXTURE_COMMAND_NAME), + ) + monkeypatch.setattr(sys, "argv", _offline_argv(program, "--help")) + + with pytest.raises(SystemExit) as exit_info: + package.main() + + assert exit_info.value.code == 0 + assert FIXTURE_COMMAND_NAME not in group.commands + assert FIXTURE_COMMAND_NAME not in capsys.readouterr().out + + def test_the_console_script_targets_are_importable(): - # [project.scripts] points at these, so they must stay on the packages. + # [project.scripts] points at these, so they must stay where they are. + assert mvt.cli.main is mvt_main assert mvt.ios.main is ios_main assert mvt.android.main is android_main +def test_importing_mvt_does_not_import_a_cli(tmp_path): + # The mvt package deliberately re-exports nothing of mvt.cli, so that + # importing MVT stays cheap and free of side effects. + result = run_isolated_python( + "import sys\n" + "import mvt\n" + "print('imported a cli' if 'mvt.cli' in sys.modules else 'imported mvt')\n", + home=tmp_path / "home", + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "imported mvt" + + def test_importing_mvt_does_not_run_installed_plugins(tmp_path): site_path = write_cli_plugin_distribution( tmp_path / "site", IOS_CLI_PLUGIN_GROUP, MARKER_PLUGIN_TEMPLATE diff --git a/tests/test_completion.py b/tests/test_completion.py index 48c0177e..08ad3ce3 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -6,56 +6,60 @@ from click.testing import CliRunner from mvt.android.cli import cli as android_cli +from mvt.cli import cli as mvt_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"]) + result = runner.invoke(mvt_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 "Shell completion for mvt, mvt-ios and mvt-android" in result.output + assert "mvt completion bash > ~/.mvt-complete.bash" in result.output assert "Mobile Verification Toolkit" not in result.output - def test_completion_prints_bash_script(self): + def test_completion_bash_script_covers_every_cli(self): runner = CliRunner() - result = runner.invoke(ios_cli, ["completion", "bash"]) + result = runner.invoke(mvt_cli, ["completion", "bash"]) assert result.exit_code == 0 + assert "_MVT_COMPLETE=bash_complete" in result.output assert "_MVT_IOS_COMPLETE=bash_complete" in result.output + assert "_MVT_ANDROID_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): + def test_completion_fish_script_covers_every_cli(self): runner = CliRunner() - result = runner.invoke(android_cli, ["completion", "fish"]) + result = runner.invoke(mvt_cli, ["completion", "fish"]) assert result.exit_code == 0 - assert "_MVT_ANDROID_COMPLETE=fish_complete" in result.output + assert "complete --no-files --command mvt-ios" in result.output assert "complete --no-files --command mvt-android" in result.output + assert "complete --no-files --command mvt " 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"]) + result = runner.invoke(mvt_cli, ["completion", "bash", "--install"]) assert result.exit_code == 0 - script_path = tmp_path / ".mvt-ios-complete.bash" + script_path = tmp_path / ".mvt-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" - ) + script = script_path.read_text(encoding="utf-8") + assert "_MVT_COMPLETE=bash_complete" in script + assert "_MVT_IOS_COMPLETE=bash_complete" in script + assert "_MVT_ANDROID_COMPLETE=bash_complete" in script bashrc = bashrc_path.read_text(encoding="utf-8") assert "[ -f" in bashrc - assert ".mvt-ios-complete.bash" in bashrc + assert ".mvt-complete.bash" in bashrc - result = runner.invoke(ios_cli, ["completion", "bash", "--install"]) + result = runner.invoke(mvt_cli, ["completion", "bash", "--install"]) assert result.exit_code == 0 assert bashrc_path.read_text(encoding="utf-8") == bashrc @@ -65,14 +69,30 @@ class TestCompletionCommand: monkeypatch.setenv("HOME", str(tmp_path)) runner = CliRunner() - result = runner.invoke(android_cli, ["completion", "fish", "--install"]) + result = runner.invoke(mvt_cli, ["completion", "fish", "--install"]) assert result.exit_code == 0 - script_path = ( - tmp_path / ".config" / "fish" / "completions" / "mvt-android.fish" - ) + script_path = tmp_path / ".config" / "fish" / "conf.d" / "mvt-completion.fish" assert script_path.exists() - assert "_MVT_ANDROID_COMPLETE=fish_complete" in script_path.read_text( - encoding="utf-8" - ) + script = script_path.read_text(encoding="utf-8") + assert "_MVT_COMPLETE=fish_complete" in script + assert "_MVT_IOS_COMPLETE=fish_complete" in script + assert "_MVT_ANDROID_COMPLETE=fish_complete" in script assert not (tmp_path / ".fishrc").exists() + assert not (tmp_path / ".bashrc").exists() + assert not (tmp_path / ".zshrc").exists() + + def test_completion_install_without_shell_is_a_usage_error(self): + runner = CliRunner() + result = runner.invoke(mvt_cli, ["completion", "--install"]) + + assert result.exit_code == 2 + assert "A shell is required when using --install." in result.output + + def test_completion_is_not_a_command_of_the_platform_clis(self): + runner = CliRunner() + + assert "completion" not in ios_cli.commands + assert "completion" not in android_cli.commands + assert runner.invoke(ios_cli, ["completion"]).exit_code == 2 + assert runner.invoke(android_cli, ["completion"]).exit_code == 2 From dcfd500112399f77df71719d3daa0ba880cc301c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:15 +0200 Subject: [PATCH 056/109] Add plugin update checking and a plugins command (#898) * Add plugin update checking Report available updates to the installed MVT plugin packages in the startup banner, for plugins installed from a package index and for plugins installed directly from a repository. Repository installs pinned to a commit or a tag are never reported as outdated. MVT only prints the command which upgrades a plugin. Installing the update stays a deliberate choice of the analyst. The check runs at most once every twelve hours, and in between prints the findings of the latest check which still apply to what is installed. Nothing about the check can interrupt a running command: the parts of the suggested command come from package metadata and are quoted for the shell, the repository query refuses to prompt for credentials and never passes metadata as a git option, and a corrupt or stale cache is discarded rather than trusted. * Add a plugins command to list installed plugins and check updates Add a "plugins" command to the platform-neutral mvt command. "plugins list" shows every installed plugin package with its version, where it was installed from, how many forensic modules it contributes and which commands it adds. "plugins check-updates" checks for updates immediately, without waiting for the automatic check, and prints the command which upgrades a plugin instead of installing anything. It lives on mvt only. The packages it lists extend mvt-ios and mvt-android too, but auditing them is not the job of a command which analyses one platform, and the two platform CLIs should not carry commands which are not about an acquisition. The command is registered as a built-in, before any external command, so that an installed package cannot replace this audit surface. --- README.md | 2 +- docs/development/custom_commands.md | 9 +- docs/development/index.md | 3 + docs/development/plugins.md | 116 +++++ mkdocs.yml | 1 + src/mvt/cli.py | 6 + src/mvt/common/cmd_plugins.py | 205 ++++++++ src/mvt/common/help.py | 5 + src/mvt/common/logo.py | 181 ++++--- src/mvt/common/module_loader.py | 29 +- src/mvt/common/updates.py | 419 +++++++++++++++- tests/common/test_cmd_plugins.py | 236 +++++++++ tests/common/test_plugin_updates.py | 711 ++++++++++++++++++++++++++++ 13 files changed, 1849 insertions(+), 74 deletions(-) create mode 100644 docs/development/plugins.md create mode 100644 src/mvt/common/cmd_plugins.py create mode 100644 tests/common/test_cmd_plugins.py create mode 100644 tests/common/test_plugin_updates.py diff --git a/README.md b/README.md index 5f00600e..54cccca1 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ For alternative installation options and known issues, please refer to the [docu ## Usage -MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version`, `completion` and `download-iocs` (`version` and `download-iocs` remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/) +MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version`, `completion`, `plugins` and `download-iocs` (`version` and `download-iocs` remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/) ### Shell completion diff --git a/docs/development/custom_commands.md b/docs/development/custom_commands.md index 19cdb85c..71ac623a 100644 --- a/docs/development/custom_commands.md +++ b/docs/development/custom_commands.md @@ -60,7 +60,8 @@ pipx inject mvt my-mvt-plugin ``` When MVT is installed in an active virtual environment, install the plugin with -`pip` in that environment. +`pip` in that environment. `mvt plugins list` shows the installed packages and +the commands they add, see [Managing Plugins](plugins.md). Command packages that need their own settings, such as an API key, should store them in a namespaced [plugin configuration file](plugin_configuration.md) @@ -69,9 +70,9 @@ rather than in MVT's own `config.yaml`. ### Commands on `mvt` The `mvt` command hosts what belongs to neither platform: `version`, -`completion` and `download-iocs`. A plugin command which is not about the -acquisition of one platform, such as one which configures the plugin or -synchronizes the indicators it uses, belongs there too, in the +`completion`, `plugins` and `download-iocs`. A plugin command which is not +about the acquisition of one platform, such as one which configures the plugin +or synchronizes the indicators it uses, belongs there too, in the `mvt.cli_plugins` group: ```toml diff --git a/docs/development/index.md b/docs/development/index.md index c2b84646..692a2ab6 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -214,6 +214,9 @@ module came from: - When a command runs with an `--output` folder, the `command.log` file records one line per module source with the source's version or hash and the list of modules loaded from it. +- `mvt plugins list` lists the installed packages, where each of them + was installed from and how many modules it contributes, see + [Managing Plugins](plugins.md). ## Profiling diff --git a/docs/development/plugins.md b/docs/development/plugins.md new file mode 100644 index 00000000..00859757 --- /dev/null +++ b/docs/development/plugins.md @@ -0,0 +1,116 @@ +# Managing Plugins + +Plugin packages extend MVT with additional +[forensic modules](index.md#custom-modules) and +[CLI commands](custom_commands.md). Because installed packages load +automatically, `mvt plugins` audits what is installed and checks whether +updates are available. The command lives on `mvt` only, although the packages +it lists extend `mvt-ios` and `mvt-android` too. + +## List Installed Plugins + +```bash +mvt plugins list +``` + +``` + Installed MVT plugins +┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┓ +┃ Name ┃ Version ┃ Origin ┃ Modules ┃ Commands ┃ +┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━┩ +│ mvt-plugin-example │ 1.2.0 │ pypi │ 4 │ summarize │ +│ mvt-plugin-research │ 0.1.0 │ git+3f9a1c7d │ 2 │ - │ +│ mvt-plugin-local │ 0.0.1 │ local │ 1 │ triage │ +└─────────────────────┴─────────┴──────────────┴─────────┴───────────┘ +``` + +The origin records where each package was installed from: `pypi` for a package +installed from a package index, `git+` for a package installed directly +from a repository, and `local` for a package installed from a local folder or +archive rather than from an index, including an editable development install. +The last two columns show how many forensic modules the package contributes and +which CLI commands it adds. + +A plugin whose modules cannot be imported is listed with `error` in the +`Modules` column rather than breaking the listing. + +## Check for Updates + +```bash +mvt plugins check-updates +``` + +``` +Plugin updates available: + mvt-plugin-example 1.2.0 → 1.3.0 + Upgrade with: pip install -U mvt-plugin-example + +MVT does not install plugin updates. Run the command above when you decide to +upgrade. +``` + +Packages installed from a package index are compared against the latest release +published for them. A package which was never published, for example a plugin +distributed only within an organization, is skipped silently. + +!!! note + + Packages shown with the `pypi` origin are compared against + [PyPI](https://pypi.org), whichever index they were installed from. A + plugin installed from a private index under a name which also exists on + PyPI is therefore compared against the unrelated public package of that + name. Give plugins published to a private index a name which is not taken + on PyPI, and treat an unexpected update suggestion as a reason to check + where the package would come from. + +!!! warning + + MVT never installs or upgrades a plugin itself, it only prints the command + which does. Upgrading a plugin in the middle of an investigation changes + the modules producing the results, and a plugin runs as trusted code inside + the MVT process, so pulling in a new version is a decision for the analyst + to make deliberately and not a side effect of running a check. + +## Automatic Update Checks + +MVT also reports available plugin updates in the banner printed when a command +starts: + +``` + MVT - Mobile Verification Toolkit + + https://mvt.re + Version: 2026.7.29 + + Plugin updates available: + mvt-plugin-example 1.2.0 → 1.3.0 (pip install -U mvt-plugin-example) +``` + +This check runs at most once every 12 hours. In between checks MVT prints the +findings of the latest check without contacting anything, so a plugin update +stays visible without a lookup on every command. The +`mvt plugins check-updates` command checks immediately, regardless of when the +last check happened. + +The automatic check is skipped when the `--disable-update-check` option is +used, when `NETWORK_ACCESS_ALLOWED` is disabled in the MVT configuration, and +when no plugins are installed. + +## Plugins Installed From a Repository + +A plugin installed with `pip install "mvt-plugin-example @ git+"` is +checked by asking the remote repository which commit the installed revision +points at now. MVT runs git and ssh in batch mode, so a repository which needs +credentials MVT does not already have fails the check instead of prompting for +them. The check is skipped silently when git is not available, when the +repository cannot be reached, and when access to it is denied. + +How the plugin was installed decides what an update means: + +- A plugin installed from a branch is reported as outdated when the branch has + moved past the installed commit. +- A plugin installed from a specific commit or a tag is pinned. It is never + reported as outdated, however far the branch it came from moves on. + +Pinning a plugin to a commit or a tag is therefore the way to keep the modules +used across an investigation stable. diff --git a/mkdocs.yml b/mkdocs.yml index 6250cdce..66def17e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,4 +53,5 @@ nav: - Development Instructions: "development/index.md" - Custom CLI Commands: "development/custom_commands.md" - Plugin Configuration: "development/plugin_configuration.md" + - Managing Plugins: "development/plugins.md" - License: "license.md" diff --git a/src/mvt/cli.py b/src/mvt/cli.py index a2e48dd6..3ba6602e 100644 --- a/src/mvt/cli.py +++ b/src/mvt/cli.py @@ -11,6 +11,7 @@ from mvt.common.cli_plugins import ( load_cli_commands_option, register_cli_plugins, ) +from mvt.common.cmd_plugins import plugins from mvt.common.completion import completion from mvt.common.help import ( HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, @@ -84,6 +85,11 @@ def version(): return +# The plugins command is registered as a built-in command, before any external +# command, so that an installed package can never replace it. +cli.add_command(plugins) + + # ============================================================================== # Entry point of the mvt console script # ============================================================================== diff --git a/src/mvt/common/cmd_plugins.py b/src/mvt/common/cmd_plugins.py new file mode 100644 index 00000000..21619eba --- /dev/null +++ b/src/mvt/common/cmd_plugins.py @@ -0,0 +1,205 @@ +# 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 importlib.metadata +import logging +from typing import Optional + +import click +from rich.console import Console +from rich.table import Table + +from .cli_plugins import ( + ANDROID_CLI_PLUGIN_GROUP, + IOS_CLI_PLUGIN_GROUP, + NEUTRAL_CLI_PLUGIN_GROUP, +) +from .config import settings +from .help import ( + HELP_MSG_PLUGINS, + HELP_MSG_PLUGINS_CHECK_UPDATES, + HELP_MSG_PLUGINS_LIST, +) +from .module import MVTModule +from .module_loader import MODULES_ENTRY_POINT_GROUP, distribution_direct_url +from .updates import ( + SHORT_COMMIT_LENGTH, + PluginUpdates, + installed_plugin_distributions, +) + +log = logging.getLogger(__name__) + +CLI_PLUGIN_GROUPS = ( + IOS_CLI_PLUGIN_GROUP, + ANDROID_CLI_PLUGIN_GROUP, + NEUTRAL_CLI_PLUGIN_GROUP, +) +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) + + +def _entry_points(group: str) -> list[importlib.metadata.EntryPoint]: + try: + return list(importlib.metadata.entry_points(group=group)) + except Exception as exc: + log.warning("Unable to discover the entry points in group %s: %s", group, exc) + return [] + + +def _entry_point_distribution( + entry_point: importlib.metadata.EntryPoint, +) -> Optional[str]: + dist = getattr(entry_point, "dist", None) + if dist is None: + return None + try: + return dist.name + except Exception: + return None + + +def _distribution_version(dist: importlib.metadata.Distribution) -> str: + try: + return dist.version or "unknown" + except Exception: + return "unknown" + + +def _distribution_origin(dist: importlib.metadata.Distribution) -> str: + """Describe where a plugin package was installed from.""" + direct_url = distribution_direct_url(dist) + if direct_url is None: + return "pypi" + + vcs_info = direct_url.get("vcs_info") + if isinstance(vcs_info, dict): + commit = vcs_info.get("commit_id") or "" + if commit: + return f"git+{commit[:SHORT_COMMIT_LENGTH]}" + return "git" + + return "local" + + +def _contributed_modules( + entry_points: list[importlib.metadata.EntryPoint], distribution: str +) -> str: + """Count the forensic modules a plugin package contributes. + + Entry points are resolved the way MVT resolves them when it loads + modules, but a broken entry point is reported instead of raising: listing + the installed plugins must work even when one of them is faulty. + """ + count = 0 + broken = False + + for entry_point in entry_points: + if _entry_point_distribution(entry_point) != distribution: + continue + try: + loaded = entry_point.load() + if callable(loaded) and not isinstance(loaded, type): + loaded = loaded() + count += sum( + 1 + for module in loaded + if isinstance(module, type) and issubclass(module, MVTModule) + ) + except (Exception, SystemExit) as exc: + log.debug( + "Unable to load the modules of entry point %s (%s): %s", + entry_point.name, + entry_point.value, + exc, + ) + broken = True + + if broken: + return f"{count} (error)" if count else "error" + + return str(count) + + +def _contributed_commands( + entry_points: list[importlib.metadata.EntryPoint], distribution: str +) -> str: + names = { + entry_point.name + for entry_point in entry_points + if _entry_point_distribution(entry_point) == distribution + } + + return ", ".join(sorted(names)) if names else "-" + + +@click.group("plugins", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_PLUGINS) +def plugins() -> None: + pass + + +@plugins.command("list", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_PLUGINS_LIST) +def list_plugins() -> None: + distributions = installed_plugin_distributions() + if not distributions: + click.echo("No MVT plugins are installed.") + return + + module_entry_points = _entry_points(MODULES_ENTRY_POINT_GROUP) + command_entry_points = [] + for group in CLI_PLUGIN_GROUPS: + command_entry_points.extend(_entry_points(group)) + + table = Table(title="Installed MVT plugins") + table.add_column("Name", style="bold") + table.add_column("Version") + table.add_column("Origin") + table.add_column("Modules", justify="right") + table.add_column("Commands") + + for dist in distributions: + name = dist.name + table.add_row( + name, + _distribution_version(dist), + _distribution_origin(dist), + _contributed_modules(module_entry_points, name), + _contributed_commands(command_entry_points, name), + ) + + Console().print(table) + + +@plugins.command( + "check-updates", + context_settings=CONTEXT_SETTINGS, + help=HELP_MSG_PLUGINS_CHECK_UPDATES, + short_help="Check the installed plugins for updates", +) +def check_plugin_updates() -> None: + if not settings.NETWORK_ACCESS_ALLOWED: + click.echo( + "Network access is disabled, cannot check for plugin updates. " + "Enable NETWORK_ACCESS_ALLOWED in the MVT configuration to check." + ) + return + + if not installed_plugin_distributions(): + click.echo("No MVT plugins are installed.") + return + + findings = PluginUpdates().check() + if not findings: + click.echo("All plugins are up to date.") + return + + click.echo("Plugin updates available:") + for finding in findings: + click.echo(f" {finding['name']} {finding['installed']} → {finding['latest']}") + click.echo(f" Upgrade with: {finding['upgrade_command']}") + + click.echo( + "\nMVT does not install plugin updates. Run the command above when you " + "decide to upgrade." + ) diff --git a/src/mvt/common/help.py b/src/mvt/common/help.py index 4eccef14..13575e5c 100644 --- a/src/mvt/common/help.py +++ b/src/mvt/common/help.py @@ -24,6 +24,11 @@ HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check" HELP_MSG_COMPLETION = ( "Generate or install shell completion for mvt, mvt-ios and mvt-android" ) +HELP_MSG_PLUGINS = "Inspect the installed MVT plugin packages" +HELP_MSG_PLUGINS_LIST = "List the installed plugins and what they contribute to MVT" +HELP_MSG_PLUGINS_CHECK_UPDATES = ( + "Check the installed plugins for updates without installing them" +) # IOS Specific HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup" diff --git a/src/mvt/common/logo.py b/src/mvt/common/logo.py index 862479f6..96836a90 100644 --- a/src/mvt/common/logo.py +++ b/src/mvt/common/logo.py @@ -8,10 +8,125 @@ import logging import requests from rich import print as rich_print -from .updates import IndicatorsUpdates, MVTUpdates +from .config import settings +from .updates import ( + IndicatorsUpdates, + MVTUpdates, + PluginUpdates, + installed_plugin_distributions, +) from .version import MVT_VERSION +def _check_version_updates(log: logging.Logger) -> None: + try: + mvt_updates = MVTUpdates() + latest_version = mvt_updates.check() + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + rich_print( + "\t[bold]Note: Could not check for MVT updates.[/bold] " + "You may be working offline. Please update MVT regularly." + ) + except Exception as e: + log.error("Error encountered when trying to check latest MVT version: %s", e) + else: + if latest_version: + rich_print( + f"\t[bold]Version {latest_version} is available! " + "Upgrade mvt with `pip3 install -U mvt` or with `pipx upgrade mvt`[/bold]" + ) + + +def _check_indicator_updates(log: logging.Logger) -> None: + ioc_updates = IndicatorsUpdates() + + # Before proceeding, we check if we have downloaded an indicators index. + # If not, there's no point in proceeding with the updates check. + if ioc_updates.get_latest_update() == 0: + rich_print( + "\t[bold]You have not yet downloaded any indicators, check " + "the `download-iocs` command![/bold]" + ) + return + + # We only perform this check at a fixed frequency, in order to not + # overburden the user with too many lookups if the command is being run + # multiple times. + should_check, hours = ioc_updates.should_check() + if not should_check: + rich_print( + f"\tIndicators updates checked recently, next automatic check " + f"in {int(hours)} hours" + ) + return + + try: + ioc_to_update = ioc_updates.check() + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + rich_print( + "\t[bold]Note: Could not check for indicator updates.[/bold] " + "You may be working offline. Please update MVT indicators regularly." + ) + except Exception as e: + log.error("Error encountered when trying to check latest MVT indicators: %s", e) + else: + if ioc_to_update: + rich_print( + "\t[bold]There are updates to your indicators files! " + "Run the `download-iocs` command to update![/bold]" + ) + else: + rich_print("\tYour indicators files seem to be up to date.") + + +def _print_plugin_updates(findings: list) -> None: + if not findings: + return + + rich_print("\t[bold]Plugin updates available:[/bold]") + for finding in findings: + rich_print( + f"\t {finding['name']} {finding['installed']} → " + f"{finding['latest']} ({finding['upgrade_command']})" + ) + + +def _check_plugin_updates(log: logging.Logger) -> None: + if not settings.NETWORK_ACCESS_ALLOWED: + return + + # This runs on every command, so nothing here, including reading back what + # the latest check stored, may ever interrupt MVT. + try: + distributions = installed_plugin_distributions() + + # There is nothing to check when MVT was not extended with any plugin. + if not distributions: + return + + plugin_updates = PluginUpdates() + + # We only perform this check at a fixed frequency, in order to not + # overburden the user (and the plugin repositories) with too many + # lookups. In between checks we print the findings of the latest one, + # leaving out those which no longer apply to what is installed. + should_check, _ = plugin_updates.should_check() + if not should_check: + _print_plugin_updates(plugin_updates.current_findings(distributions)) + return + + findings = plugin_updates.check() + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + rich_print( + "\t[bold]Note: Could not check for plugin updates.[/bold] " + "You may be working offline. Please update your MVT plugins regularly." + ) + except Exception as e: + log.error("Error encountered when trying to check MVT plugin updates: %s", e) + else: + _print_plugin_updates(findings) + + def check_updates( disable_version_check: bool = False, disable_indicator_check: bool = False ) -> None: @@ -19,68 +134,16 @@ def check_updates( # First we check for MVT version updates. if not disable_version_check: - try: - mvt_updates = MVTUpdates() - latest_version = mvt_updates.check() - except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): - rich_print( - "\t[bold]Note: Could not check for MVT updates.[/bold] " - "You may be working offline. Please update MVT regularly." - ) - except Exception as e: - log.error( - "Error encountered when trying to check latest MVT version: %s", e - ) - else: - if latest_version: - rich_print( - f"\t[bold]Version {latest_version} is available! " - "Upgrade mvt with `pip3 install -U mvt` or with `pipx upgrade mvt`[/bold]" - ) + _check_version_updates(log) # Then we check for indicators files updates. if not disable_indicator_check: - ioc_updates = IndicatorsUpdates() + _check_indicator_updates(log) - # Before proceeding, we check if we have downloaded an indicators index. - # If not, there's no point in proceeding with the updates check. - if ioc_updates.get_latest_update() == 0: - rich_print( - "\t[bold]You have not yet downloaded any indicators, check " - "the `download-iocs` command![/bold]" - ) - return - - # We only perform this check at a fixed frequency, in order to not - # overburden the user with too many lookups if the command is being run - # multiple times. - should_check, hours = ioc_updates.should_check() - if not should_check: - rich_print( - f"\tIndicators updates checked recently, next automatic check " - f"in {int(hours)} hours" - ) - return - - try: - ioc_to_update = ioc_updates.check() - except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): - rich_print( - "\t[bold]Note: Could not check for indicator updates.[/bold] " - "You may be working offline. Please update MVT indicators regularly." - ) - except Exception as e: - log.error( - "Error encountered when trying to check latest MVT indicators: %s", e - ) - else: - if ioc_to_update: - rich_print( - "\t[bold]There are updates to your indicators files! " - "Run the `download-iocs` command to update![/bold]" - ) - else: - rich_print("\tYour indicators files seem to be up to date.") + # Finally we check for updates to the installed plugin packages. MVT never + # installs an update itself, it only reports the command which does. + if not disable_version_check: + _check_plugin_updates(log) def logo( diff --git a/src/mvt/common/module_loader.py b/src/mvt/common/module_loader.py index 956da4c3..37f447d9 100644 --- a/src/mvt/common/module_loader.py +++ b/src/mvt/common/module_loader.py @@ -179,21 +179,36 @@ def _module_key(module_class: type[MVTModule]) -> tuple[str, str]: return (source, module_class.__qualname__) +def distribution_direct_url(dist: importlib.metadata.Distribution) -> Optional[dict]: + """Return the PEP 610 direct URL metadata of a distribution, if recorded. + + Packages installed from an index have no direct URL metadata, while + packages installed directly from a repository or from a local folder + record where they were installed from in ``direct_url.json``. + """ + try: + direct_url_text = dist.read_text("direct_url.json") + if not direct_url_text: + return None + direct_url = json.loads(direct_url_text) + return direct_url if isinstance(direct_url, dict) else None + except Exception: + return None + + def _distribution_commit(dist: importlib.metadata.Distribution) -> Optional[str]: """Return the VCS commit a distribution was installed from, if recorded. Packages installed directly from a repository (``pip install git+...``) record the commit in ``direct_url.json`` (PEP 610). """ - try: - direct_url_text = dist.read_text("direct_url.json") - if not direct_url_text: - return None - commit = json.loads(direct_url_text).get("vcs_info", {}).get("commit_id") - return commit if isinstance(commit, str) else None - except Exception: + vcs_info = (distribution_direct_url(dist) or {}).get("vcs_info") + if not isinstance(vcs_info, dict): return None + commit = vcs_info.get("commit_id") + return commit if isinstance(commit, str) else None + def _entry_point_origin(entry_point: importlib.metadata.EntryPoint) -> ModuleOrigin: name = entry_point.name diff --git a/src/mvt/common/updates.py b/src/mvt/common/updates.py index 001a5c21..71e97cc4 100644 --- a/src/mvt/common/updates.py +++ b/src/mvt/common/updates.py @@ -3,8 +3,13 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import importlib.metadata +import json import logging import os +import re +import shlex +import subprocess from datetime import datetime from typing import Optional, Tuple @@ -12,14 +17,35 @@ import requests import yaml from packaging import version +from .cli_plugins import ( + ANDROID_CLI_PLUGIN_GROUP, + IOS_CLI_PLUGIN_GROUP, + NEUTRAL_CLI_PLUGIN_GROUP, +) from .config import settings from .indicators import MVT_DATA_FOLDER, MVT_INDICATORS_FOLDER +from .module_loader import MODULES_ENTRY_POINT_GROUP, distribution_direct_url from .version import MVT_VERSION log = logging.getLogger(__name__) # In hours. INDICATORS_CHECK_FREQUENCY = 12 +PLUGINS_CHECK_FREQUENCY = 12 + +# The entry-point groups a package can use to extend MVT. +PLUGIN_ENTRY_POINT_GROUPS = ( + MODULES_ENTRY_POINT_GROUP, + IOS_CLI_PLUGIN_GROUP, + ANDROID_CLI_PLUGIN_GROUP, + NEUTRAL_CLI_PLUGIN_GROUP, +) +SHORT_COMMIT_LENGTH = 8 +# The keys every cached finding has to carry to be printed. +FINDING_KEYS = ("name", "installed", "latest", "upgrade_command") +# Options which stop ssh from waiting for an answer nobody is there to give. +_SSH_BATCH_OPTIONS = ("-o", "BatchMode=yes", "-o", "ConnectTimeout=10") +_COMMIT_PATTERN = re.compile(r"\A[0-9a-f]{7,40}\Z") class MVTUpdates: @@ -38,6 +64,395 @@ class MVTUpdates: return "" +def installed_plugin_distributions() -> list[importlib.metadata.Distribution]: + """Return the installed distributions which extend MVT. + + A plugin package is any distribution registering at least one entry point + in the module or CLI command groups. Distributions are returned once each, + sorted by name. MVT itself is not a plugin and is never returned. + """ + distributions: dict[str, importlib.metadata.Distribution] = {} + + for group in PLUGIN_ENTRY_POINT_GROUPS: + try: + entry_points = importlib.metadata.entry_points(group=group) + except Exception as exc: + log.warning( + "Unable to discover installed plugin packages in entry-point " + "group %s: %s", + group, + exc, + ) + continue + + for entry_point in entry_points: + # Manually constructed entry points have no associated distribution. + dist = getattr(entry_point, "dist", None) + if dist is None: + continue + try: + name = dist.name + except Exception: + continue + if not name or name == "mvt": + continue + distributions.setdefault(name, dist) + + return [distributions[name] for name in sorted(distributions)] + + +def _is_usable_finding(finding: object) -> bool: + """Check that a cached finding carries everything needed to print it.""" + if not isinstance(finding, dict): + return False + + return all( + isinstance(finding.get(key), str) and finding.get(key) for key in FINDING_KEYS + ) + + +def _installed_revision( + dist: importlib.metadata.Distribution, origin: object +) -> Optional[str]: + """Return what a plugin's installed revision is right now. + + This is the value a finding recorded as installed when it was made, so a + finding can be compared against the current state of the installation + without looking anything up remotely. + """ + try: + if origin != "git": + return dist.version + + vcs_info = (distribution_direct_url(dist) or {}).get("vcs_info") + if not isinstance(vcs_info, dict): + return None + + commit = vcs_info.get("commit_id") or "" + return commit[:SHORT_COMMIT_LENGTH] or None + except Exception as e: + log.debug("Failed to read the installed revision of a plugin: %s", e) + return None + + +def _batch_mode_ssh_command(ssh_command: str) -> str: + """Return an ssh command line which cannot stop to ask a question. + + ssh keeps the first value it is given for a keyword, so the batch mode + options are inserted right after the ssh program, ahead of whatever the + analyst configured. Their remaining options, such as which key to use, + still apply. + """ + try: + arguments = shlex.split(ssh_command.strip()) + except ValueError: + arguments = [] + + if not arguments: + arguments = ["ssh"] + + return shlex.join([arguments[0], *_SSH_BATCH_OPTIONS, *arguments[1:]]) + + +def _revision_pins_commit(revision: str, commit: str) -> bool: + """Check whether a requested revision pins the installed commit.""" + candidate = revision.lower() + if not _COMMIT_PATTERN.match(candidate): + return False + + return commit.lower().startswith(candidate) + + +class PluginUpdates: + """Check for updates to the installed MVT plugin packages. + + MVT never installs or upgrades a plugin package itself. It only reports + the command which upgrades a plugin, leaving the analyst to decide when to + run it. + """ + + @property + def latest_check_path(self) -> str: + return os.path.join(MVT_DATA_FOLDER, "latest_plugins_check") + + @property + def findings_path(self) -> str: + return os.path.join(MVT_DATA_FOLDER, "plugin_updates.json") + + def _create_data_folder(self) -> None: + if not os.path.exists(MVT_DATA_FOLDER): + os.makedirs(MVT_DATA_FOLDER) + + def get_latest_check(self) -> int: + if not os.path.exists(self.latest_check_path): + return 0 + + # A corrupt or truncated timestamp only means the next check happens + # sooner. It must never stop MVT from running. + try: + with open(self.latest_check_path, "r", encoding="utf-8") as handle: + data = handle.read().strip() + if data: + return int(data) + except (OSError, ValueError) as e: + log.debug("Failed to read the time of the latest plugin check: %s", e) + + return 0 + + def set_latest_check(self) -> None: + self._create_data_folder() + timestamp = int(datetime.now().timestamp()) + with open(self.latest_check_path, "w", encoding="utf-8") as handle: + handle.write(str(timestamp)) + + def get_findings(self) -> list[dict]: + """ + Return the findings of the latest check, without checking again. + Returns an empty list if no check was ever performed. + """ + if not os.path.exists(self.findings_path): + return [] + + try: + with open(self.findings_path, "r", encoding="utf-8") as handle: + findings = json.load(handle) + except Exception as e: + log.debug("Failed to read the cached plugin updates: %s", e) + return [] + + if not isinstance(findings, list): + return [] + + # Anything which does not look like a finding is dropped rather than + # trusted: the cache is only a convenience. + return [finding for finding in findings if _is_usable_finding(finding)] + + def current_findings( + self, distributions: Optional[list[importlib.metadata.Distribution]] = None + ) -> list[dict]: + """ + Return the cached findings which still apply to what is installed. + Findings about a plugin which was upgraded or removed since the latest + check are dropped, so an update is never reported twice. + """ + if distributions is None: + distributions = installed_plugin_distributions() + + installed = {} + for dist in distributions: + try: + installed[dist.name] = dist + except Exception: + continue + + current = [] + for finding in self.get_findings(): + plugin = installed.get(finding["name"]) + if plugin is None: + continue + if ( + _installed_revision(plugin, finding.get("origin")) + != finding["installed"] + ): + continue + current.append(finding) + + return current + + def set_findings(self, findings: list[dict]) -> None: + self._create_data_folder() + with open(self.findings_path, "w", encoding="utf-8") as handle: + json.dump(findings, handle) + + def should_check(self) -> Tuple[bool, int]: + """ + Compare time of the latest plugins check with current time. + Returns bool and number of hours since the last check. + """ + now = datetime.now() + latest_check_ts = self.get_latest_check() + latest_check_dt = datetime.fromtimestamp(latest_check_ts) + + diff = now - latest_check_dt + diff_hours = divmod(diff.total_seconds(), 3600)[0] + + if diff_hours >= PLUGINS_CHECK_FREQUENCY: + return True, 0 + + return False, int(PLUGINS_CHECK_FREQUENCY - diff_hours) + + def _check_index_plugin(self, name: str, installed: str) -> Optional[dict]: + """Check a plugin installed from a package index for a newer release.""" + url = f"https://pypi.org/pypi/{name}/json" + try: + res = requests.get(url, timeout=settings.NETWORK_TIMEOUT) + except requests.exceptions.RequestException as e: + log.debug("Failed to check for updates to plugin %s: %s", name, e) + return None + + # Plugins which were never published to a public index are expected, + # and there is nothing to compare their version against. + if res.status_code == 404: + return None + + if res.status_code != 200: + log.debug( + "Failed to check for updates to plugin %s (error %d)", + name, + res.status_code, + ) + return None + + try: + latest = res.json().get("info", {}).get("version", "") + if not latest or version.parse(latest) <= version.parse(installed): + return None + except Exception as e: + log.debug("Failed to compare the versions of plugin %s: %s", name, e) + return None + + return { + "name": name, + "installed": installed, + "latest": latest, + "origin": "pypi", + # The name comes from package metadata, so the command MVT + # suggests is quoted rather than assumed to be shell-safe. + "upgrade_command": f"pip install -U {shlex.quote(name)}", + } + + def _git_ls_remote(self, url: str, revision: str) -> list[Tuple[str, str]]: + """Return the remote references matching a revision, if git allows it.""" + # Neither value is trusted: they are read from the metadata of an + # installed package and must not turn into git options. + if url.startswith("-") or revision.startswith("-"): + log.debug("Skipping the update check for the invalid repository %s", url) + return [] + + environment = dict(os.environ) + # Never prompt the analyst for repository credentials. git handles its + # own prompts, while ssh reads the terminal directly and only batch + # mode makes it fail instead of asking. + environment["GIT_TERMINAL_PROMPT"] = "0" + environment["GIT_SSH_COMMAND"] = _batch_mode_ssh_command( + environment.get("GIT_SSH_COMMAND", "") + ) + + try: + process = subprocess.run( + ["git", "ls-remote", url, revision], + capture_output=True, + stdin=subprocess.DEVNULL, + text=True, + env=environment, + timeout=settings.NETWORK_TIMEOUT, + check=False, + ) + except FileNotFoundError: + log.debug("Could not find git, skipping the update check for %s", url) + return [] + except (subprocess.SubprocessError, OSError) as e: + log.debug("Failed to query the repository %s: %s", url, e) + return [] + + if process.returncode != 0: + log.debug( + "Failed to query the repository %s (error %d): %s", + url, + process.returncode, + (process.stderr or "").strip(), + ) + return [] + + references = [] + for line in (process.stdout or "").splitlines(): + commit, _, reference = line.partition("\t") + if commit.strip() and reference.strip(): + references.append((commit.strip(), reference.strip())) + + return references + + def _check_repository_plugin( + self, name: str, direct_url: dict, vcs_info: dict + ) -> Optional[dict]: + """Check a plugin installed from a repository for a newer commit.""" + url = direct_url.get("url") or "" + installed = vcs_info.get("commit_id") or "" + revision = vcs_info.get("requested_revision") or "" + if not url or not installed: + return None + + # A plugin installed from a commit is pinned and never goes out of + # date, no matter what the branch it came from does next. + if revision and _revision_pins_commit(revision, installed): + return None + + latest = "" + wanted_reference = f"refs/heads/{revision}" if revision else "HEAD" + for commit, reference in self._git_ls_remote(url, revision or "HEAD"): + # Tags are pinned installs too. + if reference.startswith("refs/tags/"): + return None + if reference == wanted_reference: + latest = commit + + if not latest or latest == installed: + return None + + requirement = f"{name} @ git+{url}" + if revision: + requirement += f"@{revision}" + + return { + "name": name, + "installed": installed[:SHORT_COMMIT_LENGTH], + "latest": latest[:SHORT_COMMIT_LENGTH], + "origin": "git", + # A repository URL and a branch name can both hold characters a + # shell would act on, so the requirement is quoted for the shell + # the analyst is going to paste the command into. + "upgrade_command": f"pip install -U {shlex.quote(requirement)}", + } + + def _check_distribution( + self, dist: importlib.metadata.Distribution + ) -> Optional[dict]: + try: + name = dist.name + installed = dist.version + except Exception as e: + log.debug("Failed to read the metadata of an installed plugin: %s", e) + return None + + direct_url = distribution_direct_url(dist) + if direct_url is None: + return self._check_index_plugin(name, installed) + + vcs_info = direct_url.get("vcs_info") + if isinstance(vcs_info, dict): + return self._check_repository_plugin(name, direct_url, vcs_info) + + # Plugins installed from a local folder, including editable installs, + # are maintained by the analyst and have nothing to check against. + return None + + def check(self) -> list[dict]: + """ + Check every installed plugin package for an available update. + Returns one entry per plugin which can be upgraded. + """ + findings = [] + for dist in installed_plugin_distributions(): + finding = self._check_distribution(dist) + if finding: + findings.append(finding) + + self.set_findings(findings) + self.set_latest_check() + + return findings + + class IndicatorsUpdates: def __init__(self) -> None: self.github_raw_url = "https://raw.githubusercontent.com/{}/{}/{}/{}" @@ -180,9 +595,7 @@ class IndicatorsUpdates: def _get_remote_file_latest_commit( self, owner: str, repo: str, branch: str, path: str ) -> int: - file_commit_url = ( - f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}&sha={branch}" - ) + file_commit_url = f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}&sha={branch}" try: res = requests.get(file_commit_url, timeout=5) except requests.exceptions.RequestException as e: diff --git a/tests/common/test_cmd_plugins.py b/tests/common/test_cmd_plugins.py new file mode 100644 index 00000000..adf93227 --- /dev/null +++ b/tests/common/test_cmd_plugins.py @@ -0,0 +1,236 @@ +import json +from types import SimpleNamespace + +import pytest +from click.testing import CliRunner + +from mvt.android.cli import cli as android_cli +from mvt.cli import cli as mvt_cli +from mvt.common.cli_plugins import ( + ANDROID_CLI_PLUGIN_GROUP, + IOS_CLI_PLUGIN_GROUP, + NEUTRAL_CLI_PLUGIN_GROUP, +) +from mvt.common.cmd_plugins import plugins +from mvt.common.module import MVTModule +from mvt.common.module_loader import MODULES_ENTRY_POINT_GROUP +from mvt.common.updates import PluginUpdates +from mvt.ios.cli import cli as ios_cli + + +class ExampleModule(MVTModule): + pass + + +class AnotherModule(MVTModule): + pass + + +class FakeDistribution: + def __init__(self, name, version="1.0.0", direct_url=None): + self.name = name + self.version = version + self.direct_url = direct_url + + def read_text(self, file_name): + if file_name == "direct_url.json" and self.direct_url is not None: + return json.dumps(self.direct_url) + return None + + +def _entry_point(name, distribution, modules=None, exception=None): + def load(): + if exception is not None: + raise exception + return modules + + return SimpleNamespace( + name=name, value="example_plugin:modules", dist=distribution, load=load + ) + + +def _run(command, arguments): + # Keep rich from wrapping the table while its content is being asserted. + return CliRunner().invoke(command, arguments, env={"COLUMNS": "200"}) + + +def _table_rows(output): + """Return the content of the table rows, without the header and the box.""" + return [ + [cell.strip() for cell in line.strip().strip("│").split("│")] + for line in output.splitlines() + if "│" in line + ] + + +def _table_header(output): + for line in output.splitlines(): + if "┃" in line: + return [cell.strip() for cell in line.strip().strip("┃").split("┃")] + return [] + + +def _install(monkeypatch, distributions, entry_points): + monkeypatch.setattr( + "mvt.common.cmd_plugins.installed_plugin_distributions", + lambda: distributions, + ) + monkeypatch.setattr( + "mvt.common.cmd_plugins.importlib.metadata.entry_points", + lambda *, group: entry_points.get(group, []), + ) + + +def test_plugins_is_a_builtin_command_of_the_mvt_cli_only(): + assert mvt_cli.commands["plugins"] is plugins + assert "plugins" not in ios_cli.commands + assert "plugins" not in android_cli.commands + + +def test_list_shows_what_every_plugin_contributes(monkeypatch): + index_plugin = FakeDistribution("example-plugin", version="1.2.0") + repository_plugin = FakeDistribution( + "repository-plugin", + version="0.1.0", + direct_url={ + "url": "https://example.org/plugin.git", + "vcs_info": {"vcs": "git", "commit_id": "b" * 40}, + }, + ) + local_plugin = FakeDistribution( + "local-plugin", + direct_url={"url": "file:///plugins", "dir_info": {"editable": True}}, + ) + _install( + monkeypatch, + [index_plugin, local_plugin, repository_plugin], + { + MODULES_ENTRY_POINT_GROUP: [ + _entry_point( + "example", index_plugin, modules=[ExampleModule, AnotherModule] + ), + _entry_point("local", local_plugin, modules=lambda: [ExampleModule]), + ], + IOS_CLI_PLUGIN_GROUP: [_entry_point("summarize", repository_plugin)], + ANDROID_CLI_PLUGIN_GROUP: [_entry_point("triage", local_plugin)], + NEUTRAL_CLI_PLUGIN_GROUP: [_entry_point("report", repository_plugin)], + }, + ) + + result = _run(plugins, ["list"]) + + assert result.exit_code == 0 + # Plugins are listed by name, with the modules and the commands each of + # them contributes. + assert _table_header(result.output) == [ + "Name", + "Version", + "Origin", + "Modules", + "Commands", + ] + assert _table_rows(result.output) == [ + ["example-plugin", "1.2.0", "pypi", "2", "-"], + ["local-plugin", "1.0.0", "local", "1", "triage"], + ["repository-plugin", "0.1.0", "git+bbbbbbbb", "0", "report, summarize"], + ] + + +def test_list_reports_a_broken_module_entry_point(monkeypatch): + plugin = FakeDistribution("broken-plugin") + _install( + monkeypatch, + [plugin], + { + MODULES_ENTRY_POINT_GROUP: [ + _entry_point( + "broken", plugin, exception=ImportError("missing dependency") + ) + ] + }, + ) + + result = _run(plugins, ["list"]) + + assert result.exit_code == 0 + assert _table_rows(result.output) == [ + ["broken-plugin", "1.0.0", "pypi", "error", "-"] + ] + + +def test_list_without_plugins(monkeypatch): + _install(monkeypatch, [], {}) + + result = _run(plugins, ["list"]) + + assert result.exit_code == 0 + assert result.output == "No MVT plugins are installed.\n" + + +def test_check_updates_prints_the_findings_and_ignores_the_throttle(monkeypatch): + findings = [ + { + "name": "example-plugin", + "installed": "1.0.0", + "latest": "1.2.0", + "origin": "pypi", + "upgrade_command": "pip install -U example-plugin", + } + ] + _install(monkeypatch, [FakeDistribution("example-plugin")], {}) + monkeypatch.setattr(PluginUpdates, "check", lambda self: findings) + monkeypatch.setattr( + PluginUpdates, + "should_check", + lambda self: pytest.fail("an explicit check must not be throttled"), + ) + + result = _run(plugins, ["check-updates"]) + + assert result.exit_code == 0 + assert "Plugin updates available:" in result.output + assert "example-plugin 1.0.0 → 1.2.0" in result.output + assert "Upgrade with: pip install -U example-plugin" in result.output + assert "MVT does not install plugin updates." in result.output + + +def test_check_updates_without_available_updates(monkeypatch): + _install(monkeypatch, [FakeDistribution("example-plugin")], {}) + monkeypatch.setattr(PluginUpdates, "check", lambda self: []) + + result = _run(plugins, ["check-updates"]) + + assert result.exit_code == 0 + assert "All plugins are up to date." in result.output + + +def test_check_updates_without_plugins(monkeypatch): + _install(monkeypatch, [], {}) + monkeypatch.setattr( + PluginUpdates, + "check", + lambda self: pytest.fail("nothing must be checked without plugins"), + ) + + result = _run(plugins, ["check-updates"]) + + assert result.exit_code == 0 + assert "No MVT plugins are installed." in result.output + + +def test_check_updates_without_network_access(monkeypatch): + monkeypatch.setattr("mvt.common.cmd_plugins.settings.NETWORK_ACCESS_ALLOWED", False) + monkeypatch.setattr( + "mvt.common.cmd_plugins.installed_plugin_distributions", + lambda: pytest.fail("plugins must not be listed without network access"), + ) + monkeypatch.setattr( + PluginUpdates, + "check", + lambda self: pytest.fail("nothing must be checked without network access"), + ) + + result = _run(plugins, ["check-updates"]) + + assert result.exit_code == 0 + assert "Network access is disabled" in result.output diff --git a/tests/common/test_plugin_updates.py b/tests/common/test_plugin_updates.py new file mode 100644 index 00000000..9c45ceaf --- /dev/null +++ b/tests/common/test_plugin_updates.py @@ -0,0 +1,711 @@ +import json +import shlex +from datetime import datetime, timedelta +from types import SimpleNamespace + +import pytest + +from mvt.common import logo +from mvt.common.cli_plugins import ( + ANDROID_CLI_PLUGIN_GROUP, + IOS_CLI_PLUGIN_GROUP, + NEUTRAL_CLI_PLUGIN_GROUP, +) +from mvt.common.module_loader import MODULES_ENTRY_POINT_GROUP +from mvt.common.updates import ( + MVTUpdates, + PluginUpdates, + installed_plugin_distributions, +) + + +REPOSITORY_URL = "https://example.org/plugin.git" +INSTALLED_COMMIT = "a" * 40 +REMOTE_COMMIT = "b" * 40 + + +class FakeDistribution: + def __init__(self, name, version="1.0.0", direct_url=None): + self.name = name + self.version = version + self.direct_url = direct_url + + def read_text(self, file_name): + if file_name == "direct_url.json" and self.direct_url is not None: + return json.dumps(self.direct_url) + return None + + +class FakeResponse: + def __init__(self, status_code=200, payload=None): + self.status_code = status_code + self.payload = payload or {} + + def json(self): + return self.payload + + +def _entry_point(name, distribution, value="plugin:modules"): + return SimpleNamespace(name=name, value=value, dist=distribution) + + +def _git_distribution(requested_revision=None, commit=INSTALLED_COMMIT): + vcs_info = {"vcs": "git", "commit_id": commit} + if requested_revision: + vcs_info["requested_revision"] = requested_revision + + return FakeDistribution( + "example-plugin", + direct_url={"url": REPOSITORY_URL, "vcs_info": vcs_info}, + ) + + +def _fake_git(stdout="", returncode=0, exception=None, calls=None): + def run(command, **kwargs): + if calls is not None: + calls.append((command, kwargs)) + if exception is not None: + raise exception + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr="") + + return run + + +@pytest.fixture +def data_folder(tmp_path, monkeypatch): + folder = tmp_path / "mvt-data" + monkeypatch.setattr("mvt.common.updates.MVT_DATA_FOLDER", str(folder)) + return folder + + +@pytest.fixture +def one_plugin(monkeypatch): + def install(distribution): + monkeypatch.setattr( + "mvt.common.updates.installed_plugin_distributions", + lambda: [distribution], + ) + return distribution + + return install + + +def test_installed_plugin_distributions_covers_every_plugin_group(monkeypatch): + zeta = FakeDistribution("zeta-plugin") + alpha = FakeDistribution("alpha-plugin") + neutral = FakeDistribution("neutral-plugin") + + def entry_points(*, group): + if group == MODULES_ENTRY_POINT_GROUP: + return [_entry_point("zeta", zeta), _entry_point("alpha", alpha)] + if group == IOS_CLI_PLUGIN_GROUP: + return [_entry_point("zeta-ios", zeta)] + if group == ANDROID_CLI_PLUGIN_GROUP: + return [_entry_point("alpha-android", alpha)] + if group == NEUTRAL_CLI_PLUGIN_GROUP: + return [_entry_point("shared", neutral)] + return [] + + monkeypatch.setattr( + "mvt.common.updates.importlib.metadata.entry_points", entry_points + ) + + distributions = installed_plugin_distributions() + + assert [distribution.name for distribution in distributions] == [ + "alpha-plugin", + "neutral-plugin", + "zeta-plugin", + ] + + +def test_installed_plugin_distributions_skips_mvt_and_orphan_entry_points(monkeypatch): + entry_points = [ + _entry_point("builtin", FakeDistribution("mvt")), + SimpleNamespace(name="orphan", value="plugin:modules", dist=None), + _entry_point("plugin", FakeDistribution("example-plugin")), + ] + monkeypatch.setattr( + "mvt.common.updates.importlib.metadata.entry_points", + lambda **kwargs: entry_points, + ) + + distributions = installed_plugin_distributions() + + assert [distribution.name for distribution in distributions] == ["example-plugin"] + + +def test_installed_plugin_distributions_survives_broken_metadata(monkeypatch, caplog): + def entry_points(*, group): + raise RuntimeError("invalid package metadata") + + monkeypatch.setattr( + "mvt.common.updates.importlib.metadata.entry_points", entry_points + ) + + assert installed_plugin_distributions() == [] + assert "Unable to discover installed plugin packages" in caplog.text + + +def test_index_plugin_update_is_reported(monkeypatch, data_folder, one_plugin): + one_plugin(FakeDistribution("example-plugin", version="1.0.0")) + monkeypatch.setattr( + "mvt.common.updates.requests.get", + lambda url, **kwargs: FakeResponse(payload={"info": {"version": "1.2.0"}}), + ) + + findings = PluginUpdates().check() + + assert findings == [ + { + "name": "example-plugin", + "installed": "1.0.0", + "latest": "1.2.0", + "origin": "pypi", + "upgrade_command": "pip install -U example-plugin", + } + ] + + +def test_index_plugin_queries_the_package_index_with_the_configured_timeout( + monkeypatch, data_folder, one_plugin +): + one_plugin(FakeDistribution("example-plugin")) + requests_made = [] + + def get(url, **kwargs): + requests_made.append((url, kwargs)) + return FakeResponse(payload={"info": {"version": "1.0.0"}}) + + monkeypatch.setattr("mvt.common.updates.requests.get", get) + monkeypatch.setattr("mvt.common.updates.settings.NETWORK_TIMEOUT", 3) + + PluginUpdates().check() + + assert requests_made == [ + ("https://pypi.org/pypi/example-plugin/json", {"timeout": 3}) + ] + + +def test_up_to_date_index_plugin_is_not_reported(monkeypatch, data_folder, one_plugin): + one_plugin(FakeDistribution("example-plugin", version="1.2.0")) + monkeypatch.setattr( + "mvt.common.updates.requests.get", + lambda url, **kwargs: FakeResponse(payload={"info": {"version": "1.2.0"}}), + ) + + assert PluginUpdates().check() == [] + + +def test_unpublished_plugin_is_skipped_silently(monkeypatch, data_folder, one_plugin): + one_plugin(FakeDistribution("private-plugin")) + monkeypatch.setattr( + "mvt.common.updates.requests.get", + lambda url, **kwargs: FakeResponse(status_code=404), + ) + + assert PluginUpdates().check() == [] + + +def test_repository_plugin_following_a_branch_is_reported( + monkeypatch, data_folder, one_plugin +): + one_plugin(_git_distribution(requested_revision="main")) + calls = [] + monkeypatch.setattr( + "mvt.common.updates.subprocess.run", + _fake_git(stdout=f"{REMOTE_COMMIT}\trefs/heads/main\n", calls=calls), + ) + monkeypatch.delenv("GIT_SSH_COMMAND", raising=False) + + findings = PluginUpdates().check() + + assert findings == [ + { + "name": "example-plugin", + "installed": "aaaaaaaa", + "latest": "bbbbbbbb", + "origin": "git", + "upgrade_command": ( + f"pip install -U 'example-plugin @ git+{REPOSITORY_URL}@main'" + ), + } + ] + command, options = calls[0] + assert command == ["git", "ls-remote", REPOSITORY_URL, "main"] + assert options["env"]["GIT_TERMINAL_PROMPT"] == "0" + # ssh asks the terminal for a passphrase or a host key unless it is told + # not to, which git itself cannot prevent. + assert options["env"]["GIT_SSH_COMMAND"] == ( + "ssh -o BatchMode=yes -o ConnectTimeout=10" + ) + + +def test_batch_mode_options_come_before_the_configured_ssh_options( + monkeypatch, data_folder, one_plugin +): + one_plugin(_git_distribution(requested_revision="main")) + calls = [] + monkeypatch.setattr( + "mvt.common.updates.subprocess.run", + _fake_git(stdout=f"{REMOTE_COMMIT}\trefs/heads/main\n", calls=calls), + ) + monkeypatch.setenv("GIT_SSH_COMMAND", "ssh -o BatchMode=no -i /home/analyst/key") + + PluginUpdates().check() + + # ssh uses the first value it is given for a keyword, so an analyst asking + # for prompts cannot bring them back, while their other options still + # apply. + assert calls[0][1]["env"]["GIT_SSH_COMMAND"] == ( + "ssh -o BatchMode=yes -o ConnectTimeout=10 -o BatchMode=no -i /home/analyst/key" + ) + + +def test_repository_plugin_without_a_revision_follows_the_default_branch( + monkeypatch, data_folder, one_plugin +): + one_plugin(_git_distribution()) + calls = [] + monkeypatch.setattr( + "mvt.common.updates.subprocess.run", + _fake_git(stdout=f"{REMOTE_COMMIT}\tHEAD\n", calls=calls), + ) + + findings = PluginUpdates().check() + + assert calls[0][0] == ["git", "ls-remote", REPOSITORY_URL, "HEAD"] + assert findings[0]["upgrade_command"] == ( + f"pip install -U 'example-plugin @ git+{REPOSITORY_URL}'" + ) + + +def test_hostile_revision_cannot_inject_into_the_upgrade_command( + monkeypatch, data_folder, one_plugin +): + revision = "main$(id)`id`;id" + one_plugin(_git_distribution(requested_revision=revision)) + monkeypatch.setattr( + "mvt.common.updates.subprocess.run", + _fake_git(stdout=f"{REMOTE_COMMIT}\trefs/heads/{revision}\n"), + ) + + upgrade_command = PluginUpdates().check()[0]["upgrade_command"] + + # Single quotes are the only quoting a shell does not expand anything in. + assert upgrade_command == ( + f"pip install -U 'example-plugin @ git+{REPOSITORY_URL}@{revision}'" + ) + assert shlex.split(upgrade_command) == [ + "pip", + "install", + "-U", + f"example-plugin @ git+{REPOSITORY_URL}@{revision}", + ] + + +def test_repository_plugin_with_an_option_like_url_is_skipped( + monkeypatch, data_folder, one_plugin +): + one_plugin( + FakeDistribution( + "example-plugin", + direct_url={ + "url": "--upload-pack=touch /tmp/mvt", + "vcs_info": {"vcs": "git", "commit_id": INSTALLED_COMMIT}, + }, + ) + ) + calls = [] + monkeypatch.setattr("mvt.common.updates.subprocess.run", _fake_git(calls=calls)) + + assert PluginUpdates().check() == [] + assert calls == [] + + +def test_repository_plugin_with_an_option_like_revision_is_skipped( + monkeypatch, data_folder, one_plugin +): + one_plugin(_git_distribution(requested_revision="--upload-pack=touch /tmp/mvt")) + calls = [] + monkeypatch.setattr("mvt.common.updates.subprocess.run", _fake_git(calls=calls)) + + assert PluginUpdates().check() == [] + assert calls == [] + + +def test_repository_plugin_at_the_latest_commit_is_not_reported( + monkeypatch, data_folder, one_plugin +): + one_plugin(_git_distribution(requested_revision="main")) + monkeypatch.setattr( + "mvt.common.updates.subprocess.run", + _fake_git(stdout=f"{INSTALLED_COMMIT}\trefs/heads/main\n"), + ) + + assert PluginUpdates().check() == [] + + +def test_commit_pinned_repository_plugin_is_never_outdated( + monkeypatch, data_folder, one_plugin +): + one_plugin(_git_distribution(requested_revision=INSTALLED_COMMIT)) + calls = [] + monkeypatch.setattr("mvt.common.updates.subprocess.run", _fake_git(calls=calls)) + + assert PluginUpdates().check() == [] + assert calls == [] + + +def test_short_commit_pinned_repository_plugin_is_never_outdated( + monkeypatch, data_folder, one_plugin +): + one_plugin(_git_distribution(requested_revision=INSTALLED_COMMIT[:10])) + calls = [] + monkeypatch.setattr("mvt.common.updates.subprocess.run", _fake_git(calls=calls)) + + assert PluginUpdates().check() == [] + assert calls == [] + + +def test_tag_pinned_repository_plugin_is_never_outdated( + monkeypatch, data_folder, one_plugin +): + one_plugin(_git_distribution(requested_revision="v1.0.0")) + monkeypatch.setattr( + "mvt.common.updates.subprocess.run", + _fake_git(stdout=f"{REMOTE_COMMIT}\trefs/tags/v1.0.0\n"), + ) + + assert PluginUpdates().check() == [] + + +def test_repository_plugin_is_skipped_without_git(monkeypatch, data_folder, one_plugin): + one_plugin(_git_distribution(requested_revision="main")) + monkeypatch.setattr( + "mvt.common.updates.subprocess.run", + _fake_git(exception=FileNotFoundError("git")), + ) + + assert PluginUpdates().check() == [] + + +def test_repository_plugin_is_skipped_when_git_fails( + monkeypatch, data_folder, one_plugin +): + one_plugin(_git_distribution(requested_revision="main")) + monkeypatch.setattr( + "mvt.common.updates.subprocess.run", + _fake_git(stdout="", returncode=128), + ) + + assert PluginUpdates().check() == [] + + +def test_local_plugin_install_is_skipped(monkeypatch, data_folder, one_plugin): + one_plugin( + FakeDistribution( + "example-plugin", + direct_url={ + "url": "file:///home/analyst/example-plugin", + "dir_info": {"editable": True}, + }, + ) + ) + + def fail(*args, **kwargs): + raise AssertionError("a local plugin install must not be checked") + + monkeypatch.setattr("mvt.common.updates.requests.get", fail) + monkeypatch.setattr("mvt.common.updates.subprocess.run", fail) + + assert PluginUpdates().check() == [] + + +def test_check_stores_the_findings_and_the_check_timestamp( + monkeypatch, data_folder, one_plugin +): + one_plugin(FakeDistribution("example-plugin", version="1.0.0")) + monkeypatch.setattr( + "mvt.common.updates.requests.get", + lambda url, **kwargs: FakeResponse(payload={"info": {"version": "1.2.0"}}), + ) + plugin_updates = PluginUpdates() + + findings = plugin_updates.check() + + assert json.loads((data_folder / "plugin_updates.json").read_text()) == findings + assert (data_folder / "latest_plugins_check").read_text().isdigit() + assert PluginUpdates().get_findings() == findings + + +def test_findings_are_empty_before_the_first_check(data_folder): + assert PluginUpdates().get_findings() == [] + + +def test_malformed_cached_findings_are_dropped(data_folder): + plugin_updates = PluginUpdates() + usable = { + "name": "example-plugin", + "installed": "1.0.0", + "latest": "1.2.0", + "origin": "pypi", + "upgrade_command": "pip install -U example-plugin", + } + data_folder.mkdir(parents=True, exist_ok=True) + (data_folder / "plugin_updates.json").write_text( + json.dumps( + [ + {"oops": 1}, + "not a finding", + {"name": "half-plugin", "installed": "1.0.0"}, + {**usable, "latest": None}, + usable, + ] + ), + encoding="utf-8", + ) + + assert plugin_updates.get_findings() == [usable] + + +def test_corrupt_cached_findings_are_ignored(data_folder): + data_folder.mkdir(parents=True, exist_ok=True) + (data_folder / "plugin_updates.json").write_text("{ not json", encoding="utf-8") + + assert PluginUpdates().get_findings() == [] + + +def test_cached_findings_of_upgraded_and_removed_plugins_are_dropped( + monkeypatch, data_folder +): + findings = [ + { + "name": "upgraded-plugin", + "installed": "1.0.0", + "latest": "1.2.0", + "origin": "pypi", + "upgrade_command": "pip install -U upgraded-plugin", + }, + { + "name": "removed-plugin", + "installed": "1.0.0", + "latest": "1.2.0", + "origin": "pypi", + "upgrade_command": "pip install -U removed-plugin", + }, + { + "name": "example-plugin", + "installed": "1.0.0", + "latest": "1.2.0", + "origin": "pypi", + "upgrade_command": "pip install -U example-plugin", + }, + ] + plugin_updates = PluginUpdates() + plugin_updates.set_findings(findings) + monkeypatch.setattr( + "mvt.common.updates.installed_plugin_distributions", + lambda: [ + # The analyst upgraded this plugin since the latest check. + FakeDistribution("upgraded-plugin", version="1.2.0"), + FakeDistribution("example-plugin", version="1.0.0"), + ], + ) + + assert plugin_updates.current_findings() == [findings[2]] + + +def test_cached_findings_of_updated_repository_plugins_are_dropped(data_folder): + findings = [ + { + "name": "example-plugin", + "installed": "aaaaaaaa", + "latest": "bbbbbbbb", + "origin": "git", + "upgrade_command": "pip install -U example-plugin", + } + ] + plugin_updates = PluginUpdates() + plugin_updates.set_findings(findings) + + assert plugin_updates.current_findings([_git_distribution()]) == findings + assert ( + plugin_updates.current_findings([_git_distribution(commit=REMOTE_COMMIT)]) == [] + ) + + +def test_corrupt_check_timestamp_does_not_raise(data_folder): + plugin_updates = PluginUpdates() + data_folder.mkdir(parents=True, exist_ok=True) + (data_folder / "latest_plugins_check").write_text("truncated", encoding="utf-8") + + assert plugin_updates.get_latest_check() == 0 + assert plugin_updates.should_check() == (True, 0) + + +def test_should_check_is_throttled_for_twelve_hours(data_folder): + plugin_updates = PluginUpdates() + plugin_updates.set_findings([]) + + recent = datetime.now() - timedelta(hours=4) + with open(plugin_updates.latest_check_path, "w", encoding="utf-8") as handle: + handle.write(str(int(recent.timestamp()))) + + should_check, hours = plugin_updates.should_check() + assert not should_check + assert hours == 8 + + old = datetime.now() - timedelta(hours=13) + with open(plugin_updates.latest_check_path, "w", encoding="utf-8") as handle: + handle.write(str(int(old.timestamp()))) + + assert plugin_updates.should_check() == (True, 0) + + +def test_should_check_without_a_previous_check(data_folder): + assert PluginUpdates().should_check() == (True, 0) + + +@pytest.fixture +def no_version_check(monkeypatch): + monkeypatch.setattr(MVTUpdates, "check", lambda self: "") + # Keep rich from wrapping the plugin lines while they are being asserted. + monkeypatch.setenv("COLUMNS", "200") + + +@pytest.fixture +def throttled_cache(monkeypatch, data_folder): + """Fill the findings cache and put the check inside its throttle window.""" + + def fill(findings, distributions): + PluginUpdates().set_findings(findings) + monkeypatch.setattr( + logo, "installed_plugin_distributions", lambda: distributions + ) + monkeypatch.setattr(PluginUpdates, "should_check", lambda self: (False, 8)) + monkeypatch.setattr( + PluginUpdates, + "check", + lambda self: pytest.fail("the check must be throttled"), + ) + + return fill + + +def test_logo_prints_the_cached_plugin_updates( + capsys, no_version_check, throttled_cache +): + throttled_cache( + [ + { + "name": "example-plugin", + "installed": "1.0.0", + "latest": "1.2.0", + "origin": "pypi", + "upgrade_command": "pip install -U example-plugin", + } + ], + [FakeDistribution("example-plugin", version="1.0.0")], + ) + + logo.check_updates(disable_indicator_check=True) + + output = capsys.readouterr().out + assert "Plugin updates available:" in output + assert "example-plugin 1.0.0 → 1.2.0 (pip install -U example-plugin)" in output + + +def test_logo_does_not_print_a_cached_update_of_an_upgraded_plugin( + capsys, no_version_check, throttled_cache +): + throttled_cache( + [ + { + "name": "example-plugin", + "installed": "1.0.0", + "latest": "1.2.0", + "origin": "pypi", + "upgrade_command": "pip install -U example-plugin", + } + ], + # The analyst already upgraded the plugin the cached finding is about. + [FakeDistribution("example-plugin", version="1.2.0")], + ) + + logo.check_updates(disable_indicator_check=True) + + assert "Plugin updates" not in capsys.readouterr().out + + +def test_logo_prints_nothing_when_throttled_without_findings( + capsys, no_version_check, throttled_cache +): + throttled_cache([], [FakeDistribution("example-plugin")]) + + logo.check_updates(disable_indicator_check=True) + + assert "Plugin updates" not in capsys.readouterr().out + + +def test_logo_survives_a_corrupt_plugin_cache( + monkeypatch, capsys, data_folder, no_version_check +): + data_folder.mkdir(parents=True, exist_ok=True) + (data_folder / "plugin_updates.json").write_text( + json.dumps([{"oops": 1}]), encoding="utf-8" + ) + monkeypatch.setattr( + logo, + "installed_plugin_distributions", + lambda: [FakeDistribution("example-plugin")], + ) + monkeypatch.setattr(PluginUpdates, "should_check", lambda self: (False, 8)) + + logo.check_updates(disable_indicator_check=True) + + assert "Plugin updates" not in capsys.readouterr().out + + +def test_logo_skips_the_plugin_check_without_plugins( + monkeypatch, capsys, no_version_check +): + monkeypatch.setattr(logo, "installed_plugin_distributions", list) + monkeypatch.setattr( + PluginUpdates, + "should_check", + lambda self: pytest.fail("plugins must not be checked without plugins"), + ) + + logo.check_updates(disable_indicator_check=True) + + assert "Plugin updates" not in capsys.readouterr().out + + +def test_logo_skips_the_plugin_check_without_network_access( + monkeypatch, capsys, no_version_check +): + monkeypatch.setattr("mvt.common.logo.settings.NETWORK_ACCESS_ALLOWED", False) + monkeypatch.setattr( + logo, + "installed_plugin_distributions", + lambda: pytest.fail("plugins must not be listed without network access"), + ) + + logo.check_updates(disable_indicator_check=True) + + assert "Plugin updates" not in capsys.readouterr().out + + +def test_logo_skips_the_plugin_check_when_update_checks_are_disabled( + monkeypatch, capsys +): + monkeypatch.setattr( + logo, + "installed_plugin_distributions", + lambda: pytest.fail("plugins must not be checked with --disable-update-check"), + ) + + logo.check_updates(disable_version_check=True, disable_indicator_check=True) + + assert capsys.readouterr().out == "" From 91da90174166a84f7e8f6682385abe1904e89093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:16 +0200 Subject: [PATCH 057/109] Find the console log handler by type when changing verbosity (#899) * Find the console log handler by type when changing verbosity set_verbose_logging() adjusted the first handler on the "mvt" logger, whichever handler that happened to be. Anything else attaching a handler to that logger - an embedding application, a plugin, a test harness - had it mistaken for the console and raised or lowered behind its back, and the same slot can hold the file handler a command attaches to its output folder, whose level a --verbose flag should never decide: command.log records the whole run either way. Walk the handlers instead and adjust only MVT's own console handler, found by its MVTLogHandler type. Every other handler on the logger is left alone. Behaviour is otherwise unchanged. * Add a --verbose option to the mvt, mvt-ios and mvt-android commands Verbosity was an option of each module-running command, so "mvt-ios --verbose check-backup" was a usage error, a plugin command had to define a flag of its own, and there was no way to get debug output from mvt at all. The option now sits on the three commands themselves and sets the level of MVT's console handler for the run, through set_verbose_logging(), before any command runs. Plugin commands registered on any of the three CLIs get the option for free and need none of their own. The per-command --verbose of the check-* commands is kept for backward compatibility. It only ever raises the level, so the CLI's choice is never undone by a command's default, and its help text says it is kept for compatibility. It is to be removed in a later release. --- README.md | 2 + docs/android/intrusion_logs.md | 2 +- src/mvt/android/cli.py | 27 ++++++--- src/mvt/cli.py | 8 ++- src/mvt/common/help.py | 4 ++ src/mvt/common/utils.py | 16 +++-- src/mvt/ios/cli.py | 23 ++++--- tests/common/test_utils.py | 27 +++++++++ tests/test_cli_verbose.py | 107 +++++++++++++++++++++++++++++++++ 9 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 tests/test_cli_verbose.py diff --git a/README.md b/README.md index 54cccca1..98e90e35 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ For alternative installation options and known issues, please refer to the [docu MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version`, `completion`, `plugins` and `download-iocs` (`version` and `download-iocs` remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/) +Pass `--verbose` to any of the three commands, before the command name (`mvt-ios --verbose check-backup ...`), for debug output. The `--verbose` option the `check-*` commands accept after their name still works but is kept for compatibility only and will be removed in a future release. + ### Shell completion MVT can generate a shell completion script for Bash, Zsh, and Fish which covers `mvt`, `mvt-ios` and `mvt-android`: diff --git a/docs/android/intrusion_logs.md b/docs/android/intrusion_logs.md index da8fc837..16c6bf15 100644 --- a/docs/android/intrusion_logs.md +++ b/docs/android/intrusion_logs.md @@ -58,7 +58,7 @@ mvt-android check-intrusion-logs --output /path/to/results/ /path/to/intrusion-l | `-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. | +| `-v, --verbose` | Verbose logging. Kept for compatibility and to be removed in a future release: pass `--verbose` to `mvt-android` itself instead. | ## Modules diff --git a/src/mvt/android/cli.py b/src/mvt/android/cli.py index 4716168e..c32a7b23 100644 --- a/src/mvt/android/cli.py +++ b/src/mvt/android/cli.py @@ -36,6 +36,7 @@ from mvt.common.help import ( HELP_MSG_OUTPUT, HELP_MSG_STIX2, HELP_MSG_VERBOSE, + HELP_MSG_VERBOSE_COMMAND, HELP_MSG_VERSION, HELP_MSG_VIRUS_TOTAL, ) @@ -70,6 +71,11 @@ def _get_disable_flags(ctx): ) +def _get_verbose(ctx): + """Return whether --verbose was passed to the CLI itself.""" + return bool(ctx.obj and ctx.obj.get("verbose", False)) + + def _load_custom_modules(load_module): try: return load_custom_modules(load_module) @@ -90,11 +96,14 @@ def _load_custom_modules(load_module): is_flag=True, help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, ) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.pass_context -def cli(ctx, disable_update_check, disable_indicator_update_check): +def cli(ctx, disable_update_check, disable_indicator_update_check, verbose): ctx.ensure_object(dict) ctx.obj["disable_version_check"] = disable_update_check ctx.obj["disable_indicator_check"] = disable_indicator_update_check + ctx.obj["verbose"] = verbose + set_verbose_logging(verbose) logo( disable_version_check=disable_update_check, disable_indicator_check=disable_indicator_update_check, @@ -145,7 +154,7 @@ def check_adb(ctx): default=[], help=HELP_MSG_LOAD_MODULE, ) -@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.argument("BUGREPORT_PATH", type=click.Path(exists=True)) @click.pass_context def check_bugreport( @@ -158,7 +167,7 @@ def check_bugreport( verbose, bugreport_path, ): - set_verbose_logging(verbose) + set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) # Always generate hashes as bug reports are small. cmd = CmdAndroidCheckBugreport( @@ -213,7 +222,7 @@ def check_bugreport( ) @click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE) @click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD) -@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.pass_context def check_backup( @@ -227,7 +236,7 @@ def check_backup( verbose, backup_path, ): - set_verbose_logging(verbose) + set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) # Always generate hashes as backups are generally small. @@ -287,7 +296,7 @@ def check_backup( ) @click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE) @click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD) -@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.argument("ANDROIDQF_PATH", type=click.Path(exists=True)) @click.pass_context def check_androidqf( @@ -305,7 +314,7 @@ def check_androidqf( verbose, androidqf_path, ): - set_verbose_logging(verbose) + set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) cmd = CmdAndroidCheckAndroidQF( @@ -373,7 +382,7 @@ def check_androidqf( "time instead of UTC." ), ) -@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.argument("LOGS_PATH", type=click.Path(exists=True)) @click.pass_context def check_intrusion_logs( @@ -387,7 +396,7 @@ def check_intrusion_logs( verbose, logs_path, ): - set_verbose_logging(verbose) + set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) module_options = {} diff --git a/src/mvt/cli.py b/src/mvt/cli.py index 3ba6602e..40a86fe8 100644 --- a/src/mvt/cli.py +++ b/src/mvt/cli.py @@ -17,11 +17,12 @@ from mvt.common.help import ( HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, HELP_MSG_DISABLE_UPDATE_CHECK, HELP_MSG_STIX2, + HELP_MSG_VERBOSE, HELP_MSG_VERSION, ) from mvt.common.logo import logo from mvt.common.updates import IndicatorsUpdates -from mvt.common.utils import init_logging +from mvt.common.utils import init_logging, set_verbose_logging init_logging() @@ -41,8 +42,9 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) is_flag=True, help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, ) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.pass_context -def cli(ctx, disable_update_check, disable_indicator_update_check): +def cli(ctx, disable_update_check, disable_indicator_update_check, verbose): """Mobile Verification Toolkit. mvt-ios and mvt-android run the forensic analysis of an acquisition: each @@ -53,6 +55,8 @@ 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 + ctx.obj["verbose"] = verbose + set_verbose_logging(verbose) if ctx.invoked_subcommand != "completion": logo( disable_version_check=disable_update_check, diff --git a/src/mvt/common/help.py b/src/mvt/common/help.py index 13575e5c..8b70f20b 100644 --- a/src/mvt/common/help.py +++ b/src/mvt/common/help.py @@ -17,6 +17,10 @@ HELP_MSG_LOAD_MODULE = ( 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" +HELP_MSG_VERBOSE_COMMAND = ( + "Verbose mode (kept for compatibility, pass --verbose before the command " + "name instead)" +) 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" diff --git a/src/mvt/common/utils.py b/src/mvt/common/utils.py index ad3b3949..b71c4ab7 100644 --- a/src/mvt/common/utils.py +++ b/src/mvt/common/utils.py @@ -256,12 +256,18 @@ def init_logging(verbose: bool = False): def set_verbose_logging(verbose: bool = False): + """Raise or lower the verbosity of MVT's console output. + + Only MVT's own console handler is adjusted, wherever it sits in the list. + The file handler a command attaches to its output folder keeps recording + everything, so the command.log of a run does not depend on how the run was + invoked, and a handler attached to the "mvt" logger by anything else is + left alone. + """ log = logging.getLogger("mvt") - handler = log.handlers[0] - if verbose: - handler.setLevel(logging.DEBUG) - else: - handler.setLevel(logging.INFO) + for handler in log.handlers: + if isinstance(handler, MVTLogHandler): + handler.setLevel(logging.DEBUG if verbose else logging.INFO) def exec_or_profile(module, globals, locals): diff --git a/src/mvt/ios/cli.py b/src/mvt/ios/cli.py index 6f633eae..35acec19 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -38,6 +38,7 @@ from mvt.common.help import ( HELP_MSG_LOAD_MODULE, HELP_MSG_MODULE, HELP_MSG_VERBOSE, + HELP_MSG_VERBOSE_COMMAND, HELP_MSG_CHECK_FS, HELP_MSG_CHECK_IOCS, HELP_MSG_STIX2, @@ -74,6 +75,11 @@ def _get_disable_flags(ctx): ) +def _get_verbose(ctx): + """Return whether --verbose was passed to the CLI itself.""" + return bool(ctx.obj and ctx.obj.get("verbose", False)) + + def _load_custom_modules(load_module): try: return load_custom_modules(load_module) @@ -94,11 +100,14 @@ def _load_custom_modules(load_module): is_flag=True, help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, ) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.pass_context -def cli(ctx, disable_update_check, disable_indicator_update_check): +def cli(ctx, disable_update_check, disable_indicator_update_check, verbose): ctx.ensure_object(dict) ctx.obj["disable_version_check"] = disable_update_check ctx.obj["disable_indicator_check"] = disable_indicator_update_check + ctx.obj["verbose"] = verbose + set_verbose_logging(verbose) logo( disable_version_check=disable_update_check, disable_indicator_check=disable_indicator_update_check, @@ -254,7 +263,7 @@ def extract_key(password, key_file, backup_path): help=HELP_MSG_LOAD_MODULE, ) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) -@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.pass_context def check_backup( @@ -269,7 +278,7 @@ def check_backup( verbose, backup_path, ): - set_verbose_logging(verbose) + set_verbose_logging(verbose or _get_verbose(ctx)) module_options = {"fast_mode": fast} custom_modules = _load_custom_modules(load_module) @@ -323,7 +332,7 @@ def check_backup( help=HELP_MSG_LOAD_MODULE, ) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) -@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.argument("DUMP_PATH", type=click.Path(exists=True)) @click.pass_context def check_fs( @@ -338,7 +347,7 @@ def check_fs( verbose, dump_path, ): - set_verbose_logging(verbose) + set_verbose_logging(verbose or _get_verbose(ctx)) module_options = {"fast_mode": fast} custom_modules = _load_custom_modules(load_module) @@ -392,7 +401,7 @@ def check_fs( help=HELP_MSG_LOAD_MODULE, ) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) -@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) +@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True)) @click.pass_context def check_sysdiagnose( @@ -406,7 +415,7 @@ def check_sysdiagnose( verbose, sysdiagnose_path, ): - set_verbose_logging(verbose) + set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) cmd = CmdIOSCheckSysdiagnose( target_path=sysdiagnose_path, diff --git a/tests/common/test_utils.py b/tests/common/test_utils.py index b8791e18..6bef1472 100644 --- a/tests/common/test_utils.py +++ b/tests/common/test_utils.py @@ -18,6 +18,7 @@ from mvt.common.utils import ( generate_hashes_from_path, get_sha256_from_file_path, init_logging, + set_verbose_logging, ) from ..utils import get_artifact_folder @@ -122,3 +123,29 @@ class TestInitLogging: sum(isinstance(handler, MVTLogHandler) for handler in log.handlers) == handler_count ) + + def test_verbose_logging_finds_the_console_handler_among_others(self): + # Something else may have attached a handler to the "mvt" logger + # before MVT did, so the console handler is not always the first. + log = logging.getLogger("mvt") + init_logging() + foreign_handler = logging.NullHandler() + foreign_handler.setLevel(logging.CRITICAL) + log.handlers.insert(0, foreign_handler) + + try: + set_verbose_logging(True) + console_handlers = [ + handler + for handler in log.handlers + if isinstance(handler, MVTLogHandler) + ] + assert console_handlers + assert all(handler.level == logging.DEBUG for handler in console_handlers) + assert foreign_handler.level == logging.CRITICAL + + set_verbose_logging(False) + assert all(handler.level == logging.INFO for handler in console_handlers) + assert foreign_handler.level == logging.CRITICAL + finally: + log.handlers.remove(foreign_handler) diff --git a/tests/test_cli_verbose.py b/tests/test_cli_verbose.py new file mode 100644 index 00000000..d8826ff6 --- /dev/null +++ b/tests/test_cli_verbose.py @@ -0,0 +1,107 @@ +# 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 + +import pytest +from click.testing import CliRunner + +from mvt.android.cli import cli as android_cli +from mvt.cli import cli as mvt_cli +from mvt.common.log import MVTLogHandler +from mvt.common.utils import set_verbose_logging +from mvt.ios.cli import cli as ios_cli + +# Keep the banner of the group callback from checking for updates online. +OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"] + +PROGRAMS = {"mvt": mvt_cli, "mvt-ios": ios_cli, "mvt-android": android_cli} + + +@pytest.fixture(autouse=True) +def _reset_console_level(): + """Leave the console handler at its default level after every test.""" + yield + set_verbose_logging(False) + + +def _console_level(): + """Return the level of MVT's own console log handler.""" + for handler in logging.getLogger("mvt").handlers: + if isinstance(handler, MVTLogHandler): + return handler.level + raise AssertionError("MVT has no console log handler") + + +class TestVerboseOnTheCommands: + @pytest.mark.parametrize("program", sorted(PROGRAMS)) + def test_verbose_before_the_command_name_turns_on_debug(self, program): + cli = PROGRAMS[program] + + result = CliRunner().invoke(cli, [*OFFLINE, "--verbose", "version"]) + + assert result.exit_code == 0 + assert _console_level() == logging.DEBUG + + @pytest.mark.parametrize("program", sorted(PROGRAMS)) + def test_a_run_without_verbose_goes_back_to_info(self, program): + cli = PROGRAMS[program] + CliRunner().invoke(cli, [*OFFLINE, "--verbose", "version"]) + + result = CliRunner().invoke(cli, [*OFFLINE, "version"]) + + assert result.exit_code == 0 + assert _console_level() == logging.INFO + + def test_mvt_verbose_without_a_command_prints_the_help(self): + result = CliRunner().invoke(mvt_cli, [*OFFLINE, "--verbose"]) + + assert result.exit_code == 0 + assert "Usage:" in result.output + assert _console_level() == logging.DEBUG + + +class TestVerboseOnTheCheckCommands: + def test_ios_command_default_does_not_undo_the_cli_choice(self, tmp_path): + result = CliRunner().invoke( + ios_cli, + [*OFFLINE, "--verbose", "check-backup", "--list-modules", str(tmp_path)], + ) + + assert result.exit_code == 0 + assert _console_level() == logging.DEBUG + + def test_ios_verbose_after_the_command_name_still_works(self, tmp_path): + result = CliRunner().invoke( + ios_cli, + [*OFFLINE, "check-backup", "--verbose", "--list-modules", str(tmp_path)], + ) + + assert result.exit_code == 0 + assert _console_level() == logging.DEBUG + + def test_android_command_default_does_not_undo_the_cli_choice(self, tmp_path): + result = CliRunner().invoke( + android_cli, + [*OFFLINE, "--verbose", "check-bugreport", "--list-modules", str(tmp_path)], + ) + + assert result.exit_code == 0 + assert _console_level() == logging.DEBUG + + def test_android_verbose_after_the_command_name_still_works(self, tmp_path): + result = CliRunner().invoke( + android_cli, + [*OFFLINE, "check-bugreport", "--verbose", "--list-modules", str(tmp_path)], + ) + + assert result.exit_code == 0 + assert _console_level() == logging.DEBUG + + def test_the_command_option_says_it_is_kept_for_compatibility(self): + result = CliRunner().invoke(ios_cli, [*OFFLINE, "check-backup", "--help"]) + + assert result.exit_code == 0 + assert "kept for compatibility" in result.output From 85adb02eb994215cf72547713fca0bcef4b2a5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:16 +0200 Subject: [PATCH 058/109] Share the check-iocs module lists between the CLI and the code (#900) * Share the check-iocs module lists between the CLI and the code check-iocs re-checks the results a previous run stored, so its module list is every module of the platform that could have written one. Each platform's CLI composed that list inline, concatenating the families by hand, so the list existed only inside the click callback: anything else needing to know what check-iocs runs had to build its own copy, and the two could drift apart without a test noticing. Give each platform a command_modules.py holding the one list, and have its CLI assign it. The modules check-iocs runs are unchanged, and a test pins each list to the families it is composed of. * Pin that check-iocs re-checks the results of custom modules check-iocs takes its custom modules from load_custom_modules() like every check-* command and matches result files to modules by slug, so a plugin module's stored results are re-checked whenever it declares the check-iocs pair of its platform; nothing asserted it. --- src/mvt/android/cli.py | 9 +- src/mvt/android/command_modules.py | 23 +++++ src/mvt/ios/cli.py | 6 +- src/mvt/ios/command_modules.py | 22 +++++ tests/common/test_cmd_check_iocs.py | 139 +++++++++++++++++++++++++++ tests/common/test_command_modules.py | 27 ++++++ 6 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 src/mvt/android/command_modules.py create mode 100644 src/mvt/ios/command_modules.py create mode 100644 tests/common/test_cmd_check_iocs.py create mode 100644 tests/common/test_command_modules.py diff --git a/src/mvt/android/cli.py b/src/mvt/android/cli.py index c32a7b23..92b2fe93 100644 --- a/src/mvt/android/cli.py +++ b/src/mvt/android/cli.py @@ -49,11 +49,8 @@ from .cmd_check_androidqf import CmdAndroidCheckAndroidQF from .cmd_check_backup import CmdAndroidCheckBackup from .cmd_check_bugreport import CmdAndroidCheckBugreport 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 .command_modules import ANDROID_CHECK_IOCS_MODULES from .modules.backup.helpers import cli_load_android_backup_password -from .modules.bugreport import BUGREPORT_MODULES init_logging() log = logging.getLogger("mvt") @@ -459,9 +456,7 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder): custom_modules=custom_modules, platform="android", ) - cmd.modules = ( - BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES - ) + cmd.modules = ANDROID_CHECK_IOCS_MODULES if list_modules: cmd.list_modules() diff --git a/src/mvt/android/command_modules.py b/src/mvt/android/command_modules.py new file mode 100644 index 00000000..9b6b56ef --- /dev/null +++ b/src/mvt/android/command_modules.py @@ -0,0 +1,23 @@ +# 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/ + +"""Module lists an mvt-android command composes from more than one family. + +Commands whose modules are one family read that family directly. check-iocs +re-checks stored results, so it has to know every module that could have +written one, and both the CLI and any other code needing that answer share +the list from here rather than each concatenating their own. +""" + +from mvt.common.module import MVTModule + +from .modules.androidqf import ANDROIDQF_MODULES +from .modules.backup import BACKUP_MODULES +from .modules.bugreport import BUGREPORT_MODULES +from .modules.intrusion_logs import INTRUSION_LOGS_MODULES + +ANDROID_CHECK_IOCS_MODULES: list[type[MVTModule]] = ( + BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES +) diff --git a/src/mvt/ios/cli.py b/src/mvt/ios/cli.py index 35acec19..8251fd91 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -53,9 +53,7 @@ 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 -from .modules.mixed import MIXED_MODULES +from .command_modules import IOS_CHECK_IOCS_MODULES init_logging() log = logging.getLogger("mvt") @@ -479,7 +477,7 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder): custom_modules=custom_modules, platform="ios", ) - cmd.modules = BACKUP_MODULES + FS_MODULES + MIXED_MODULES + cmd.modules = IOS_CHECK_IOCS_MODULES if list_modules: cmd.list_modules() diff --git a/src/mvt/ios/command_modules.py b/src/mvt/ios/command_modules.py new file mode 100644 index 00000000..26fa1d61 --- /dev/null +++ b/src/mvt/ios/command_modules.py @@ -0,0 +1,22 @@ +# 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/ + +"""Module lists an mvt-ios command composes from more than one family. + +Commands whose modules are one family read that family directly. check-iocs +re-checks stored results, so it has to know every module that could have +written one, and both the CLI and any other code needing that answer share +the list from here rather than each concatenating their own. +""" + +from mvt.common.module import MVTModule + +from .modules.backup import BACKUP_MODULES +from .modules.fs import FS_MODULES +from .modules.mixed import MIXED_MODULES + +IOS_CHECK_IOCS_MODULES: list[type[MVTModule]] = ( + BACKUP_MODULES + FS_MODULES + MIXED_MODULES +) diff --git a/tests/common/test_cmd_check_iocs.py b/tests/common/test_cmd_check_iocs.py new file mode 100644 index 00000000..1bdd2327 --- /dev/null +++ b/tests/common/test_cmd_check_iocs.py @@ -0,0 +1,139 @@ +# 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 json +import logging + +import pytest +from click.testing import CliRunner + +from mvt.android.cli import cli as android_cli +from mvt.android.command_modules import ANDROID_CHECK_IOCS_MODULES +from mvt.common.cmd_check_iocs import CmdCheckIOCS +from mvt.common.module import MVTModule +from mvt.ios.cli import cli as ios_cli +from mvt.ios.command_modules import IOS_CHECK_IOCS_MODULES + +# Keep the banner of the group callback from checking for updates online. +OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"] + + +class CustomResultsModule(MVTModule): + """A custom module which declares the check-iocs pair of both platforms.""" + + slug = "custom_results" + supported_commands = ( + ("ios", "check-backup"), + ("ios", "check-iocs"), + ("android", "check-iocs"), + ) + + checked: list = [] + + def run(self) -> None: + pass + + def check_indicators(self) -> None: + self.checked.append(list(self.results)) + + +class BackupOnlyModule(MVTModule): + """A custom module which does not declare check-iocs.""" + + slug = "backup_only" + supported_commands = (("ios", "check-backup"),) + + def check_indicators(self) -> None: + raise AssertionError("must not be re-checked") + + +@pytest.mark.parametrize( + "platform, builtin_modules", + [("ios", IOS_CHECK_IOCS_MODULES), ("android", ANDROID_CHECK_IOCS_MODULES)], +) +def test_check_iocs_rechecks_the_stored_results_of_custom_modules( + platform, builtin_modules, tmp_path, caplog +): + # check-iocs matches every .json in the results folder to the module + # with that slug, custom modules included, and runs its check_indicators() + # again over the stored results. + results = [{"domain": "example.org"}] + (tmp_path / "custom_results.json").write_text(json.dumps(results)) + (tmp_path / "backup_only.json").write_text(json.dumps(results)) + CustomResultsModule.checked.clear() + + cmd = CmdCheckIOCS( + target_path=str(tmp_path), + custom_modules=[CustomResultsModule, BackupOnlyModule], + platform=platform, + ) + cmd.modules = builtin_modules + + with caplog.at_level(logging.INFO): + cmd.run() + + assert CustomResultsModule.checked == [results] + assert ( + 'Loading results from "custom_results.json" with module CustomResultsModule' + in caplog.text + ) + # A module declaring only check-backup is not part of check-iocs. + assert "backup_only.json" not in caplog.text + + +def test_check_iocs_lists_custom_modules_declaring_the_command(caplog): + cmd = CmdCheckIOCS( + custom_modules=[CustomResultsModule, BackupOnlyModule], + platform="ios", + ) + cmd.modules = IOS_CHECK_IOCS_MODULES + + with caplog.at_level(logging.INFO): + cmd.list_modules() + + assert "CustomResultsModule" in caplog.text + assert "BackupOnlyModule" not in caplog.text + + +LOADED_MODULE = ''' +from mvt.common.module import MVTModule + + +class LoadedResultsModule(MVTModule): + """A module loaded from a file with --load-module.""" + + slug = "loaded_results" + supported_commands = (("ios", "check-iocs"), ("android", "check-iocs")) + + def run(self) -> None: + pass + + def check_indicators(self) -> None: + self.log.warning("loaded module checked %d results", len(self.results)) +''' + + +@pytest.mark.parametrize("cli", [ios_cli, android_cli], ids=["mvt-ios", "mvt-android"]) +def test_check_iocs_loads_custom_modules_from_a_file_on_each_cli(cli, tmp_path, caplog): + module_path = tmp_path / "loaded_module.py" + module_path.write_text(LOADED_MODULE) + results_folder = tmp_path / "results" + results_folder.mkdir() + (results_folder / "loaded_results.json").write_text(json.dumps([{"a": 1}])) + + with caplog.at_level(logging.INFO): + result = CliRunner().invoke( + cli, + [ + *OFFLINE, + "check-iocs", + "--load-module", + str(module_path), + str(results_folder), + ], + ) + + assert result.exit_code == 0, result.output + assert "loaded module checked 1 results" in caplog.text diff --git a/tests/common/test_command_modules.py b/tests/common/test_command_modules.py new file mode 100644 index 00000000..8f79ce9d --- /dev/null +++ b/tests/common/test_command_modules.py @@ -0,0 +1,27 @@ +# 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/ + +from mvt.android.command_modules import ANDROID_CHECK_IOCS_MODULES +from mvt.android.modules.androidqf import ANDROIDQF_MODULES +from mvt.android.modules.backup import BACKUP_MODULES as ANDROID_BACKUP_MODULES +from mvt.android.modules.bugreport import BUGREPORT_MODULES +from mvt.android.modules.intrusion_logs import INTRUSION_LOGS_MODULES +from mvt.ios.command_modules import IOS_CHECK_IOCS_MODULES +from mvt.ios.modules.backup import BACKUP_MODULES as IOS_BACKUP_MODULES +from mvt.ios.modules.fs import FS_MODULES +from mvt.ios.modules.mixed import MIXED_MODULES + + +def test_the_check_iocs_lists_are_the_families_of_their_platform(): + # The CLI reads these same lists, so nothing composing one elsewhere can + # drift from what the command runs. This pins what the lists are composed + # of. + assert IOS_CHECK_IOCS_MODULES == IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES + assert ANDROID_CHECK_IOCS_MODULES == ( + ANDROID_BACKUP_MODULES + + BUGREPORT_MODULES + + ANDROIDQF_MODULES + + INTRUSION_LOGS_MODULES + ) From 0b5b3f2d7c41fd84139a694fb4e973b779640400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:16 +0200 Subject: [PATCH 059/109] Add the mvt.plugin import surface (#901) * Add the mvt.plugin import surface mvt.plugin re-exports the names a plugin needs from MVT under one import path. It holds the module base classes and Command, the alert and result types, the database errors a module raises, the timestamp converters, the plugin settings API, MVT's settings, get_plugin_logger() and MVT_VERSION. The names it exports are kept working on a best-effort basis. Changes to them are announced in the release notes. Anything else in mvt can still be imported, and may change between releases without notice. get_plugin_logger(__name__) returns a logger under mvt.ext for plugin code outside a module class. Its records then reach the console and the command.log file of a run. A file loaded with --load-module or --load-command is named after the file. * Document how to write MVT plugins The custom modules page now leads with plugin packages. Loading module files with --load-module and MVT_CUSTOM_MODULES moves to a section on developing a module locally. A new "Writing a module" section shows a module which subclasses IOSExtraction. It lists each base class, the command pair it serves and the helpers it provides. "Depending on a built-in module" says to import a built-in class from its family package. "Importing from MVT" says what mvt.plugin exports and what importing from it means. The custom commands page shows a Command subclass which lists its own modules. The sysdiagnose and plugin configuration pages import from mvt.plugin. --- docs/development/custom_commands.md | 62 +++++++ docs/development/index.md | 214 +++++++++++++++++------ docs/development/plugin_configuration.md | 22 ++- docs/ios/sysdiagnose.md | 32 ++-- src/mvt/common/cli_plugins.py | 4 +- src/mvt/common/module_loader.py | 57 +++++- src/mvt/plugin.py | 80 +++++++++ tests/common/test_module_loader.py | 31 +++- tests/common/test_plugin.py | 34 ++++ tests/plugin_fixtures.py | 1 + 10 files changed, 448 insertions(+), 89 deletions(-) create mode 100644 src/mvt/plugin.py create mode 100644 tests/common/test_plugin.py diff --git a/docs/development/custom_commands.md b/docs/development/custom_commands.md index 71ac623a..3161cef1 100644 --- a/docs/development/custom_commands.md +++ b/docs/development/custom_commands.md @@ -29,6 +29,10 @@ def summarize(path): click.echo(f"Summarizing {path}") ``` +Log through `get_plugin_logger(__name__)` from `mvt.plugin`. Records logged +through `logging.getLogger(__name__)` do not reach `command.log`, and MVT's +console handler does not show them. + Register the object in the package's `pyproject.toml`. The entry-point name is the command users invoke: @@ -125,6 +129,64 @@ export MVT_IOS_CUSTOM_COMMANDS=./ios_commands export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands ``` +## Building a Module-Running Command + +A command which runs forensic modules over an acquisition subclasses `Command`. +`Command` creates the output folder and writes `command.log`. It orders the +modules, resolves their dependencies and runs them. It writes the result files, +`alerts.json` and `info.json`. The subclass sets `platform`, `name` and +`modules`: + +```python +from mvt.plugin import Command, MVTModule, convert_unix_to_iso, get_plugin_logger + +log = get_plugin_logger(__name__) + + +class APKManifest(MVTModule): + supported_commands = (("android", "check-apks"),) + + def run(self): + self.results = [{"checked_at": convert_unix_to_iso(0)}] + + +class CmdCheckAPKs(Command): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.platform = "android" + self.name = "check-apks" + self.modules = [APKManifest] +``` + +`platform` and `name` are the pair the modules declare in +`supported_commands`. `modules` lists the module classes the command runs, +imported directly. A command which runs modules of another plugin depends on +that package in `pyproject.toml` and imports them the same way. `init()`, +`module_init(module)` and `finish()` are optional hooks. `run()` calls them +before the run, before each module and after the run. + +Wrap the command in a Click command with an `--output` option. Run it, then +print the alert summary: + +```python +import click + + +@click.command("check-apks") +@click.option("--output", "-o", type=click.Path(exists=False)) +@click.argument("TARGET_PATH", type=click.Path(exists=True)) +def cli(output, target_path): + cmd = CmdCheckAPKs(target_path=target_path, results_path=output) + log.info("Checking APK files at path: %s", target_path) + cmd.run() + cmd.show_alerts_brief() +``` + +The `--verbose` option of `mvt`, `mvt-ios` and `mvt-android` applies to the +command. The command defines no `--verbose` option of its own. The pair a +plugin command adds is not listed anywhere in MVT. Name it in the plugin's +README. + ## Naming and Errors Built-in MVT commands cannot be replaced. External command names must also be diff --git a/docs/development/index.md b/docs/development/index.md index 692a2ab6..d1c167ff 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -48,51 +48,21 @@ configuration problem: the command logs a warning and runs no modules at all. ## 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: +MVT's module-running `check-*` commands can run forensic modules which are not +part of MVT. Custom modules are distributed as plugin packages. A Python +package installed next to MVT registers its modules through an entry point. +The modules then load automatically in every command they support, see +[Installed module packages](#installed-module-packages). `mvt plugins list` +shows the installed packages and where each one was installed from. -```bash -mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup -``` +MVT can also load module files by path, with `--load-module` and +`MVT_CUSTOM_MODULES`, see +[Developing modules locally](#developing-modules-locally). This can be used +while writing a module. Use a Python package to distribute one. -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: +A custom module declares in `supported_commands` the platform and command +pairs it runs in. A module with empty `supported_commands` does not run and +MVT logs a warning. The nine pairs are: ```python ("ios", "check-backup") @@ -106,13 +76,86 @@ pairs are: ("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: +`check-iocs` re-checks stored results rather than an acquisition. It matches +every `.json` file in the results folder to the module with that slug. +It then runs that module's `check_indicators()` again. + +### Writing a module + +A module subclasses `MVTModule` or one of the base classes below and +implements `run()`. `check_indicators()` and `serialize()` are optional. The +first matches results against IOCs or detections. The second returns timeline +records. ```python -from mvt.common.module import MVTModule -from mvt.ios.modules.backup.manifest import Manifest +from mvt.plugin import IOSExtraction, convert_unix_to_iso + + +class ExampleCustomModule(IOSExtraction): + supported_commands = ( + ("ios", "check-backup"), + ("ios", "check-fs"), + ) + slug = "example_custom_module" + + def run(self): + self.results = [{"checked_at": convert_unix_to_iso(0)}] + + def check_indicators(self): + pass + + def serialize(self, result): + return None +``` + +The base classes are: + +- `MVTModule`: the base of every module. It provides `self.results`, + `self.alertstore`, `self.log`, `self.indicators` and + `get_dependency_results()`. Subclass it directly for a module which reads + only the results of other modules. +- `IOSExtraction`: `("ios", "check-backup")` and `("ios", "check-fs")`. Adds + `_find_ios_database()`, which locates a module's database in a backup or in + a filesystem dump and repairs it if it is malformed. Adds + `_get_backup_files_from_manifest()`, `_get_backup_file_from_id()` and + `_get_fs_files_from_patterns()`. Adds `_open_sqlite_db()`, which opens a + database read-only. +- `SysdiagnoseExtraction`: `("ios", "check-sysdiagnose")`. MVT extracts the + archive and calls `from_sysdiagnose_folder()` before `run()`. The module + reads files with `_get_files_by_pattern()` and `_get_file_content()`. + `ips_files` lists the crash reports. See + [Check an iOS Sysdiagnose](../ios/sysdiagnose.md). +- `AndroidQFModule`: `("android", "check-androidqf")`. MVT calls `from_dir()` + or `from_zip()` with the file list of the acquisition. The module reads + files with `_get_files_by_pattern()` and `_get_file_content()`. + `_get_device_timezone()` returns the device timezone. +- `AndroidBackupModule`: `("android", "check-backup")`. MVT calls `from_dir()` + or `from_ab()`. The module reads files with `_get_files_by_pattern()` and + `_get_file_content()`. +- `BugReportModule`: `("android", "check-bugreport")`. MVT calls `from_dir()` + or `from_zip()`. The module reads files with `_get_files_by_pattern()`, + `_get_files_by_patterns()` and `_get_file_content()`. + `_get_dumpstate_file()` returns the dumpstate file, and + `_get_file_modification_time()` the modification time of a file. + +The underscore-named helpers are internal to the base classes. Plugin modules +can call them. Their names and signatures can change between releases. Read +the base class in `src/mvt/ios/modules` or `src/mvt/android/modules` before +relying on one. + +### Depending on a built-in module + +A module which post-processes records generated by one or more built-in MVT +modules must declare the source modules in `dependencies`. It reads their +results with `get_dependency_results()`. Import the class from its family +package: `mvt.ios.modules.backup`, `mvt.ios.modules.fs`, +`mvt.ios.modules.mixed`, `mvt.android.modules.androidqf`, +`mvt.android.modules.backup`, `mvt.android.modules.bugreport` or +`mvt.android.modules.intrusion_logs`. + +```python +from mvt.ios.modules.backup import Manifest +from mvt.plugin import MVTModule class DependentCustomModule(MVTModule): @@ -124,6 +167,30 @@ class DependentCustomModule(MVTModule): self.results = [{"manifest_entries": len(manifest_results)}] ``` +Dependencies are ordered as for the built-in modules, with custom modules +appended after the built-ins, see [Module dependencies](#module-dependencies). +A dependency has to run in every command the module supports. Where it does +not, MVT skips the module with a warning. + +`get_dependency_results()` returns the plain dictionaries the module produced. +They are the same records it writes to `.json`. Typed results per module +are planned. + +### Importing from MVT + +Import from `mvt.plugin` if it has what you need. The names it exports are +kept working on a best-effort basis. A change to one of them is announced in +the release notes. Anything else in `mvt` can be imported too, but may change +between releases without notice. The plugin interface is best effort. + +`mvt.plugin` exports the base classes above and `Command`, `Alert` and +`AlertLevel`, the result types, `DatabaseNotFoundError` and +`DatabaseCorruptedError`, the timestamp converters, the settings API of +[Plugin Configuration](plugin_configuration.md), MVT's own `settings`, +`get_plugin_logger()` and `MVT_VERSION`. `src/mvt/plugin.py` holds the list. +Read MVT's `settings` for values such as `NETWORK_ACCESS_ALLOWED` and +`NETWORK_TIMEOUT`. Plugin values go in the plugin's own settings file. + ## Installed module packages Python packages can register modules so they load automatically in every @@ -133,14 +200,14 @@ the package's `pyproject.toml`: ```toml [project.entry-points."mvt.modules"] -mvt-plugin-amnesty-custom = "mvt_plugin_amnesty_custom:get_modules" +mvt-plugin-example-org = "mvt_plugin_example_org:get_modules" ``` The entry point must resolve to an iterable of `MVTModule` subclasses, or to a callable returning one: ```python -from mvt.common.module import MVTModule +from mvt.plugin import MVTModule class PackagedModule(MVTModule): @@ -154,6 +221,10 @@ def get_modules() -> list[type[MVTModule]]: return [PackagedModule] ``` +`get_modules()` is the package's module list, written by hand. A package which +keeps its modules in separate files imports each class there and lists it. A +module missing from the list does not load. + Installed modules follow the same rules as other custom modules: each module must declare `supported_commands`, and dependencies are resolved with the standard ordering logic. A broken entry point is skipped with a warning and @@ -164,7 +235,7 @@ from sources you trust. For a `pipx` installation of MVT, inject the package into MVT's environment: ```bash -pipx inject mvt mvt-plugin-amnesty-custom +pipx inject mvt mvt-plugin-example-org ``` Module packages that need their own settings, such as an API key, should store @@ -175,9 +246,9 @@ rather than in MVT's own `config.yaml`. Name module packages `mvt-plugin-` (import package `mvt_plugin_`), and include the name of the publishing organization or author so packages from -different groups do not collide: for example, Amnesty International's custom -modules would be distributed as `mvt-plugin-amnesty-custom` with the import -package `mvt_plugin_amnesty_custom`. +different groups do not collide: for example, an organisation's custom modules +would be distributed as `mvt-plugin-example-org` with the import package +`mvt_plugin_example_org`. The prefix makes module packages easy to find on PyPI and keeps their import names from clashing with unrelated Python packages. It is a convention, not a @@ -196,11 +267,42 @@ came from. MVT's own modules log under their dotted path (for example MVT's internal logger tree: - Installed packages log under `mvt.ext.`, with the `mvt_plugin_` - prefix stripped: modules in `mvt_plugin_amnesty_custom` log as - `mvt.ext.amnesty_custom.*`. + prefix stripped: modules in `mvt_plugin_example_org` log as + `mvt.ext.example_org.*`. - Files loaded with `--load-module` or `MVT_CUSTOM_MODULES` log as `mvt.ext.`. +Outside a module class, for example in a custom command line handler, log +through `get_plugin_logger(__name__)`. It returns a logger in the same +namespace. + +## Developing modules locally + +While a module is being written, load it from its file. `--load-module` takes a +Python file, or a folder of them, on every module-running command, and can be +repeated: + +```bash +mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup +``` + +For a folder, MVT loads its non-hidden top-level `*.py` files in sorted order +and skips `__init__.py`. `MVT_CUSTOM_MODULES` names a folder to load on every +module-running command, before any `--load-module` path: + +```bash +MVT_CUSTOM_MODULES=./custom_modules mvt-android check-bugreport ./bugreport.zip +``` + +Files loaded this way follow the same rules as packaged modules. +`--list-modules` reports them with the SHA-256 hash of the file in place of a +version. An editable install of the package (`pip install -e .`) also works: +the modules load through the entry point, and `mvt plugins list` shows the +package with the `local` origin. + +Loading by path is for development. Move a module into a package once it +works. + ## Auditing loaded modules Because installed module packages load automatically, MVT records where every diff --git a/docs/development/plugin_configuration.md b/docs/development/plugin_configuration.md index e41f4e43..62d35a79 100644 --- a/docs/development/plugin_configuration.md +++ b/docs/development/plugin_configuration.md @@ -24,8 +24,7 @@ configuration: The exact parent folder follows the platform convention used for MVT's `config.yaml` (for example `~/Library/Application Support/mvt` on macOS). Use -`mvt.common.plugin_config.plugin_config_path()` instead of building the path by -hand. +`plugin_config_path()` from `mvt.plugin` instead of building the path by hand. Plugin names must be lowercase and may only contain letters, digits and dashes, matching the `mvt-plugin-` package naming convention. MVT creates the @@ -38,9 +37,8 @@ leaves a partially written settings file behind. Everything else a plugin keeps on disk, such as a cache, a downloaded artifact or synchronization state, belongs in the folder returned by the `data_folder()` -class method of the plugin's settings class, or by -`mvt.common.plugin_config.plugin_data_folder()` called with the plugin name if -the plugin has no settings class: +class method of the plugin's settings class, or by `plugin_data_folder()` from +`mvt.plugin`, called with the plugin name if the plugin has no settings class: ``` ~/.local/share/mvt/plugin-data// # Linux @@ -76,7 +74,7 @@ defaults: ```python from typing import Optional -from mvt.common.plugin_config import MVTPluginSettings +from mvt.plugin import MVTPluginSettings class ExamplePluginSettings(MVTPluginSettings): @@ -94,12 +92,15 @@ from datetime import datetime, timezone import click +from mvt.plugin import plugin_env_prefix + def sync(): settings = ExamplePluginSettings.load() if not settings.API_KEY: + prefix = plugin_env_prefix(settings.plugin_name) raise click.ClickException( - "No API key configured. Set MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY or " + f"No API key configured. Set {prefix}API_KEY or " "run 'example-plugin configure'." ) @@ -107,6 +108,9 @@ def sync(): settings.save() ``` +The message builds the variable name with `plugin_env_prefix()`, see +[Environment Variables](#environment-variables). + A missing settings file is not an error: the plugin then runs on the field defaults and on whatever the environment provides. `save()` only persists the values that differ from the defaults, and it never touches MVT's `config.yaml`. @@ -129,6 +133,10 @@ export MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY=... export MVT_PLUGIN_EXAMPLE_PLUGIN_MAX_RESULTS=50 ``` +Do not repeat the plugin name in a field name: a plugin named `example-scanner` +with a `SCANNER_API_KEY` field asks the user for +`MVT_PLUGIN_EXAMPLE_SCANNER_SCANNER_API_KEY`. Name the field `API_KEY`. + Settings resolve in this order, from highest to lowest priority: 1. Arguments passed to the settings class directly, such as diff --git a/docs/ios/sysdiagnose.md b/docs/ios/sysdiagnose.md index 6488bc24..d0d84fb3 100644 --- a/docs/ios/sysdiagnose.md +++ b/docs/ios/sysdiagnose.md @@ -1,30 +1,32 @@ # 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. +custom MVT modules. MVT does not include built-in sysdiagnose modules. The +command runs the modules of the installed +[plugin packages](../development/index.md#installed-module-packages) which +declare support for it. Install at least one such package first. 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 \ +mvt-ios check-sysdiagnose --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. +`--list-modules` to display the eligible 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`. +Extend `SysdiagnoseExtraction` from `mvt.plugin`, see +[Writing a module](../development/index.md#writing-a-module). The module reads +the archive the same way whether MVT was given a folder or a tar archive. It +declares the command in `supported_commands`. While writing one, +[load it from its file](../development/index.md#developing-modules-locally). ```python -from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction +from mvt.plugin import SysdiagnoseExtraction class ExampleSysdiagnoseModule(SysdiagnoseExtraction): @@ -44,7 +46,9 @@ class ExampleSysdiagnoseModule(SysdiagnoseExtraction): 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`. +MVT extracts a tar archive first. It calls `from_sysdiagnose_folder()` on each +module before `run()`. `ips_files` lists the IPS crash reports. + +`_get_files_by_pattern()` and `_get_file_content()` are internal helpers of the +base class. Use them to read the archive. Their names and signatures can change +between releases. See `src/mvt/ios/modules/sysdiagnose/base.py`. diff --git a/src/mvt/common/cli_plugins.py b/src/mvt/common/cli_plugins.py index 47352228..57f71813 100644 --- a/src/mvt/common/cli_plugins.py +++ b/src/mvt/common/cli_plugins.py @@ -15,6 +15,8 @@ from typing import Iterable import click +from .module_loader import CUSTOM_COMMAND_MODULE_PREFIX + IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins" ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins" # Commands in this group are registered on the platform-neutral mvt command only. @@ -57,7 +59,7 @@ class BrokenPluginCommand(click.Command): 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}" + return f"{CUSTOM_COMMAND_MODULE_PREFIX}{path.stem}_{digest}" def _iter_command_files(path: Path) -> Iterable[Path]: diff --git a/src/mvt/common/module_loader.py b/src/mvt/common/module_loader.py index 37f447d9..51f2b800 100644 --- a/src/mvt/common/module_loader.py +++ b/src/mvt/common/module_loader.py @@ -10,6 +10,7 @@ import inspect import json import logging import os +import re import sys from dataclasses import dataclass from functools import lru_cache @@ -26,6 +27,9 @@ EXTERNAL_LOGGER_NAMESPACE = "mvt.ext" PLUGIN_PACKAGE_PREFIX = "mvt_plugin_" _ORIGIN_ATTRIBUTE = "_mvt_module_origin" _PATH_MODULE_PREFIX = "_mvt_custom_module_" +# Shared with cli_plugins, which names a loaded command file this way. +CUSTOM_COMMAND_MODULE_PREFIX = "_mvt_custom_command_" +_LOADED_FILE_DIGEST = re.compile(r"_[0-9a-f]{16}$") log = logging.getLogger(__name__) @@ -66,6 +70,25 @@ def _module_name_for_path(path: Path) -> str: return f"{_PATH_MODULE_PREFIX}{path.stem}_{digest}" +def _is_builtin_logger_name(name: str) -> bool: + return name == "mvt" or name.startswith("mvt.") + + +def _loaded_file_stem(name: str, prefix: str) -> str: + """Recover a loaded file's name from the import name MVT gave it.""" + return _LOADED_FILE_DIGEST.sub("", name[len(prefix) :]) + + +def _external_logger_name(name: str) -> str: + """Return the "mvt.ext" logger name external code logs under.""" + top_level, separator, rest = name.partition(".") + if top_level.startswith(PLUGIN_PACKAGE_PREFIX) and len(top_level) > len( + PLUGIN_PACKAGE_PREFIX + ): + name = top_level[len(PLUGIN_PACKAGE_PREFIX) :] + separator + rest + return f"{EXTERNAL_LOGGER_NAMESPACE}.{name}" + + def get_module_logger(module_class: type[MVTModule]) -> logging.Logger: """Return the logger a module's records should be emitted through. @@ -80,19 +103,35 @@ def get_module_logger(module_class: type[MVTModule]) -> logging.Logger: "mvt_plugin_" naming convention log under "mvt.ext.". """ name = module_class.__module__ - if name == "mvt" or name.startswith("mvt."): + if _is_builtin_logger_name(name): return logging.getLogger(name) if name.startswith(_PATH_MODULE_PREFIX): - name = Path(get_module_origin(module_class).name).stem - else: - top_level, separator, rest = name.partition(".") - if top_level.startswith(PLUGIN_PACKAGE_PREFIX) and len(top_level) > len( - PLUGIN_PACKAGE_PREFIX - ): - name = top_level[len(PLUGIN_PACKAGE_PREFIX) :] + separator + rest + file_name = Path(get_module_origin(module_class).name).stem + return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{file_name}") - return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{name}") + return logging.getLogger(_external_logger_name(name)) + + +def get_plugin_logger(name: str) -> logging.Logger: + """Return a general logger for use in custom MVT plugins. + + Call it with ``__name__``. The logger sits under "mvt.ext". That is + where get_module_logger() puts module classes. A file loaded with + --load-module or --load-command is named after the file. + """ + if _is_builtin_logger_name(name): + return logging.getLogger(name) + + # A file loaded with --load-module or --load-command is imported under a + # mangled name. Log it under the file it came from. get_module_logger() + # does the same for the module classes such a file defines. + for prefix in (_PATH_MODULE_PREFIX, CUSTOM_COMMAND_MODULE_PREFIX): + if name.startswith(prefix): + stem = _loaded_file_stem(name, prefix) + return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{stem}") + + return logging.getLogger(_external_logger_name(name)) def _iter_module_files(path: Path) -> Iterable[Path]: diff --git a/src/mvt/plugin.py b/src/mvt/plugin.py new file mode 100644 index 00000000..9257dc63 --- /dev/null +++ b/src/mvt/plugin.py @@ -0,0 +1,80 @@ +# 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/ + +"""Stable functions and modules which plugins can import. + +Anything else in mvt can be imported too, and may change +between releases without notice. +""" + +from mvt.android.modules.androidqf.base import AndroidQFModule +from mvt.android.modules.backup.base import BackupModule as AndroidBackupModule +from mvt.android.modules.bugreport.base import BugReportModule +from mvt.common.alerts import Alert, AlertLevel +from mvt.common.command import Command +from mvt.common.config import settings +from mvt.common.module import DatabaseCorruptedError, DatabaseNotFoundError, MVTModule +from mvt.common.module_loader import get_plugin_logger +from mvt.common.module_types import ( + ModuleAtomicResult, + ModuleResults, + ModuleSerializedResult, +) +from mvt.common.plugin_config import ( + MVTPluginSettings, + PluginConfigLoadError, + plugin_config_path, + plugin_data_folder, + plugin_env_prefix, +) +from mvt.common.utils import ( + convert_chrometime_to_datetime, + convert_datetime_to_iso, + convert_mactime_to_datetime, + convert_mactime_to_iso, + convert_unix_to_iso, + convert_unix_to_utc_datetime, +) +from mvt.common.version import MVT_VERSION +from mvt.ios.modules.base import IOSExtraction +from mvt.ios.modules.sysdiagnose.base import SysdiagnoseExtraction + +__all__ = [ + # Classes a plugin subclasses. + "MVTModule", + "IOSExtraction", + "SysdiagnoseExtraction", + "AndroidQFModule", + "AndroidBackupModule", + "BugReportModule", + "Command", + # Results and alerts. + "ModuleAtomicResult", + "ModuleResults", + "ModuleSerializedResult", + "Alert", + "AlertLevel", + # Errors a module raises. + "DatabaseNotFoundError", + "DatabaseCorruptedError", + # Settings. + "settings", + "MVTPluginSettings", + "PluginConfigLoadError", + "plugin_config_path", + "plugin_data_folder", + "plugin_env_prefix", + # Logging. + "get_plugin_logger", + # Timestamps. + "convert_chrometime_to_datetime", + "convert_datetime_to_iso", + "convert_mactime_to_datetime", + "convert_mactime_to_iso", + "convert_unix_to_iso", + "convert_unix_to_utc_datetime", + # MVT's version. + "MVT_VERSION", +] diff --git a/tests/common/test_module_loader.py b/tests/common/test_module_loader.py index dec636ef..527d01cc 100644 --- a/tests/common/test_module_loader.py +++ b/tests/common/test_module_loader.py @@ -1,9 +1,14 @@ +from pathlib import Path + import pytest +from mvt.common.cli_plugins import _module_name_for_path as _command_module_name from mvt.common.module import MVTModule from mvt.common.module_loader import ( CustomModuleLoadError, + _module_name_for_path, get_module_logger, + get_plugin_logger, load_custom_modules, load_custom_modules_from_path, module_supports_command, @@ -168,9 +173,9 @@ def test_get_module_logger_strips_the_plugin_package_prefix(): class PluginModule(MVTModule): pass - PluginModule.__module__ = "mvt_plugin_amnesty_custom.ios.custom" + PluginModule.__module__ = "mvt_plugin_example_org.ios.custom" - assert get_module_logger(PluginModule).name == "mvt.ext.amnesty_custom.ios.custom" + assert get_module_logger(PluginModule).name == "mvt.ext.example_org.ios.custom" def test_get_module_logger_only_strips_the_prefix_from_the_top_level(): @@ -187,3 +192,25 @@ def test_get_module_logger_names_path_modules_after_their_file(tmp_path): module = load_custom_modules_from_path(str(module_path))[0] assert get_module_logger(module).name == "mvt.ext.my_custom_module" + + +def test_get_plugin_logger_uses_the_same_namespace_as_modules(): + assert ( + get_plugin_logger("mvt_plugin_example_org.commands.summarize").name + == "mvt.ext.example_org.commands.summarize" + ) + assert get_plugin_logger("example_plugin.cli").name == "mvt.ext.example_plugin.cli" + + +def test_get_plugin_logger_keeps_builtin_names(): + assert get_plugin_logger("mvt.ios.cli").name == "mvt.ios.cli" + + +def test_get_plugin_logger_names_loaded_files_after_the_file(): + # A file loaded with --load-command or --load-module is imported under a + # mangled name. The log names the file instead. + command_name = _command_module_name(Path("/tmp/case_summary.py")) + module_name = _module_name_for_path(Path("/tmp/my_custom_module.py")) + + assert get_plugin_logger(command_name).name == "mvt.ext.case_summary" + assert get_plugin_logger(module_name).name == "mvt.ext.my_custom_module" diff --git a/tests/common/test_plugin.py b/tests/common/test_plugin.py new file mode 100644 index 00000000..255f2e85 --- /dev/null +++ b/tests/common/test_plugin.py @@ -0,0 +1,34 @@ +# 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 mvt.plugin +from mvt.android.modules.backup.base import BackupModule +from mvt.common.config import settings + +from ..plugin_fixtures import run_isolated_python + + +def test_the_exported_names_are_the_public_names(): + public = {name for name in vars(mvt.plugin) if not name.startswith("_")} + + assert public == set(mvt.plugin.__all__) + assert mvt.plugin.settings is settings + assert mvt.plugin.AndroidBackupModule is BackupModule + + +def test_the_surface_imports_before_anything_else_of_mvt(tmp_path): + # A plugin can import the surface as its first import of MVT. The + # subprocess gets a temporary home because importing MVT writes its + # configuration file. + result = run_isolated_python( + "from mvt.plugin import IOSExtraction, MVT_VERSION, settings\n" + "assert MVT_VERSION\n" + "assert settings.NETWORK_TIMEOUT > 0\n" + "assert IOSExtraction.__name__ == 'IOSExtraction'\n", + home=tmp_path, + ) + + assert result.returncode == 0, result.stderr + assert result.stderr == "" diff --git a/tests/plugin_fixtures.py b/tests/plugin_fixtures.py index ab3359b8..ef989dc8 100644 --- a/tests/plugin_fixtures.py +++ b/tests/plugin_fixtures.py @@ -73,6 +73,7 @@ def run_isolated_python( } isolated_environment["HOME"] = str(home) isolated_environment["XDG_CONFIG_HOME"] = str(home / "config") + isolated_environment["XDG_DATA_HOME"] = str(home / "data") if site_path is not None: isolated_environment["PYTHONPATH"] = str(site_path) isolated_environment.update(environment) From 47ac8a5a85acae96e1059815b6cef56384915c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:17 +0200 Subject: [PATCH 060/109] Allow custom modules to replace built-in modules (#902) A custom module which extends a built-in one ran alongside it, and both wrote to the same results file when they shared a slug, with the run order deciding the surviving content. Custom modules can now name the module class they supersede in a `replaces` attribute. When both are available to a command, the named module is dropped from the run and the substitution is logged with the origin of the replacement, so it is recorded in command.log. Only the replacements which are applied are reported: a declaration from a disabled module is ignored, so that replacing a module cannot silently disable it, and modules which replace each other in a cycle all keep running and replace nothing, with a warning naming every one of them. Dependencies are remapped along with the modules themselves: a module depending on a replaced class is ordered against, and receives the results of, the module which took its place. Replacing a module which others depend on therefore does not make that dependency unavailable. The remapping applies wherever dependencies are read, so a replacement which cannot run is skipped like any other module with an unavailable dependency, and takes the modules depending on the module it replaced with it. Those warnings name the dependency the author declared as well as the module which replaces it. A replacement does not have to keep the class name of the module it replaces, so `--module` now falls back to the name of a replaced module and runs its replacement. A name which matches no module at all stops the run with a warning instead of silently analyzing nothing, as does a selection left with nothing to run once skipped modules are dropped. Sharing a slug outside a replacement stays possible and is now reported. Two modules writing to the same results file is a forensic-integrity problem rather than an error, so both still run and a warning names them, where each came from, and the file the later one overwrites. Taking over the slug of a replaced module is not reported, because that module is no longer part of the run. --- docs/development/index.md | 53 ++++ src/mvt/common/command.py | 281 ++++++++++++++++++-- src/mvt/common/module.py | 5 + tests/common/test_command.py | 490 +++++++++++++++++++++++++++++++++++ 4 files changed, 813 insertions(+), 16 deletions(-) diff --git a/docs/development/index.md b/docs/development/index.md index d1c167ff..cfd58930 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -176,6 +176,59 @@ not, MVT skips the module with a warning. They are the same records it writes to `.json`. Typed results per module are planned. +### Replacing a built-in module + +A custom module which extends a built-in one runs alongside it, and when the +two share a slug they write to the same results file. MVT warns about that +collision, naming both modules, where each came from and the file the later one +overwrites, but it runs them both. Set `replaces` to the class the module +supersedes to take that module's place instead: when both are available to a +command, the named module is dropped from the run. + +```python +from mvt.ios.modules.backup import Manifest as BuiltinManifest + + +class Manifest(BuiltinManifest): + supported_commands = (("ios", "check-backup"),) + replaces = BuiltinManifest +``` + +Keeping the class name of the replaced module, as above, keeps the name +`--module` selects the module by and the slug its results file is named after. +A replacement with a different class name writes to a file named after its own +slug, unless it sets `slug` to the slug of the module it replaces, and +`--module` still selects it by the name of the replaced module, which MVT logs. +Taking over the slug of a replaced module is not a collision and is not warned +about, because that module is no longer part of the run. + +Modules which depend on the replaced module receive the replacement instead, so +`get_dependency_results(BuiltinManifest)` returns the replacement's results. A +module must not depend on the module it replaces: that module does not run, so +the replacement has to produce the data itself, and MVT logs a warning when a +module declares both. + +A replacement takes on the obligations of the module it replaces. If it +declares a dependency the command does not provide, it is skipped like any +other module with an unavailable dependency, and the module it replaced stays +out of the run: neither of them produces results, and the modules depending on +the replaced module are skipped as well. + +Subclassing the replaced module is not required, but a replacement which is not +a subclass is logged with a warning, because its results may not be what the +modules depending on the replaced module expect. Naming a module the command +does not run has no effect, and modules which replace each other in a cycle all +keep running and replace nothing. Every applied substitution is logged, so it +is recorded in `command.log` when the command runs with an `--output` folder. + +Every command resolves replacements on its own. `check-iocs` matches stored +results files against the slugs of the modules available for that command, so a +replacement checks the indicators of its own results only if it also declares +the `("ios", "check-iocs")` pair; otherwise the built-in module it replaced +re-checks the file. It also matches `--module` on the class name only, so pass +a differently named replacement's own name there, not the name of the module +it replaces. + ### Importing from MVT Import from `mvt.plugin` if it has what you need. The names it exports are diff --git a/src/mvt/common/command.py b/src/mvt/common/command.py index f2f211d0..12429417 100644 --- a/src/mvt/common/command.py +++ b/src/mvt/common/command.py @@ -72,6 +72,10 @@ class Command: # down a password to decrypt a backup or flags which are need by some modules. self.module_options = module_options if module_options else {} + # This dictionary maps the modules which were replaced by a module + # declaring `replaces` to the module which took their place. + self.module_replacements: dict[type[MVTModule], type[MVTModule]] = {} + # This list will contain all executed modules. # We can use this to reference e.g. self.executed[0].results. self.executed: list[MVTModule] = [] @@ -257,7 +261,174 @@ class Command: if module not in deduplicated: deduplicated.append(module) - return deduplicated + available = self._apply_replacements(deduplicated) + self._warn_about_slug_collisions(available) + + return available + + def _warn_about_slug_collisions(self, modules: list[type[MVTModule]]) -> None: + """Report modules writing their results to the same file. + + Results are stored in a file named after the module slug, so two + modules sharing one silently overwrite each other. Replacements are + already resolved here, so a module deliberately taking over the slug + of the module it replaces is not reported: the module it replaced is + no longer part of the run. + """ + modules_by_slug: dict[str, type[MVTModule]] = {} + for module in modules: + slug = module.get_slug() + first = modules_by_slug.setdefault(slug, module) + if first is module: + continue + + self.log.warning( + "Modules %s from %s and %s from %s both use the slug %s. If " + "both run, whichever runs last overwrites the results of the " + "other in %s.json.", + first.__name__, + get_module_origin(first).label, + module.__name__, + get_module_origin(module).label, + slug, + slug, + ) + + def _declared_replacements( + self, modules: list[type[MVTModule]] + ) -> dict[type[MVTModule], type[MVTModule]]: + """Return the replacements declared by the given modules.""" + replacements: dict[type[MVTModule], type[MVTModule]] = {} + for module in modules: + replaced = module.replaces + if replaced is None or replaced is module: + continue + + if not module.enabled: + # Replacing a module must not disable it, as a disabled + # replacement never runs in its place. + self.log.debug( + "Module %s is disabled and does not replace module %s.", + module.__name__, + replaced.__name__, + ) + continue + + if replaced not in modules: + # A module can support several commands while the module it + # replaces is only available in some of them. + self.log.debug( + "Module %s replaces module %s, which is not available " + "for the %s command.", + module.__name__, + replaced.__name__, + self.name, + ) + continue + + if replaced in replacements: + self.log.warning( + "Modules %s and %s both replace module %s. Both of them " + "will run, %s will not, and modules depending on %s will " + "use the results of %s. Replacements which share the slug " + "of %s overwrite each other's results file.", + replacements[replaced].__name__, + module.__name__, + replaced.__name__, + replaced.__name__, + replaced.__name__, + replacements[replaced].__name__, + replaced.__name__, + ) + continue + + replacements[replaced] = module + + return replacements + + def _drop_replacement_cycles( + self, replacements: dict[type[MVTModule], type[MVTModule]] + ) -> None: + """Undo the replacements between modules which replace each other.""" + cyclic: set[type[MVTModule]] = set() + for replaced in replacements: + walked: list[type[MVTModule]] = [] + module = replaced + while module in replacements and module not in cyclic: + if module in walked: + cyclic.update(walked[walked.index(module) :]) + break + walked.append(module) + module = replacements[module] + + if not cyclic: + return + + self.log.warning( + "Modules %s replace each other in a cycle. None of them replaces " + "anything and all of them will run.", + ", ".join(sorted(module.__name__ for module in cyclic)), + ) + for module in cyclic: + replacements.pop(module, None) + + def _log_replacement( + self, + replaced: type[MVTModule], + module: type[MVTModule], + replacement: type[MVTModule], + ) -> None: + """Report an applied replacement, and any problem with it.""" + if not issubclass(module, replaced): + self.log.warning( + "Module %s replaces module %s but is not a subclass of it. " + "Its results might not be compatible with what modules " + "depending on %s expect.", + module.__name__, + replaced.__name__, + replaced.__name__, + ) + + if replaced in module.dependencies: + self.log.warning( + "Module %s depends on module %s, which it also replaces. The " + "dependency cannot be satisfied: a replacement has to produce " + "that data itself.", + module.__name__, + replaced.__name__, + ) + + self.log.info( + "Module %s from %s replaces module %s from %s.", + replacement.__name__, + get_module_origin(replacement).label, + replaced.__name__, + get_module_origin(replaced).label, + ) + + def _apply_replacements( + self, modules: list[type[MVTModule]] + ) -> list[type[MVTModule]]: + """Drop the modules superseded by a module declaring `replaces`.""" + declared = self._declared_replacements(modules) + self._drop_replacement_cycles(declared) + + replacements: dict[type[MVTModule], type[MVTModule]] = {} + for replaced, module in declared.items(): + # Follow chains of replacements, so that a dependency on a + # replaced module always resolves to a module which is part of + # the run. + replacement = module + while replacement in declared: + replacement = declared[replacement] + + # Only the replacements which are applied are reported, so that + # the record matches the modules which actually run. + self._log_replacement(replaced, module, replacement) + replacements[replaced] = replacement + + self.module_replacements = replacements + return [module for module in modules if module not in replacements] def init(self) -> None: raise NotImplementedError @@ -317,6 +488,80 @@ class Command: console.print("") console.print(panel) + def _module_dependencies( + self, module: type[MVTModule] + ) -> list[tuple[type[MVTModule], type[MVTModule]]]: + """Return the (declared, resolved) dependencies of a module. + + A dependency on a module which was replaced is resolved to the module + which took its place. A module which replaces one of its own + dependencies is not made to depend on itself, while a module which + declares itself as a dependency is left alone and still fails the + circular dependency check. + """ + dependencies = [] + for dependency in module.dependencies: + resolved = self.module_replacements.get(dependency, dependency) + if resolved is module and dependency is not module: + continue + dependencies.append((dependency, resolved)) + + return dependencies + + @staticmethod + def _dependency_name( + declared: type[MVTModule], resolved: type[MVTModule] + ) -> str: + """Return how a dependency is named in the messages about it. + + A dependency is named as the module which declared it wrote it, and, + when that module was replaced, as the module which runs in its place. + """ + if declared is resolved: + return declared.__name__ + + return f"{resolved.__name__} (replacing module {declared.__name__})" + + def _selected_modules( + self, modules: list[type[MVTModule]] + ) -> Optional[list[type[MVTModule]]]: + """Return the modules explicitly requested, or all the enabled ones. + + Returns None when a module was requested by name and no module of + that name can be run. + """ + if not self.module_name: + return [module for module in modules if module.enabled] + + selected = [ + module for module in modules if module.__name__ == self.module_name + ] + + # A module replacing another one does not have to keep its name, so + # the name of a replaced module selects its replacement. + if not selected: + for replaced, replacement in self.module_replacements.items(): + if replaced.__name__ != self.module_name or replacement in selected: + continue + self.log.info( + "Module %s was replaced by module %s, which is run " + "in its place.", + replaced.__name__, + replacement.__name__, + ) + selected.append(replacement) + + if not selected: + self.log.warning( + "No module named %s is available for the %s command. " + "No modules will be run.", + self.module_name, + self.name, + ) + return None + + return selected + def _skipped_modules( self, required: list[type[MVTModule]], @@ -349,15 +594,18 @@ class Command: if module in skipped: continue - for dependency in module.dependencies: + for declared, dependency in self._module_dependencies(module): if dependency not in module_indexes: - skipped[module] = (module, dependency) + # A replaced dependency always resolves to a module of + # this command, so an unavailable one is always the + # module class the author declared. + skipped[module] = (module, declared) changed = True self.log.warning( "Module %s will be SKIPPED: it depends on module " "%s, which is not available in this command.%s", module.__name__, - dependency.__name__, + declared.__name__, remainder, ) break @@ -372,7 +620,7 @@ class Command: "module %s, itself skipped for depending on " "unavailable module %s.%s", module.__name__, - dependency.__name__, + self._dependency_name(declared, dependency), missing.__name__, remainder, ) @@ -383,7 +631,7 @@ class Command: "module %s, which depends on unavailable " "module %s.%s", module.__name__, - dependency.__name__, + self._dependency_name(declared, dependency), root.__name__, missing.__name__, remainder, @@ -397,12 +645,9 @@ class Command: 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] + selected = self._selected_modules(modules) + if selected is None: + return None required: set[type[MVTModule]] = set() pending = list(selected) @@ -411,7 +656,7 @@ class Command: if module in required: continue required.add(module) - for dependency in module.dependencies: + for _, dependency in self._module_dependencies(module): # Unavailable dependencies are reported by _skipped_modules(). if dependency in module_indexes: pending.append(dependency) @@ -424,13 +669,14 @@ class Command: "Every selected module was skipped for an unavailable " "dependency. No modules will be run." ) + return None dependents: dict[type[MVTModule], list[type[MVTModule]]] = { module: [] for module in runnable } indegree = {module: 0 for module in runnable} for module in runnable: - for dependency in module.dependencies: + for _, dependency in self._module_dependencies(module): if dependency not in indegree: continue dependents[dependency].append(module) @@ -486,9 +732,12 @@ class Command: module_options=self.module_options, log=module_logger, ) + # Dependencies are keyed by the module class they declare, even + # when it was replaced, so that a module asking for the results + # of a replaced module receives those of its replacement. m.dependency_modules = { - dependency: executed_by_type[dependency] - for dependency in module.dependencies + dependency: executed_by_type[resolved] + for dependency, resolved in self._module_dependencies(module) } if self.iocs.total_ioc_count: diff --git a/src/mvt/common/module.py b/src/mvt/common/module.py index cd127a60..1257aa6a 100644 --- a/src/mvt/common/module.py +++ b/src/mvt/common/module.py @@ -46,6 +46,11 @@ class MVTModule: slug: Optional[str] = None dependencies: Sequence[type["MVTModule"]] = () supported_commands: Sequence[tuple[str, str]] = () + # A custom module can name a module class it supersedes, usually a + # built-in one. When both are available to a command, the named module is + # dropped from the run and this module takes its place, including in the + # dependencies of any other module. + replaces: Optional[type["MVTModule"]] = None def __init__( self, diff --git a/tests/common/test_command.py b/tests/common/test_command.py index 4835fd32..8e09f57c 100644 --- a/tests/common/test_command.py +++ b/tests/common/test_command.py @@ -68,6 +68,123 @@ class CustomDependsOnBuiltin(RecordingModule): dependencies = (FirstModule,) +class ReplacementModule(FirstModule): + supported_commands = (("ios", "check-backup"),) + replaces = FirstModule + + def run(self): + super().run() + self.results = ["replacement"] + + +class OtherReplacementModule(FirstModule): + supported_commands = (("ios", "check-backup"),) + replaces = FirstModule + + +class UnrelatedReplacementModule(RecordingModule): + supported_commands = (("ios", "check-backup"),) + replaces = IndependentModule + + +class ReplacementOfReplacementModule(ReplacementModule): + supported_commands = (("ios", "check-backup"),) + replaces = ReplacementModule + + def run(self): + super().run() + self.results = ["replacement of replacement"] + + +class ReplacesOwnDependencyModule(FirstModule): + supported_commands = (("ios", "check-backup"),) + replaces = FirstModule + dependencies = (FirstModule,) + + +class DisabledReplacementModule(FirstModule): + supported_commands = (("ios", "check-backup"),) + replaces = FirstModule + enabled = False + + +class MutualReplacementOne(RecordingModule): + supported_commands = (("ios", "check-backup"),) + + +class MutualReplacementTwo(RecordingModule): + supported_commands = (("ios", "check-backup"),) + replaces = MutualReplacementOne + + +MutualReplacementOne.replaces = MutualReplacementTwo + + +class CycleOneModule(RecordingModule): + supported_commands = (("ios", "check-backup"),) + + +class CycleTwoModule(RecordingModule): + supported_commands = (("ios", "check-backup"),) + replaces = CycleOneModule + + +class CycleThreeModule(RecordingModule): + supported_commands = (("ios", "check-backup"),) + replaces = CycleTwoModule + + +CycleOneModule.replaces = CycleThreeModule + + +class UnavailableDependencyModule(RecordingModule): + supported_commands = (("ios", "check-backup"),) + + +class ReplacementMissingDependency(FirstModule): + supported_commands = (("ios", "check-backup"),) + replaces = FirstModule + dependencies = (UnavailableDependencyModule,) + + +class SharedSlugModule(RecordingModule): + supported_commands = (("ios", "check-backup"),) + slug = "shared_slug" + + +class OtherSharedSlugModule(RecordingModule): + supported_commands = (("ios", "check-backup"),) + slug = "shared_slug" + + +class ReplacementKeepingTheSlug(FirstModule): + supported_commands = (("ios", "check-backup"),) + replaces = FirstModule + slug = "first_module" + + +class SameNameReplacementModule(FirstModule): + supported_commands = (("ios", "check-backup"),) + replaces = FirstModule + + def run(self): + super().run() + self.results = ["replacement"] + + +# Modules replacing a built-in one usually keep its class name, which is the +# name `--module` matches on. +SameNameReplacementModule.__name__ = "FirstModule" + + +def logged_substitutions(caplog) -> list[str]: + return [ + record.getMessage() + for record in caplog.records + if record.levelno == logging.INFO and "replaces module" in record.getMessage() + ] + + class RecordingCommand(Command): def init(self): self.initialized = True @@ -303,3 +420,376 @@ class TestCommand: cmd.run() assert RecordingModule.run_order == ["FirstModule", "CustomDependsOnBuiltin"] + + def test_custom_module_replaces_builtin(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule, IndependentModule] + cmd.custom_modules = [ReplacementModule] + + with caplog.at_level(logging.INFO): + cmd.run() + + assert RecordingModule.run_order == ["IndependentModule", "ReplacementModule"] + assert cmd.module_replacements == {FirstModule: ReplacementModule} + assert ( + "Module ReplacementModule from" in caplog.text + and "replaces module FirstModule" in caplog.text + ) + + def test_modules_without_replaces_all_run(self): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule] + cmd.custom_modules = [CustomIOSBackupModule] + + cmd.run() + + assert RecordingModule.run_order == ["FirstModule", "CustomIOSBackupModule"] + assert cmd.module_replacements == {} + + def test_unavailable_replaced_module_is_ignored(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [IndependentModule] + cmd.custom_modules = [ReplacementModule] + + with caplog.at_level(logging.INFO): + cmd.run() + + assert RecordingModule.run_order == ["IndependentModule", "ReplacementModule"] + assert cmd.module_replacements == {} + assert "replaces module FirstModule" not in caplog.text + + def test_dependencies_are_resolved_to_the_replacement(self): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [SecondModule, FirstModule] + cmd.custom_modules = [ReplacementModule] + + cmd.run() + + assert RecordingModule.run_order == ["ReplacementModule", "SecondModule"] + second = next( + module for module in cmd.executed if isinstance(module, SecondModule) + ) + assert isinstance(second.dependency_modules[FirstModule], ReplacementModule) + assert second.results == ["replacement", "second"] + + def test_replacement_with_unavailable_dependency_skips_its_dependents( + self, caplog + ): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule, SecondModule, IndependentModule] + cmd.custom_modules = [ReplacementMissingDependency] + + with caplog.at_level(logging.INFO): + cmd.run() + + # The replacement cannot run, and the module it replaced was dropped + # from the run by the replacement, so neither of them produces + # results and the module depending on the replaced one is skipped. + assert RecordingModule.run_order == ["IndependentModule"] + assert ( + "Module ReplacementMissingDependency will be SKIPPED: it depends " + "on module UnavailableDependencyModule, which is not available in " + "this command." in caplog.text + ) + # The skipped dependent is told which module it actually depends on, + # and the module class its author declared. + assert ( + "Module SecondModule will be SKIPPED: it depends on module " + "ReplacementMissingDependency (replacing module FirstModule), " + "itself skipped for depending on unavailable module " + "UnavailableDependencyModule." in caplog.text + ) + + def test_selected_replacement_with_unavailable_dependency_runs_nothing( + self, caplog, tmp_path + ): + cmd = RecordingCommand(module_name="FirstModule", results_path=str(tmp_path)) + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule, IndependentModule] + cmd.custom_modules = [ReplacementMissingDependency] + + with caplog.at_level(logging.INFO): + cmd.run() + + assert RecordingModule.run_order == [] + assert not hasattr(cmd, "initialized") + assert ( + "Module FirstModule was replaced by module " + "ReplacementMissingDependency, which is run in its place." + in caplog.text + ) + assert "Module ReplacementMissingDependency will be SKIPPED" in caplog.text + assert "No modules will be run" in caplog.text + # Nothing else was selected, so the warnings must not promise that the + # analysis continues right before saying that it does not. + assert "The rest of the analysis will still run" not in caplog.text + # No module ran, so no results were stored next to the command log. + assert [path.name for path in tmp_path.iterdir()] == ["command.log"] + + def test_chained_replacements_are_resolved(self): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [SecondModule, FirstModule] + cmd.custom_modules = [ReplacementModule, ReplacementOfReplacementModule] + + cmd.run() + + assert RecordingModule.run_order == [ + "ReplacementOfReplacementModule", + "SecondModule", + ] + second = next( + module for module in cmd.executed if isinstance(module, SecondModule) + ) + assert second.results == ["replacement of replacement", "second"] + + def test_replacement_which_is_not_a_subclass_warns(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [IndependentModule] + cmd.custom_modules = [UnrelatedReplacementModule] + + with caplog.at_level(logging.WARNING): + cmd.run() + + assert RecordingModule.run_order == ["UnrelatedReplacementModule"] + assert "is not a subclass of it" in caplog.text + + def test_multiple_modules_replacing_the_same_module_warn(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule] + cmd.custom_modules = [ReplacementModule, OtherReplacementModule] + + with caplog.at_level(logging.WARNING): + cmd.run() + + assert RecordingModule.run_order == [ + "ReplacementModule", + "OtherReplacementModule", + ] + assert cmd.module_replacements == {FirstModule: ReplacementModule} + assert ( + "Modules ReplacementModule and OtherReplacementModule both replace " + "module FirstModule. Both of them will run, FirstModule will not, " + "and modules depending on FirstModule will use the results of " + "ReplacementModule." in caplog.text + ) + assert "overwrite each other's results file" in caplog.text + + def test_module_replacing_its_own_dependency_runs(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule] + cmd.custom_modules = [ReplacesOwnDependencyModule] + + with caplog.at_level(logging.WARNING): + cmd.run() + + assert RecordingModule.run_order == ["ReplacesOwnDependencyModule"] + assert ( + "Module ReplacesOwnDependencyModule depends on module FirstModule, " + "which it also replaces" in caplog.text + ) + + def test_literal_self_dependency_is_still_circular(self, caplog): + class SelfDependentModule(RecordingModule): + pass + + SelfDependentModule.dependencies = (SelfDependentModule,) + + cmd = RecordingCommand() + cmd.modules = [SelfDependentModule] + + 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_disabled_replacement_keeps_the_replaced_module(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule] + cmd.custom_modules = [DisabledReplacementModule] + + with caplog.at_level(logging.INFO): + cmd.run() + + assert RecordingModule.run_order == ["FirstModule"] + assert cmd.module_replacements == {} + assert logged_substitutions(caplog) == [] + + def test_modules_replacing_each_other_all_run(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.custom_modules = [MutualReplacementOne, MutualReplacementTwo] + + with caplog.at_level(logging.INFO): + cmd.run() + + assert RecordingModule.run_order == [ + "MutualReplacementOne", + "MutualReplacementTwo", + ] + assert cmd.module_replacements == {} + assert ( + "Modules MutualReplacementOne, MutualReplacementTwo replace each " + "other in a cycle" in caplog.text + ) + assert logged_substitutions(caplog) == [] + + def test_replacement_cycle_of_three_modules_all_run(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.custom_modules = [CycleOneModule, CycleTwoModule, CycleThreeModule] + + with caplog.at_level(logging.INFO): + cmd.run() + + assert RecordingModule.run_order == [ + "CycleOneModule", + "CycleTwoModule", + "CycleThreeModule", + ] + assert cmd.module_replacements == {} + assert ( + "Modules CycleOneModule, CycleThreeModule, CycleTwoModule replace " + "each other in a cycle" in caplog.text + ) + assert logged_substitutions(caplog) == [] + + def test_selected_replaced_module_name_runs_the_replacement(self, caplog): + cmd = RecordingCommand(module_name="FirstModule") + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule] + cmd.custom_modules = [ReplacementModule] + + with caplog.at_level(logging.INFO): + cmd.run() + + assert RecordingModule.run_order == ["ReplacementModule"] + assert ( + "Module FirstModule was replaced by module ReplacementModule" + in caplog.text + ) + + def test_unknown_selected_module_warns_and_stops(self, caplog): + cmd = RecordingCommand(module_name="NoSuchModule") + cmd.name = "check-backup" + cmd.modules = [FirstModule] + + with caplog.at_level(logging.WARNING): + cmd.run() + + assert RecordingModule.run_order == [] + assert not hasattr(cmd, "initialized") + assert ( + "No module named NoSuchModule is available for the check-backup " + "command" in caplog.text + ) + + def test_selected_module_name_matches_the_replacement(self): + cmd = RecordingCommand(module_name="FirstModule") + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule] + cmd.custom_modules = [SameNameReplacementModule] + + cmd.run() + + assert len(cmd.executed) == 1 + assert isinstance(cmd.executed[0], SameNameReplacementModule) + assert cmd.executed[0].results == ["replacement"] + + def test_list_modules_reflects_replacements(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule, IndependentModule] + cmd.custom_modules = [ReplacementModule] + + with caplog.at_level(logging.INFO): + cmd.list_modules() + + listed = [ + record.getMessage() + for record in caplog.records + if "Modules from" in record.getMessage() + ] + assert any("ReplacementModule" in message for message in listed) + assert not any("FirstModule" in message for message in listed) + + def test_modules_sharing_a_slug_are_reported(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.custom_modules = [SharedSlugModule, OtherSharedSlugModule] + + with caplog.at_level(logging.WARNING): + cmd.run() + + assert RecordingModule.run_order == [ + "SharedSlugModule", + "OtherSharedSlugModule", + ] + collisions = [ + record.getMessage() + for record in caplog.records + if "both use the slug" in record.getMessage() + ] + assert len(collisions) == 1 + assert "Modules SharedSlugModule from" in collisions[0] + assert "and OtherSharedSlugModule from" in collisions[0] + assert "both use the slug shared_slug" in collisions[0] + assert "overwrites the results of the other in shared_slug.json" in ( + collisions[0] + ) + + def test_replacement_keeping_the_replaced_slug_is_not_reported(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule] + cmd.custom_modules = [ReplacementKeepingTheSlug] + + with caplog.at_level(logging.WARNING): + cmd.run() + + # The replaced module is no longer part of the run, so taking over its + # slug is what the replacement is for, not a collision. + assert RecordingModule.run_order == ["ReplacementKeepingTheSlug"] + assert ReplacementKeepingTheSlug.get_slug() == FirstModule.get_slug() + assert "both use the slug" not in caplog.text + + def test_modules_with_distinct_slugs_are_not_reported(self, caplog): + cmd = RecordingCommand() + cmd.platform = "ios" + cmd.name = "check-backup" + cmd.modules = [FirstModule, IndependentModule] + cmd.custom_modules = [CustomIOSBackupModule] + + with caplog.at_level(logging.WARNING): + cmd.run() + + assert "both use the slug" not in caplog.text From 00d892d354d3992d1b275241310cde1af98be1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 14:47:17 +0200 Subject: [PATCH 061/109] Run check-iocs on every module which implements check_indicators() (#903) A module is part of check-iocs for a platform when it declares the check-iocs pair, as before. It is now also part of check-iocs when it overrides check_indicators() and supports at least one command of that platform. The rule lives in module_supports_command(). CmdCheckIOCS already uses that function to pick its modules, so --list-modules and --module follow it too. Built-in modules are unaffected. check-iocs takes them from IOS_CHECK_IOCS_MODULES and ANDROID_CHECK_IOCS_MODULES. A custom module which replaces a built-in module is covered by the same rule. If it subclasses the module it replaces, it inherits its check_indicators() and takes over the re-check of the results file. The "Replacing a built-in module" section of the plugin documentation is updated to say that. --- docs/development/index.md | 18 +++-- src/mvt/common/module_loader.py | 12 ++- tests/common/test_cmd_check_iocs.py | 110 +++++++++++++++++++++++++--- 3 files changed, 120 insertions(+), 20 deletions(-) diff --git a/docs/development/index.md b/docs/development/index.md index cfd58930..8b802380 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -78,7 +78,9 @@ MVT logs a warning. The nine pairs are: `check-iocs` re-checks stored results rather than an acquisition. It matches every `.json` file in the results folder to the module with that slug. -It then runs that module's `check_indicators()` again. +It then runs that module's `check_indicators()` again. A module which +implements `check_indicators()` is included in `check-iocs` for its platform. +It does not need to declare the `check-iocs` pair. ### Writing a module @@ -222,12 +224,14 @@ keep running and replace nothing. Every applied substitution is logged, so it is recorded in `command.log` when the command runs with an `--output` folder. Every command resolves replacements on its own. `check-iocs` matches stored -results files against the slugs of the modules available for that command, so a -replacement checks the indicators of its own results only if it also declares -the `("ios", "check-iocs")` pair; otherwise the built-in module it replaced -re-checks the file. It also matches `--module` on the class name only, so pass -a differently named replacement's own name there, not the name of the module -it replaces. +results files against the slugs of the modules available for that command. A +replacement which subclasses the module it replaces inherits its +`check_indicators()`. It is then part of `check-iocs` for its platform and +re-checks the results file named after its slug. A replacement with no +`check_indicators()` is not part of `check-iocs`. The built-in module it +replaced re-checks the file. `check-iocs` matches `--module` on the class name +only. Pass a differently named replacement's own name there, not the name of +the module it replaces. ### Importing from MVT diff --git a/src/mvt/common/module_loader.py b/src/mvt/common/module_loader.py index 51f2b800..d0028b8e 100644 --- a/src/mvt/common/module_loader.py +++ b/src/mvt/common/module_loader.py @@ -402,4 +402,14 @@ def module_supports_command( ) return False - return (platform, command) in {tuple(entry) for entry in supported_commands} + pairs = {tuple(entry) for entry in supported_commands} + if (platform, command) in pairs: + return True + + # A module which implements check_indicators() is re-checked by check-iocs + # for its platform. It does not need to declare the check-iocs pair. + return ( + command == "check-iocs" + and platform in {entry[0] for entry in pairs if entry} + and module_class.check_indicators is not MVTModule.check_indicators + ) diff --git a/tests/common/test_cmd_check_iocs.py b/tests/common/test_cmd_check_iocs.py index 1bdd2327..0c05c1b0 100644 --- a/tests/common/test_cmd_check_iocs.py +++ b/tests/common/test_cmd_check_iocs.py @@ -15,6 +15,7 @@ from mvt.common.cmd_check_iocs import CmdCheckIOCS from mvt.common.module import MVTModule from mvt.ios.cli import cli as ios_cli from mvt.ios.command_modules import IOS_CHECK_IOCS_MODULES +from mvt.ios.modules.backup.manifest import Manifest # Keep the banner of the group callback from checking for updates online. OFFLINE = ["--disable-update-check", "--disable-indicator-update-check"] @@ -39,34 +40,69 @@ class CustomResultsModule(MVTModule): self.checked.append(list(self.results)) +class BackupCheckerModule(MVTModule): + """An iOS module which implements check_indicators() without declaring check-iocs.""" + + slug = "backup_checker" + supported_commands = (("ios", "check-backup"),) + + checked: list = [] + + def run(self) -> None: + pass + + def check_indicators(self) -> None: + self.checked.append(list(self.results)) + + +class BugReportCheckerModule(MVTModule): + """The same for Android.""" + + slug = "bugreport_checker" + supported_commands = (("android", "check-bugreport"),) + + checked: list = [] + + def run(self) -> None: + pass + + def check_indicators(self) -> None: + self.checked.append(list(self.results)) + + class BackupOnlyModule(MVTModule): - """A custom module which does not declare check-iocs.""" + """A custom module which does not implement check_indicators().""" slug = "backup_only" supported_commands = (("ios", "check-backup"),) - def check_indicators(self) -> None: - raise AssertionError("must not be re-checked") + def run(self) -> None: + pass @pytest.mark.parametrize( - "platform, builtin_modules", - [("ios", IOS_CHECK_IOCS_MODULES), ("android", ANDROID_CHECK_IOCS_MODULES)], + "platform, builtin_modules, checker_module", + [ + ("ios", IOS_CHECK_IOCS_MODULES, BackupCheckerModule), + ("android", ANDROID_CHECK_IOCS_MODULES, BugReportCheckerModule), + ], ) def test_check_iocs_rechecks_the_stored_results_of_custom_modules( - platform, builtin_modules, tmp_path, caplog + platform, builtin_modules, checker_module, tmp_path, caplog ): # check-iocs matches every .json in the results folder to the module # with that slug, custom modules included, and runs its check_indicators() # again over the stored results. results = [{"domain": "example.org"}] (tmp_path / "custom_results.json").write_text(json.dumps(results)) + (tmp_path / f"{checker_module.slug}.json").write_text(json.dumps(results)) (tmp_path / "backup_only.json").write_text(json.dumps(results)) CustomResultsModule.checked.clear() + checker_module.checked.clear() cmd = CmdCheckIOCS( target_path=str(tmp_path), - custom_modules=[CustomResultsModule, BackupOnlyModule], + custom_modules=[CustomResultsModule, checker_module, BackupOnlyModule], platform=platform, ) cmd.modules = builtin_modules @@ -74,29 +110,79 @@ def test_check_iocs_rechecks_the_stored_results_of_custom_modules( with caplog.at_level(logging.INFO): cmd.run() + # A module which declares the check-iocs pair is re-checked. assert CustomResultsModule.checked == [results] assert ( 'Loading results from "custom_results.json" with module CustomResultsModule' in caplog.text ) - # A module declaring only check-backup is not part of check-iocs. + # So is a module which only implements check_indicators(). + assert checker_module.checked == [results] + # A module which does neither is not part of check-iocs. assert "backup_only.json" not in caplog.text -def test_check_iocs_lists_custom_modules_declaring_the_command(caplog): +@pytest.mark.parametrize( + "platform, builtin_modules, listed, not_listed", + [ + ( + "ios", + IOS_CHECK_IOCS_MODULES, + "BackupCheckerModule", + "BugReportCheckerModule", + ), + ( + "android", + ANDROID_CHECK_IOCS_MODULES, + "BugReportCheckerModule", + "BackupCheckerModule", + ), + ], +) +def test_check_iocs_lists_the_custom_modules_it_runs( + platform, builtin_modules, listed, not_listed, caplog +): cmd = CmdCheckIOCS( - custom_modules=[CustomResultsModule, BackupOnlyModule], - platform="ios", + custom_modules=[ + CustomResultsModule, + BackupCheckerModule, + BugReportCheckerModule, + BackupOnlyModule, + ], + platform=platform, ) - cmd.modules = IOS_CHECK_IOCS_MODULES + cmd.modules = builtin_modules with caplog.at_level(logging.INFO): cmd.list_modules() assert "CustomResultsModule" in caplog.text + # The module which implements check_indicators() for this platform is listed. + assert listed in caplog.text + # The one for the other platform is not, and neither is BackupOnlyModule. + assert not_listed not in caplog.text assert "BackupOnlyModule" not in caplog.text +class ReplacementManifest(Manifest): + """A replacement for a built-in module which does not declare check-iocs.""" + + supported_commands = (("ios", "check-backup"),) + replaces = Manifest + + +def test_check_iocs_uses_a_replacement_of_a_built_in_module(): + # A replacement which subclasses a built-in module inherits its + # check_indicators(). check-iocs then runs it in place of that module. + cmd = CmdCheckIOCS(custom_modules=[ReplacementManifest], platform="ios") + cmd.modules = IOS_CHECK_IOCS_MODULES + + available = cmd._available_modules() + + assert ReplacementManifest in available + assert Manifest not in available + + LOADED_MODULE = ''' from mvt.common.module import MVTModule From ee24121a7326a4cdf6162e0278cc8ebd5bafa467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 15:28:52 +0200 Subject: [PATCH 062/109] Fix redundent docs and CLI help text --- docs/development/custom_commands.md | 6 +----- src/mvt/cli.py | 6 ++---- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/docs/development/custom_commands.md b/docs/development/custom_commands.md index 3161cef1..7aac4903 100644 --- a/docs/development/custom_commands.md +++ b/docs/development/custom_commands.md @@ -73,11 +73,7 @@ rather than in MVT's own `config.yaml`. ### Commands on `mvt` -The `mvt` command hosts what belongs to neither platform: `version`, -`completion`, `plugins` and `download-iocs`. A plugin command which is not -about the acquisition of one platform, such as one which configures the plugin -or synchronizes the indicators it uses, belongs there too, in the -`mvt.cli_plugins` group: +A MVT plugin command can also add sub-commands to the base `mvt` command. This can be used for commands which are not tied to a particular forensic platform: ```toml [project.entry-points."mvt.cli_plugins"] diff --git a/src/mvt/cli.py b/src/mvt/cli.py index 40a86fe8..6e8c6672 100644 --- a/src/mvt/cli.py +++ b/src/mvt/cli.py @@ -47,10 +47,8 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) def cli(ctx, disable_update_check, disable_indicator_update_check, verbose): """Mobile Verification Toolkit. - mvt-ios and mvt-android run the forensic analysis of an acquisition: each - provides the check-* commands of its platform. This command hosts what - belongs to neither platform; run it without a command to see the installed - version and the list of what it offers. + The 'mvt-ios' and 'mvt-android' CLI commands are used to perform + forensic analysis on IOS and Android devices. """ ctx.ensure_object(dict) ctx.obj["disable_version_check"] = disable_update_check From c9a57f5d10f481016b6e073c7842e806d8c8bdc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 15:34:53 +0200 Subject: [PATCH 063/109] Do not pin the wording of the mvt help text in the test --- tests/test_cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7a5cb51e..64952436 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,7 +29,6 @@ class TestMvtCommand: assert result.exit_code == 0 assert "mvt-ios" in result.output assert "mvt-android" in result.output - assert "check-*" in result.output def test_version_prints_the_installed_version(self): result = CliRunner().invoke(cli, [*OFFLINE, "version"]) From 7d41d0646e0ca20dc4a9962cc5f824731e8ac943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Thu, 27 Aug 2026 23:40:47 +0200 Subject: [PATCH 064/109] Keep the start of mvt-ios and mvt-android cheap for shell completion Each console script imports its CLI module before Click can answer a shell completion request, and the completion scripts run the program on every keystroke. Importing mvt.ios.cli or mvt.android.cli took ~230 ms, of which building the command tree needed almost nothing: cli_plugins imported one constant from module_loader, which pulled in MVTModule, the indicators, the pydantic settings, requests and rich; the command implementations pulled in the same, and the iOS CLI imported iOSbackup (pycryptodome) for decrypt-backup. The two platform CLI modules now only build the command tree: each command imports what it runs when it is invoked. cli_plugins owns the custom command prefix instead of importing it from module_loader, and exec_or_profile() loads the settings when it runs. Completion of mvt-ios and mvt-android drops from ~260 ms to ~85 ms per keystroke on a clean install, and every command starts that much sooner. A test fails as soon as a CLI module imports the module machinery again. --- src/mvt/android/cli.py | 35 +++++++++++++++++++++++---------- src/mvt/common/cli_plugins.py | 7 ++++++- src/mvt/common/module_loader.py | 3 +-- src/mvt/common/utils.py | 5 ++++- src/mvt/ios/cli.py | 35 ++++++++++++++++++++++++--------- tests/test_cli_startup.py | 28 ++++++++++++++++++++++++++ 6 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 tests/test_cli_startup.py diff --git a/src/mvt/android/cli.py b/src/mvt/android/cli.py index 92b2fe93..7b78a4e4 100644 --- a/src/mvt/android/cli.py +++ b/src/mvt/android/cli.py @@ -14,7 +14,6 @@ from mvt.common.cli_plugins import ( load_cli_commands_option, register_cli_plugins, ) -from mvt.common.cmd_check_iocs import CmdCheckIOCS from mvt.common.help import ( HELP_MSG_ANDROID_BACKUP_PASSWORD, HELP_MSG_CHECK_ADB_REMOVED, @@ -40,17 +39,12 @@ from mvt.common.help import ( 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_androidqf import CmdAndroidCheckAndroidQF -from .cmd_check_backup import CmdAndroidCheckBackup -from .cmd_check_bugreport import CmdAndroidCheckBugreport -from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs -from .command_modules import ANDROID_CHECK_IOCS_MODULES -from .modules.backup.helpers import cli_load_android_backup_password +# The commands import what they run only when they are invoked. This module is +# imported at every start of mvt-android, including by shell completion on +# every keystroke, so importing it must do no more than build the command tree: +# the forensic modules, the backup parsers and the update checks stay out of it. init_logging() log = logging.getLogger("mvt") @@ -74,6 +68,8 @@ def _get_verbose(ctx): def _load_custom_modules(load_module): + from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules + try: return load_custom_modules(load_module) except CustomModuleLoadError as exc: @@ -101,6 +97,9 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose): ctx.obj["disable_indicator_check"] = disable_indicator_update_check ctx.obj["verbose"] = verbose set_verbose_logging(verbose) + + from mvt.common.logo import logo + logo( disable_version_check=disable_update_check, disable_indicator_check=disable_indicator_update_check, @@ -164,6 +163,8 @@ def check_bugreport( verbose, bugreport_path, ): + from .cmd_check_bugreport import CmdAndroidCheckBugreport + set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) # Always generate hashes as bug reports are small. @@ -233,6 +234,9 @@ def check_backup( verbose, backup_path, ): + from .cmd_check_backup import CmdAndroidCheckBackup + from .modules.backup.helpers import cli_load_android_backup_password + set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) @@ -311,6 +315,9 @@ def check_androidqf( verbose, androidqf_path, ): + from .cmd_check_androidqf import CmdAndroidCheckAndroidQF + from .modules.backup.helpers import cli_load_android_backup_password + set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) @@ -393,6 +400,8 @@ def check_intrusion_logs( verbose, logs_path, ): + from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs + set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) @@ -446,6 +455,10 @@ def check_intrusion_logs( @click.argument("FOLDER", type=click.Path(exists=True)) @click.pass_context def check_iocs(ctx, iocs, list_modules, module, load_module, folder): + from mvt.common.cmd_check_iocs import CmdCheckIOCS + + from .command_modules import ANDROID_CHECK_IOCS_MODULES + custom_modules = _load_custom_modules(load_module) cmd = CmdCheckIOCS( target_path=folder, @@ -472,6 +485,8 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder): # ============================================================================== @cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2) def download_indicators(): + from mvt.common.updates import IndicatorsUpdates + ioc_updates = IndicatorsUpdates() ioc_updates.update() diff --git a/src/mvt/common/cli_plugins.py b/src/mvt/common/cli_plugins.py index 57f71813..17766acb 100644 --- a/src/mvt/common/cli_plugins.py +++ b/src/mvt/common/cli_plugins.py @@ -15,7 +15,9 @@ from typing import Iterable import click -from .module_loader import CUSTOM_COMMAND_MODULE_PREFIX +# This module is imported by every CLI at start-up, and by shell completion on +# every keystroke, so it must stay cheap: nothing here may import the module +# machinery (mvt.common.module_loader and what it pulls in). IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins" ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins" @@ -24,6 +26,9 @@ NEUTRAL_CLI_PLUGIN_GROUP = "mvt.cli_plugins" MVT_CUSTOM_COMMANDS_ENV = "MVT_CUSTOM_COMMANDS" MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS" MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS" +# Prefix of the import name given to a command file loaded from a path. Shared +# with module_loader, which recognises such files when naming their loggers. +CUSTOM_COMMAND_MODULE_PREFIX = "_mvt_custom_command_" log = logging.getLogger(__name__) diff --git a/src/mvt/common/module_loader.py b/src/mvt/common/module_loader.py index d0028b8e..51b38364 100644 --- a/src/mvt/common/module_loader.py +++ b/src/mvt/common/module_loader.py @@ -18,6 +18,7 @@ from pathlib import Path from types import ModuleType from typing import Iterable, Optional +from .cli_plugins import CUSTOM_COMMAND_MODULE_PREFIX from .module import MVTModule from .version import MVT_VERSION @@ -27,8 +28,6 @@ EXTERNAL_LOGGER_NAMESPACE = "mvt.ext" PLUGIN_PACKAGE_PREFIX = "mvt_plugin_" _ORIGIN_ATTRIBUTE = "_mvt_module_origin" _PATH_MODULE_PREFIX = "_mvt_custom_module_" -# Shared with cli_plugins, which names a loaded command file this way. -CUSTOM_COMMAND_MODULE_PREFIX = "_mvt_custom_command_" _LOADED_FILE_DIGEST = re.compile(r"_[0-9a-f]{16}$") log = logging.getLogger(__name__) diff --git a/src/mvt/common/utils.py b/src/mvt/common/utils.py index b71c4ab7..2a3e39a9 100644 --- a/src/mvt/common/utils.py +++ b/src/mvt/common/utils.py @@ -14,7 +14,6 @@ from dataclasses import asdict, is_dataclass from typing import Any, Iterator, Union from .log import MVTLogHandler -from mvt.common.config import settings class CustomJSONEncoder(json.JSONEncoder): @@ -272,6 +271,10 @@ def set_verbose_logging(verbose: bool = False): def exec_or_profile(module, globals, locals): """Hook for profiling MVT modules""" + # Imported here so that the CLI modules, which import this one at start-up, + # do not load the settings (and pydantic) before a command runs. + from .config import settings + if settings.PROFILE: cProfile.runctx(module, globals, locals) else: diff --git a/src/mvt/ios/cli.py b/src/mvt/ios/cli.py index 8251fd91..edf7724c 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -14,10 +14,7 @@ from mvt.common.cli_plugins import ( load_cli_commands_option, register_cli_plugins, ) -from mvt.common.cmd_check_iocs import CmdCheckIOCS -from mvt.common.logo import logo from mvt.common.options import MutuallyExclusiveOption -from mvt.common.updates import IndicatorsUpdates from mvt.common.utils import ( generate_hashes_from_path, init_logging, @@ -47,13 +44,12 @@ from mvt.common.help import ( HELP_MSG_DISABLE_UPDATE_CHECK, HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, ) -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 .command_modules import IOS_CHECK_IOCS_MODULES + +# The commands import what they run only when they are invoked. This module is +# imported at every start of mvt-ios, including by shell completion on every +# keystroke, so importing it must do no more than build the command tree: the +# forensic modules, the backup decryption and the update checks stay out of it. init_logging() log = logging.getLogger("mvt") @@ -79,6 +75,8 @@ def _get_verbose(ctx): def _load_custom_modules(load_module): + from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules + try: return load_custom_modules(load_module) except CustomModuleLoadError as exc: @@ -106,6 +104,9 @@ def cli(ctx, disable_update_check, disable_indicator_update_check, verbose): ctx.obj["disable_indicator_check"] = disable_indicator_update_check ctx.obj["verbose"] = verbose set_verbose_logging(verbose) + + from mvt.common.logo import logo + logo( disable_version_check=disable_update_check, disable_indicator_check=disable_indicator_update_check, @@ -146,6 +147,8 @@ def version(): @click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.pass_context def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path): + from .decrypt import DecryptBackup + backup = DecryptBackup(backup_path, destination) if key_file: @@ -209,6 +212,8 @@ def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path): ) @click.argument("BACKUP_PATH", type=click.Path(exists=True)) def extract_key(password, key_file, backup_path): + from .decrypt import DecryptBackup + backup = DecryptBackup(backup_path) if password: @@ -276,6 +281,8 @@ def check_backup( verbose, backup_path, ): + from .cmd_check_backup import CmdIOSCheckBackup + set_verbose_logging(verbose or _get_verbose(ctx)) module_options = {"fast_mode": fast} custom_modules = _load_custom_modules(load_module) @@ -345,6 +352,8 @@ def check_fs( verbose, dump_path, ): + from .cmd_check_fs import CmdIOSCheckFS + set_verbose_logging(verbose or _get_verbose(ctx)) module_options = {"fast_mode": fast} custom_modules = _load_custom_modules(load_module) @@ -413,6 +422,8 @@ def check_sysdiagnose( verbose, sysdiagnose_path, ): + from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose + set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) cmd = CmdIOSCheckSysdiagnose( @@ -467,6 +478,10 @@ def check_sysdiagnose( @click.argument("FOLDER", type=click.Path(exists=True)) @click.pass_context def check_iocs(ctx, iocs, list_modules, module, load_module, folder): + from mvt.common.cmd_check_iocs import CmdCheckIOCS + + from .command_modules import IOS_CHECK_IOCS_MODULES + custom_modules = _load_custom_modules(load_module) cmd = CmdCheckIOCS( target_path=folder, @@ -493,6 +508,8 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder): # ============================================================================== @cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2) def download_iocs(): + from mvt.common.updates import IndicatorsUpdates + ioc_updates = IndicatorsUpdates() ioc_updates.update() diff --git a/tests/test_cli_startup.py b/tests/test_cli_startup.py new file mode 100644 index 00000000..e0092566 --- /dev/null +++ b/tests/test_cli_startup.py @@ -0,0 +1,28 @@ +# 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 pytest + +from .plugin_fixtures import run_isolated_python + +# Importing a platform CLI must only build its command tree: the console +# scripts import it before Click can answer a shell completion request, which +# the completion scripts make on every keystroke. Every command imports what +# it runs when it is invoked. Each of these costs tens of milliseconds to +# import and is the sign that a command implementation is imported too early. +HEAVY_MODULES = ("pydantic", "requests", "Crypto", "mvt.common.module") + + +@pytest.mark.parametrize("cli_module", ("mvt.ios.cli", "mvt.android.cli")) +def test_importing_a_cli_does_not_import_the_module_machinery(cli_module, tmp_path): + result = run_isolated_python( + "import sys\n" + f"import {cli_module}\n" + f"print(','.join(name for name in {HEAVY_MODULES!r} if name in sys.modules))\n", + home=tmp_path / "home", + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "", f"{cli_module} imported {result.stdout.strip()}" From 531d63ab071dc039f0837b94626edc6816f2eafa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Tue, 1 Sep 2026 15:49:25 +0200 Subject: [PATCH 065/109] Release monthly from a scheduled workflow The package version now comes from the latest v* git tag, so a release is a tag and nothing else. A scheduled workflow tags main on the first of the month when something was merged since the last release, creates the GitHub release with generated notes, and publishes to PyPI and the container registry. Pushing a v* tag by hand goes through the same path. --- .github/workflows/publish-release-docker.yml | 2 + .github/workflows/release.yml | 44 ++++++++++++++++++++ Dockerfile | 1 + Makefile | 5 --- pyproject.toml | 7 ++-- src/mvt/common/version.py | 4 +- 6 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/publish-release-docker.yml b/.github/workflows/publish-release-docker.yml index b3fde3ae..a90202d2 100644 --- a/.github/workflows/publish-release-docker.yml +++ b/.github/workflows/publish-release-docker.yml @@ -37,6 +37,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + fetch-depth: 0 # the package version comes from the git tags # 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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..adec25c3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + schedule: + - cron: "0 9 1 * *" # first of the month + workflow_dispatch: + push: + tags: ["v*"] + +jobs: + tag: + if: github.ref_type != 'tag' + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - run: | + last=$(git describe --tags --abbrev=0 --match 'v*') + if [ "$(git rev-list "$last..HEAD" --count)" = 0 ]; then + echo "Nothing merged since $last"; exit 0 + fi + tag=v$(date -u +%Y.%-m.%-d) + gh release create "$tag" --target "$GITHUB_SHA" --generate-notes + # Events made with GITHUB_TOKEN don't start other workflows; run them by hand. + gh workflow run release.yml --ref "$tag" + gh workflow run publish-release-docker.yml --ref "$tag" + + publish: + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + id-token: write # PyPI trusted publishing + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v10.0.0 + - run: uv build && uv publish diff --git a/Dockerfile b/Dockerfile index 58bc2166..138edc7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -122,6 +122,7 @@ RUN apt-get update \ binutils \ default-jre-headless \ file \ + git \ jq \ less \ libcurl4 \ diff --git a/Makefile b/Makefile index 528c6975..62140097 100644 --- a/Makefile +++ b/Makefile @@ -32,8 +32,3 @@ clean: dist: $(UV) build -upload: - $(UV) tool run twine upload dist/* - -test-upload: - $(UV) tool run twine upload --repository testpypi dist/* diff --git a/pyproject.toml b/pyproject.toml index fb90f8db..3124c465 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ docs = [ ] [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=61.0", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [tool.coverage.run] @@ -121,5 +121,6 @@ where = ["src"] [tool.setuptools.package-data] mvt = ["ios/data/*.json"] -[tool.setuptools.dynamic] -version = { attr = "mvt.common.version.MVT_VERSION" } +[tool.setuptools_scm] +# The version is the latest v* tag; ignore the archive/* tags. +git_describe_command = "git describe --dirty --tags --long --match 'v*'" diff --git a/src/mvt/common/version.py b/src/mvt/common/version.py index c8e77161..31496047 100644 --- a/src/mvt/common/version.py +++ b/src/mvt/common/version.py @@ -3,4 +3,6 @@ # 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 = "2026.7.29" +from importlib.metadata import version + +MVT_VERSION = version("mvt") From 27cfe7f1e7d98919a6db3b221651e06d2d90b4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Tue, 1 Sep 2026 19:31:23 +0200 Subject: [PATCH 066/109] Release weekly instead of monthly --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adec25c3..3c20dc71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release on: schedule: - - cron: "0 9 1 * *" # first of the month + - cron: "0 9 * * 1" # Mondays, 09:00 UTC workflow_dispatch: push: tags: ["v*"] From 0e231eefaf4b26dff35de02b5de2fe1cb91fd52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Fri, 4 Sep 2026 17:44:51 +0200 Subject: [PATCH 067/109] Parse dumpsys settings as per-record results The `dumpsys settings` parser matched a single regex per line, which truncated every value that spans more than one line and, when `defaultSystemSet:` did not fall on the first line, left the trailing `default:` metadata inside the value. It also keyed results by setting name within a namespace, so a name recorded twice kept only the last row and a row without a `pkg:` field was dropped entirely. Replace it with a line loop that accumulates one record at a time and splits the `key:value` fields once the whole record has been read. Results become a list of records carrying the fields dumpsys prints: namespace, user, _id, name, value, pkg, default and defaultSystemSet, plus the per-setting change history. History timestamps are printed without a year, so they are resolved against the "ending at:" time of the section and serialized into the timeline. This shows which package changed a security-relevant setting, and when. The androidqf settings module shares this artifact, so it now emits the same record shape. --- src/mvt/android/artifacts/settings.py | 264 +++++++++++++++--- .../android/modules/androidqf/aqf_settings.py | 46 +-- src/mvt/android/modules/bugreport/settings.py | 3 +- tests/android/test_artifact_settings.py | 138 +++++++++ tests/android_androidqf/test_settings.py | 25 +- tests/android_bugreport/test_bugreport.py | 20 ++ .../android_data/bugreport/dumpstate.txt | 42 +++ 7 files changed, 448 insertions(+), 90 deletions(-) create mode 100644 tests/android/test_artifact_settings.py diff --git a/src/mvt/android/artifacts/settings.py b/src/mvt/android/artifacts/settings.py index e0cd5f0b..878165a5 100644 --- a/src/mvt/android/artifacts/settings.py +++ b/src/mvt/android/artifacts/settings.py @@ -4,6 +4,11 @@ # https://license.mvt.re/1.1/ import re +from datetime import datetime +from typing import Optional, Sequence + +from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult +from mvt.common.utils import convert_datetime_to_iso from .artifact import AndroidArtifact @@ -60,45 +65,232 @@ ANDROID_DANGEROUS_SETTINGS = [ }, ] +# dumpsys prints the fields of a setting record, and of a change history entry, +# always in this order and separated by a single space. +SETTING_FIELDS = ("_id", "name", "pkg", "value") +HISTORY_FIELDS = ("time", "mode", "oldValue", "newValue", "package") + +NAMESPACE_PATTERN = re.compile( + r"^(CONFIG|GLOBAL|SECURE|SYSTEM) SETTINGS \(user (\d+)\)$" +) +SECTION_END_PATTERN = re.compile(r"ending at: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})") + class Settings(AndroidArtifact): - def parse(self, content: str) -> None: - self.results: dict[str, dict[str, str]] = {} - namespace: str | None = None - for line in content.splitlines(): - heading = re.match( - r"^(CONFIG|GLOBAL|SECURE|SYSTEM) SETTINGS \(user (\d+)\)$", - line.strip(), - ) - if heading: - namespace = f"{heading.group(1).lower()}:user_{heading.group(2)}" - self.results[namespace] = {} + """Parser for the `dumpsys settings` output. + + Every row of the settings provider becomes one result, keeping the fields + dumpsys prints alongside the value: the row id, the package which recorded + the setting, the default, and the change history. A setting name can appear + more than once within a namespace, so results are a list rather than a + mapping. + """ + + def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult: + records = [] + for entry in result.get("history", []): + if not entry.get("timestamp"): continue - if namespace is None or not line.startswith("_id:"): - continue - setting = re.match( - r"^_id:\S+\s+name:(.*?)\s+pkg:.*?\s+value:(.*?)" - r"(?:\s+default:.*\s+defaultSystemSet:(?:true|false))?$", - line, + + records.append( + { + "timestamp": entry["timestamp"], + "module": self.__class__.__name__, + "event": "settings_change", + "data": ( + f"{result.get('namespace')} setting " + f'"{result.get("name")}" changed from ' + f'"{entry.get("oldValue")}" to "{entry.get("newValue")}" ' + f"by {entry.get('pkg')}" + ), + } ) - if setting: - self.results[namespace][setting.group(1)] = setting.group(2) + + return records def check_indicators(self) -> None: - for namespace, settings in self.results.items(): - for key, value in settings.items(): - for danger in ANDROID_DANGEROUS_SETTINGS: - # 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.alertstore.medium( - f'Found suspicious "{namespace}" setting "{key} = {value}" ({danger["description"]})', - "", - { - "namespace": namespace, - "key": key, - "value": value, - "description": danger["description"], - }, - ) - break + for result in self.results: + name = result.get("name") + value = result.get("value") + for danger in ANDROID_DANGEROUS_SETTINGS: + # Check if one of the dangerous settings is using an unsafe + # value (different than the one specified). + if danger["key"] != name or danger["safe_value"] == value: + continue + + history = result.get("history") or [] + self.alertstore.medium( + f'Found suspicious "{result.get("namespace")}" setting ' + f'"{name} = {value}" ({danger["description"]})', + history[-1]["timestamp"] if history else "", + result, + ) + break + + def parse(self, content: str) -> None: + self.results: list[ModuleAtomicResult] = [] + section_end = self._parse_section_end(content) + namespace: Optional[str] = None + user: Optional[str] = None + record_lines: list[str] = [] + history_lines: list[str] = [] + in_history = False + + def flush() -> None: + nonlocal record_lines, history_lines, in_history + if record_lines: + self.results.append( + self._build_record( + namespace, user, record_lines, history_lines, section_end + ) + ) + record_lines = [] + history_lines = [] + in_history = False + + for line in content.splitlines(): + heading = NAMESPACE_PATTERN.match(line.strip()) + if heading: + flush() + namespace = heading.group(1).lower() + user = heading.group(2) + continue + + if line.startswith("--------- "): + # dumpsys closes every section with a duration trailer. + flush() + namespace = None + continue + + if namespace is None: + continue + + if line.startswith("_id:"): + flush() + record_lines = [line] + continue + + if not record_lines: + continue + + stripped = line.strip() + if stripped.startswith("History ("): + in_history = True + continue + + if in_history: + if stripped.startswith("time:"): + history_lines.append(stripped) + elif stripped and history_lines: + # A history entry can be wrapped over several lines. + history_lines[-1] += " " + stripped + continue + + # Anything else continues the value of the record being read. + record_lines.append(line) + + flush() + + @staticmethod + def _split_fields(text: str, keys: Sequence[str]) -> dict[str, str]: + """Split the `key:value` fields of one record. + + Values are free-form and may contain spaces and newlines, so a field + runs up to the start of the next key which is actually present. Keys + dumpsys did not print are skipped. + """ + fields: dict[str, str] = {} + key = keys[0] + if not text.startswith(f"{key}:"): + return fields + + remainder = text[len(key) + 1 :] + for next_key in keys[1:]: + value, separator, rest = remainder.partition(f" {next_key}:") + if separator: + fields[key] = value + key, remainder = next_key, rest + + fields[key] = remainder + return fields + + @staticmethod + def _parse_section_end(content: str) -> Optional[datetime]: + """Return the time the settings section was dumped, if reported.""" + match = SECTION_END_PATTERN.search(content) + if not match: + return None + + try: + return datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + @staticmethod + def _resolve_timestamp( + value: str, section_end: Optional[datetime] + ) -> Optional[str]: + """Add the missing year to a `MM-DD HH:MM:SS.mmm` history timestamp. + + dumpsys prints the change history without a year, so it is resolved + against the time the section was dumped: the most recent matching date + at or before that time. + """ + if section_end is None: + return None + + try: + partial = datetime.strptime(value, "%m-%d %H:%M:%S.%f") + timestamp = partial.replace(year=section_end.year) + if timestamp > section_end: + timestamp = partial.replace(year=section_end.year - 1) + except ValueError: + return None + + return convert_datetime_to_iso(timestamp) + + def _parse_history( + self, line: str, section_end: Optional[datetime] + ) -> ModuleAtomicResult: + fields = self._split_fields(line, HISTORY_FIELDS) + return { + "timestamp": self._resolve_timestamp(fields.get("time", ""), section_end), + "oldValue": fields.get("oldValue"), + "newValue": fields.get("newValue"), + "pkg": fields.get("package"), + } + + def _build_record( + self, + namespace: Optional[str], + user: Optional[str], + record_lines: list[str], + history_lines: list[str], + section_end: Optional[datetime], + ) -> ModuleAtomicResult: + text = "\n".join(record_lines).rstrip() + + # `default:` and `defaultSystemSet:` are printed after the value, and + # the default may itself be multi-line, so peel them off the end first. + default = None + default_system_set = None + head, separator, tail = text.rpartition(" defaultSystemSet:") + if separator: + default_system_set = tail.strip() + text = head + head, separator, tail = text.rpartition(" default:") + if separator: + default = tail + text = head + + record: ModuleAtomicResult = {"namespace": namespace, "user": user} + record.update(self._split_fields(text, SETTING_FIELDS)) + if default is not None: + record["default"] = default + if default_system_set is not None: + record["defaultSystemSet"] = default_system_set + + record["history"] = [ + self._parse_history(entry, section_end) for entry in history_lines + ] + return record diff --git a/src/mvt/android/modules/androidqf/aqf_settings.py b/src/mvt/android/modules/androidqf/aqf_settings.py index 8d5bb518..397e121f 100644 --- a/src/mvt/android/modules/androidqf/aqf_settings.py +++ b/src/mvt/android/modules/androidqf/aqf_settings.py @@ -3,11 +3,7 @@ # 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 mvt.common.module_types import ModuleResults from .base import AndroidQFModule @@ -15,43 +11,23 @@ from .base import AndroidQFModule class AQFSettings(SettingsArtifact, AndroidQFModule): """This module analyse setting files""" - 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.results: dict = results if results is not None else {} - def run(self) -> None: for setting_file in self._get_files_by_pattern("*/settings_*.txt"): namespace = setting_file[setting_file.rfind("_") + 1 : -4] - self.results[namespace] = {} data = self._get_file_content(setting_file) for line in data.decode("utf-8").splitlines(): - line = line.strip() - try: - key, value = line.split("=", 1) - except ValueError: + name, separator, value = line.strip().partition("=") + if not separator: continue - try: - self.results[namespace][key] = value - except IndexError: - continue + self.results.append( + { + "namespace": namespace, + "user": None, + "name": name, + "value": value, + } + ) - self.log.info( - "Identified %d settings", sum([len(val) for val in self.results.values()]) - ) + self.log.info("Identified %d settings", len(self.results)) diff --git a/src/mvt/android/modules/bugreport/settings.py b/src/mvt/android/modules/bugreport/settings.py index 20180adf..6df77a65 100644 --- a/src/mvt/android/modules/bugreport/settings.py +++ b/src/mvt/android/modules/bugreport/settings.py @@ -18,5 +18,4 @@ class Settings(SettingsArtifact, BugReportModule): data.decode("utf-8", errors="replace"), "DUMP OF SERVICE settings:" ) self.parse(section) - count = sum(len(settings) for settings in self.results.values()) - self.log.info("Identified %d Android settings", count) + self.log.info("Identified %d Android settings", len(self.results)) diff --git a/tests/android/test_artifact_settings.py b/tests/android/test_artifact_settings.py new file mode 100644 index 00000000..d3f10521 --- /dev/null +++ b/tests/android/test_artifact_settings.py @@ -0,0 +1,138 @@ +# 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.android.artifacts.settings import Settings + +from ..utils import get_artifact + + +def parse_bugreport_settings() -> Settings: + settings = Settings() + with open(get_artifact("android_data/bugreport/dumpstate.txt")) as handle: + data = handle.read() + + settings.parse(settings.extract_dumpsys_section(data, "DUMP OF SERVICE settings:")) + return settings + + +def find(settings: Settings, name: str) -> list: + return [result for result in settings.results if result["name"] == name] + + +class TestSettingsArtifact: + def test_parsing(self): + settings = parse_bugreport_settings() + + assert len(settings.results) == 11 + assert {result["namespace"] for result in settings.results} == { + "config", + "global", + "secure", + } + assert settings.results[0] == { + "namespace": "config", + "user": "0", + "_id": "682", + "name": "namespace_one/blocked_components", + "pkg": "com.example.services", + "value": ( + "com.android.settings,com.android.vending,\n" + "com.example.dialer,\n" + "com.example.camera" + ), + "default": ( + "com.android.settings,\n" + " com.android.vending,\n" + " com.example.dialer" + ), + "defaultSystemSet": "false", + "history": [], + } + + def test_multiline_values_are_kept_whole(self): + settings = parse_bugreport_settings() + + assert find(settings, "namespace_one/allowed_packages")[0]["value"] == ( + "com.example.messaging,\ncom.example.chat" + ) + assert find(settings, "widget_instance_data")[0]["value"] == ( + '{\n "version": 1,\n "data": [\n {\n "number": 10000,\n' + ' "package_name": "com.example.widget"\n }\n ]\n}' + ) + + def test_trailing_default_is_not_part_of_the_value(self): + settings = parse_bugreport_settings() + + record = find(settings, "namespace_one/streaming_blocked_components")[0] + assert record["value"] == "com.example.dialer,com.example.camera" + assert record["default"] == "com.android.settings,\n com.android.vending" + + def test_repeated_names_are_kept_as_separate_records(self): + settings = parse_bugreport_settings() + + widgets = find(settings, "widget_instance_data") + assert [record["_id"] for record in widgets] == ["771", "41654"] + + accessibility = find(settings, "accessibility_enabled") + assert [(record["user"], record["value"]) for record in accessibility] == [ + ("0", "1"), + ("10", "0"), + ] + + def test_setting_without_recording_package(self): + settings = parse_bugreport_settings() + + record = find(settings, "hidden_api_blacklist_exemptions")[0] + assert "pkg" not in record + assert record["value"] == "{null}" + + def test_history_timestamps_resolved_against_section_end(self): + settings = parse_bugreport_settings() + + # The section was dumped on 2022-03-29, so an 11-02 entry belongs to + # the previous year and an 03-14 entry to the same year. + assert find(settings, "development_settings_enabled")[0]["history"] == [ + { + "timestamp": "2021-11-02 11:21:22.212000", + "oldValue": "null", + "newValue": "1", + "pkg": "com.android.settings", + }, + { + "timestamp": "2022-03-14 09:02:11.100000", + "oldValue": "1", + "newValue": "0", + "pkg": "com.example.updater", + }, + ] + + def test_history_without_a_section_end_has_no_timestamp(self): + settings = Settings() + settings.parse( + "SECURE SETTINGS (user 0)\n" + "_id:240 name:accessibility_enabled pkg:android value:1\n" + "\tHistory (accessibility_enabled)\n" + "\t\ttime:03-28 22:41:07.980 mode:update oldValue:0 newValue:1 " + "package:com.example.helper\n" + ) + + assert settings.results[0]["history"] == [ + { + "timestamp": None, + "oldValue": "0", + "newValue": "1", + "pkg": "com.example.helper", + } + ] + + def test_dangerous_setting_is_detected_with_the_changing_package(self): + settings = parse_bugreport_settings() + settings.check_indicators() + + assert len(settings.alertstore.alerts) == 1 + alert = settings.alertstore.alerts[0] + assert "accessibility_enabled = 1" in alert.message + assert alert.event_time == "2022-03-28 22:41:07.980000" + assert alert.event["history"][0]["pkg"] == "com.example.helper" diff --git a/tests/android_androidqf/test_settings.py b/tests/android_androidqf/test_settings.py index 6edfec81..3391565c 100644 --- a/tests/android_androidqf/test_settings.py +++ b/tests/android_androidqf/test_settings.py @@ -6,27 +6,12 @@ from pathlib import Path from mvt.android.modules.androidqf.aqf_settings import AQFSettings -from mvt.android.artifacts.settings import Settings from mvt.common.module import run_module from ..utils import get_android_androidqf, list_files class TestSettingsModule: - def test_bugreport_settings_format(self): - settings = Settings() - settings.parse( - "GLOBAL SETTINGS (user 0)\n" - "_id:1 name:adb_wifi_enabled pkg:android value:0 default:0 defaultSystemSet:true\n" - "SECURE SETTINGS (user 10)\n" - "_id:2 name:accessibility_enabled pkg:android value:1\n" - ) - - assert settings.results == { - "global:user_0": {"adb_wifi_enabled": "0"}, - "secure:user_10": {"accessibility_enabled": "1"}, - } - def test_parsing(self): data_path = get_android_androidqf() m = AQFSettings(target_path=data_path) @@ -34,7 +19,13 @@ class TestSettingsModule: parent_path = Path(data_path).absolute().parent.as_posix() m.from_dir(parent_path, files) run_module(m) - assert len(m.results) == 1 - assert "random" in m.results.keys() + assert len(m.results) == 9 + assert {result["namespace"] for result in m.results} == {"random"} + assert m.results[0] == { + "namespace": "random", + "user": None, + "name": "samsung_errorlog_agree", + "value": "0", + } assert len(m.alertstore.alerts) == 1 assert "samsung_errorlog_agree" in m.alertstore.alerts[0].message diff --git a/tests/android_bugreport/test_bugreport.py b/tests/android_bugreport/test_bugreport.py index 413bb180..e0a92f7a 100644 --- a/tests/android_bugreport/test_bugreport.py +++ b/tests/android_bugreport/test_bugreport.py @@ -10,6 +10,7 @@ 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.settings import Settings from mvt.android.modules.bugreport.tombstones import Tombstones from mvt.common.module import run_module @@ -93,6 +94,25 @@ class TestBugreportAnalysis: assert alert.event == malicious_receiver assert alert.matched_indicator.value == "com.android.services" + def test_settings_module(self): + m = self.launch_bug_report_module(Settings) + assert len(m.results) == 11 + + assert len(m.alertstore.alerts) == 1 + assert "accessibility_enabled = 1" in m.alertstore.alerts[0].message + + assert len(m.timeline) == 3 + change = [ + entry + for entry in m.timeline + if entry["timestamp"] == "2022-03-28 22:41:07.980000" + ][0] + assert change["event"] == "settings_change" + assert change["data"] == ( + 'secure setting "accessibility_enabled" changed from "0" to "1" ' + "by com.example.helper" + ) + def test_tombstones_modules(self): m = self.launch_bug_report_module(Tombstones) assert len(m.results) == 2 diff --git a/tests/artifacts/android_data/bugreport/dumpstate.txt b/tests/artifacts/android_data/bugreport/dumpstate.txt index c888ed5c..34479e9f 100644 --- a/tests/artifacts/android_data/bugreport/dumpstate.txt +++ b/tests/artifacts/android_data/bugreport/dumpstate.txt @@ -264,5 +264,47 @@ ChangeId(143539591; name=SELINUX_LATEST_CHANGES; disabled) ChangeId(247079863; name=DISALLOW_INVALID_GROUP_REFERENCE; enableSinceTargetSdk=34) ChangeId(174227820; name=FORCE_DISABLE_HEVC_SUPPORT; disabled) ChangeId(168419799; name=DOWNSCALED; disabled; packageOverrides={com.google.android.apps.tachyon=false, org.torproject.torbrowser=false}; rawOverrides={org.torproject.torbrowser=false, org.article19.circulo.next=false}; overridable) +------------------------------------------------------------------------------- +DUMP OF SERVICE settings: +CONFIG SETTINGS (user 0) +_id:682 name:namespace_one/blocked_components pkg:com.example.services value:com.android.settings,com.android.vending, +com.example.dialer, +com.example.camera default:com.android.settings, + com.android.vending, + com.example.dialer defaultSystemSet:false +_id:684 name:namespace_one/streaming_blocked_components pkg:com.example.services value:com.example.dialer,com.example.camera default:com.android.settings, + com.android.vending defaultSystemSet:false +_id:680 name:namespace_one/allowed_packages pkg:com.example.services value:com.example.messaging, +com.example.chat +GLOBAL SETTINGS (user 0) +_id:2070 name:adb_wifi_enabled pkg:android value:0 default:0 defaultSystemSet:true +_id:778 name:hidden_api_blacklist_exemptions value:{null} +_id:9631 name:development_settings_enabled pkg:com.android.settings value:1 default:1 defaultSystemSet:true + History (development_settings_enabled) + time:11-02 11:21:22.212 mode:update oldValue:null newValue:1 package:com.android.settings + time:03-14 09:02:11.100 mode:update oldValue:1 newValue:0 package:com.example.updater +_id:771 name:widget_instance_data pkg:com.android.systemui value:{ + "version": 1, + "data": [ + { + "number": 10000, + "package_name": "com.example.widget" + } + ] +} defaultSystemSet:true +_id:41654 name:widget_instance_data pkg:com.android.systemui value:{ + "version": 3, + "data": [] +} defaultSystemSet:true +SECURE SETTINGS (user 0) +_id:907 name:lock_screen_show_notifications pkg:com.android.settings value:1 default:1 defaultSystemSet:true +_id:240 name:accessibility_enabled pkg:android value:1 default:0 defaultSystemSet:true + History (accessibility_enabled) + time:03-28 22:41:07.980 mode:update oldValue:0 newValue:1 package:com.example.helper + +SECURE SETTINGS (user 10) +_id:311 name:accessibility_enabled pkg:android value:0 default:0 defaultSystemSet:true + +--------- 0.019s was the duration of dumpsys settings, ending at: 2022-03-29 23:14:28 From 03012d2c2dc102e3ab20177ab521de4f3188d259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Fri, 4 Sep 2026 20:25:07 +0200 Subject: [PATCH 068/109] Fix CI coverage comment and test each matrix Python version The coverage comment never posted: the per-file table with a link on every file and missing line range exceeds GitHub's 65536-character comment limit, so PRs got only a badge. Every matrix job also raced to post the same comment, and the action was unpinned at @main. Post from one job only, limit the table to files changed in the PR, drop per-line links, and pin the action. Write the full coverage table to the job summary as well, which also works for PRs from forks where the token is read-only. Set UV_PYTHON from the matrix. Without it, .python-version pins 3.10 and `uv run` rebuilt the venv with 3.10 after `uv sync --python X`, so all five matrix jobs were testing on Python 3.10. --- .github/workflows/tests.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c98ab223..a2d37a28 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,10 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + pull-requests: write # coverage comment + jobs: build: name: Run Python Tests @@ -13,6 +17,10 @@ jobs: fail-fast: false matrix: python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + env: + # Takes precedence over .python-version, which otherwise makes `uv run` + # rebuild the venv with 3.10 and test every matrix entry on 3.10. + UV_PYTHON: ${{ matrix.python-version }} steps: - uses: actions/checkout@v7 @@ -26,16 +34,23 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install Python dependencies run: | - uv sync --locked --group dev --python ${{ matrix.python-version }} + uv sync --locked --group dev - name: Test with pytest run: | set -o pipefail make test-ci | tee pytest-coverage.txt + - name: Coverage job summary + run: uv run coverage report --format=markdown --show-missing --skip-covered >> "$GITHUB_STEP_SUMMARY" - name: Pytest coverage comment - continue-on-error: true # Workflows running on a fork can't post comments - uses: MishaKav/pytest-coverage-comment@main - if: github.event_name == 'pull_request' + # One comment per PR, not one per matrix entry. PRs from forks get a + # read-only token and can't post; the job summary above still works. + if: github.event_name == 'pull_request' && matrix.python-version == '3.13' + continue-on-error: true + uses: MishaKav/pytest-coverage-comment@v1.12.2 with: pytest-coverage-path: ./pytest-coverage.txt junitxml-path: ./pytest.xml + # The full table with per-line links exceeds GitHub's 65536-char comment limit. + report-only-changed-files: true + remove-links-to-lines: true From e5fced2a08d5c1a7ed483409ab6846b3ccadbcc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Sat, 5 Sep 2026 15:56:55 +0200 Subject: [PATCH 069/109] Peel tag and restore flags off settings rows A dumpsys settings row can end in `tag:` and, on some vendor builds, in `isValuePreservedInRestore:` or a bare `notPreservedInRestore` token. The parser only peeled `default:` and `defaultSystemSet:` off the end of a record, so on a row without a default those tokens stayed inside the value, and on a row with one they landed in `defaultSystemSet`. A value of `0 tag:null` is not the safe value `0`, so a setting at its safe value was reported as dangerous; these are the false positives measured in #912. The metadata keys are ordered fields like the rest of the record, so `_split_fields` reads them now, which also replaces the separate handling of the default. The bare token has no `key:` shape and is printed last, so it is stripped first and recorded as `isValuePreservedInRestore: false`. The bugreport fixture gains the three row shapes from #912: a restore flag after `defaultSystemSet:`, and a `tag:` or a bare token directly after the value. Two of them sit on dangerous settings at their safe value, so the unchanged alert count of one is the false-positive check. --- src/mvt/android/artifacts/settings.py | 46 +++++++++---------- tests/android/test_artifact_settings.py | 20 +++++++- tests/android_bugreport/test_bugreport.py | 2 +- .../android_data/bugreport/dumpstate.txt | 5 +- 4 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/mvt/android/artifacts/settings.py b/src/mvt/android/artifacts/settings.py index 878165a5..627fafe3 100644 --- a/src/mvt/android/artifacts/settings.py +++ b/src/mvt/android/artifacts/settings.py @@ -66,8 +66,20 @@ ANDROID_DANGEROUS_SETTINGS = [ ] # dumpsys prints the fields of a setting record, and of a change history entry, -# always in this order and separated by a single space. -SETTING_FIELDS = ("_id", "name", "pkg", "value") +# always in this order and separated by a single space. After the value come +# `default:` and `defaultSystemSet:` when a default is recorded, then `tag:`; +# some vendor builds add whether the value survives a restore, either as +# `isValuePreservedInRestore:` or as a bare `notPreservedInRestore` token. +SETTING_FIELDS = ( + "_id", + "name", + "pkg", + "value", + "default", + "defaultSystemSet", + "tag", + "isValuePreservedInRestore", +) HISTORY_FIELDS = ("time", "mode", "oldValue", "newValue", "package") NAMESPACE_PATTERN = re.compile( @@ -81,9 +93,9 @@ class Settings(AndroidArtifact): Every row of the settings provider becomes one result, keeping the fields dumpsys prints alongside the value: the row id, the package which recorded - the setting, the default, and the change history. A setting name can appear - more than once within a namespace, so results are a list rather than a - mapping. + the setting, the default, the tag, and the change history. A setting name + can appear more than once within a namespace, so results are a list rather + than a mapping. """ def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult: @@ -269,26 +281,14 @@ class Settings(AndroidArtifact): section_end: Optional[datetime], ) -> ModuleAtomicResult: text = "\n".join(record_lines).rstrip() - - # `default:` and `defaultSystemSet:` are printed after the value, and - # the default may itself be multi-line, so peel them off the end first. - default = None - default_system_set = None - head, separator, tail = text.rpartition(" defaultSystemSet:") - if separator: - default_system_set = tail.strip() - text = head - head, separator, tail = text.rpartition(" default:") - if separator: - default = tail - text = head + # The bare `notPreservedInRestore` token has no `key:` shape and is + # printed last, so peel it off before splitting the fields. + head = text.removesuffix(" notPreservedInRestore") record: ModuleAtomicResult = {"namespace": namespace, "user": user} - record.update(self._split_fields(text, SETTING_FIELDS)) - if default is not None: - record["default"] = default - if default_system_set is not None: - record["defaultSystemSet"] = default_system_set + record.update(self._split_fields(head, SETTING_FIELDS)) + if head != text: + record["isValuePreservedInRestore"] = "false" record["history"] = [ self._parse_history(entry, section_end) for entry in history_lines diff --git a/tests/android/test_artifact_settings.py b/tests/android/test_artifact_settings.py index d3f10521..c3fde0d2 100644 --- a/tests/android/test_artifact_settings.py +++ b/tests/android/test_artifact_settings.py @@ -25,7 +25,7 @@ class TestSettingsArtifact: def test_parsing(self): settings = parse_bugreport_settings() - assert len(settings.results) == 11 + assert len(settings.results) == 12 assert {result["namespace"] for result in settings.results} == { "config", "global", @@ -69,6 +69,24 @@ class TestSettingsArtifact: assert record["value"] == "com.example.dialer,com.example.camera" assert record["default"] == "com.android.settings,\n com.android.vending" + def test_trailing_metadata_is_not_part_of_the_value(self): + settings = parse_bugreport_settings() + + record = find(settings, "lock_screen_show_notifications")[0] + assert record["value"] == "1" + assert record["defaultSystemSet"] == "true" + assert record["isValuePreservedInRestore"] == "true" + + # Without a default, the tag or the restore token follows the value. + record = find(settings, "accessibility_enabled")[1] + assert record["value"] == "0" + assert record["tag"] == "null" + assert "default" not in record + + record = find(settings, "send_action_app_error")[0] + assert record["value"] == "1" + assert record["isValuePreservedInRestore"] == "false" + def test_repeated_names_are_kept_as_separate_records(self): settings = parse_bugreport_settings() diff --git a/tests/android_bugreport/test_bugreport.py b/tests/android_bugreport/test_bugreport.py index e0a92f7a..0709e5ad 100644 --- a/tests/android_bugreport/test_bugreport.py +++ b/tests/android_bugreport/test_bugreport.py @@ -96,7 +96,7 @@ class TestBugreportAnalysis: def test_settings_module(self): m = self.launch_bug_report_module(Settings) - assert len(m.results) == 11 + assert len(m.results) == 12 assert len(m.alertstore.alerts) == 1 assert "accessibility_enabled = 1" in m.alertstore.alerts[0].message diff --git a/tests/artifacts/android_data/bugreport/dumpstate.txt b/tests/artifacts/android_data/bugreport/dumpstate.txt index 34479e9f..56a5d864 100644 --- a/tests/artifacts/android_data/bugreport/dumpstate.txt +++ b/tests/artifacts/android_data/bugreport/dumpstate.txt @@ -280,6 +280,7 @@ com.example.chat GLOBAL SETTINGS (user 0) _id:2070 name:adb_wifi_enabled pkg:android value:0 default:0 defaultSystemSet:true _id:778 name:hidden_api_blacklist_exemptions value:{null} +_id:9640 name:send_action_app_error pkg:android value:1 notPreservedInRestore _id:9631 name:development_settings_enabled pkg:com.android.settings value:1 default:1 defaultSystemSet:true History (development_settings_enabled) time:11-02 11:21:22.212 mode:update oldValue:null newValue:1 package:com.android.settings @@ -299,12 +300,12 @@ _id:41654 name:widget_instance_data pkg:com.android.systemui value:{ } defaultSystemSet:true SECURE SETTINGS (user 0) -_id:907 name:lock_screen_show_notifications pkg:com.android.settings value:1 default:1 defaultSystemSet:true +_id:907 name:lock_screen_show_notifications pkg:com.android.settings value:1 default:1 defaultSystemSet:true isValuePreservedInRestore:true _id:240 name:accessibility_enabled pkg:android value:1 default:0 defaultSystemSet:true History (accessibility_enabled) time:03-28 22:41:07.980 mode:update oldValue:0 newValue:1 package:com.example.helper SECURE SETTINGS (user 10) -_id:311 name:accessibility_enabled pkg:android value:0 default:0 defaultSystemSet:true +_id:311 name:accessibility_enabled pkg:android value:0 tag:null --------- 0.019s was the duration of dumpsys settings, ending at: 2022-03-29 23:14:28 From 858496e60bba1d66016b243b00cb8c7c1a0c1119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Sat, 5 Sep 2026 16:17:43 +0200 Subject: [PATCH 070/109] End a settings record at a blank line dumpsys prints a blank line after every namespace block and after a change history, and the generation registry and any vendor dumps follow the last block before the section trailer. A record ran until the next `_id:` line, heading or trailer, so the last row of the section absorbed those dumps into whichever field came last. A blank line now closes the record being read; lines that follow it without an `_id:` are skipped until the next row or heading. The fixture carries a generation registry after its last block, modelled on the AOSP dump, and the last row is pinned to exactly its own fields. --- src/mvt/android/artifacts/settings.py | 8 ++++++++ tests/android/test_artifact_settings.py | 14 ++++++++++++++ .../artifacts/android_data/bugreport/dumpstate.txt | 5 +++++ 3 files changed, 27 insertions(+) diff --git a/src/mvt/android/artifacts/settings.py b/src/mvt/android/artifacts/settings.py index 627fafe3..39257c06 100644 --- a/src/mvt/android/artifacts/settings.py +++ b/src/mvt/android/artifacts/settings.py @@ -177,6 +177,14 @@ class Settings(AndroidArtifact): if namespace is None: continue + if not line.strip(): + # dumpsys prints a blank line after every namespace block and + # after a change history, and other dumps such as the + # generation registry follow the last block, so a blank line + # closes the record being read. + flush() + continue + if line.startswith("_id:"): flush() record_lines = [line] diff --git a/tests/android/test_artifact_settings.py b/tests/android/test_artifact_settings.py index c3fde0d2..2192c12b 100644 --- a/tests/android/test_artifact_settings.py +++ b/tests/android/test_artifact_settings.py @@ -87,6 +87,20 @@ class TestSettingsArtifact: assert record["value"] == "1" assert record["isValuePreservedInRestore"] == "false" + def test_dumps_after_the_last_block_are_not_part_of_the_last_row(self): + settings = parse_bugreport_settings() + + assert settings.results[-1] == { + "namespace": "secure", + "user": "10", + "_id": "311", + "name": "accessibility_enabled", + "pkg": "android", + "value": "0", + "tag": "null", + "history": [], + } + def test_repeated_names_are_kept_as_separate_records(self): settings = parse_bugreport_settings() diff --git a/tests/artifacts/android_data/bugreport/dumpstate.txt b/tests/artifacts/android_data/bugreport/dumpstate.txt index 56a5d864..4dffc585 100644 --- a/tests/artifacts/android_data/bugreport/dumpstate.txt +++ b/tests/artifacts/android_data/bugreport/dumpstate.txt @@ -308,4 +308,9 @@ _id:240 name:accessibility_enabled pkg:android value:1 default:0 defaultSystemSe SECURE SETTINGS (user 10) _id:311 name:accessibility_enabled pkg:android value:0 tag:null +GENERATION REGISTRY +Maximum number of backing stores:8 +Number of backing stores:1 +_Backing store for type:SETTINGS_SECURE user:10 size:1024 cachedEntries:1 + --------- 0.019s was the duration of dumpsys settings, ending at: 2022-03-29 23:14:28 From d7148c03b4e73e9e18c482f08a8fe96c85705dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Sat, 5 Sep 2026 16:30:55 +0200 Subject: [PATCH 071/109] Stop persisting environment variables to config.yaml MVTSettings.initialise() constructs the settings once with load_env=False and writes the result to config.yaml, so that values taken from MVT_* environment variables are never persisted, then constructs them again with the environment applied. Since #716 settings_customise_sources() has added env_settings unconditionally, making load_env dead code. Any MVT_* variable set for a single run, including MVT_IOS_BACKUP_PASSWORD, MVT_ANDROID_BACKUP_PASSWORD and MVT_VT_API_KEY, was written in plaintext to config.yaml and read back on every later run. Only add env_settings when load_env is true, and add a regression test. Fixes #915 --- src/mvt/common/config.py | 13 ++++++++----- tests/common/test_config.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 tests/common/test_config.py diff --git a/src/mvt/common/config.py b/src/mvt/common/config.py index ce29fb33..24bf4072 100644 --- a/src/mvt/common/config.py +++ b/src/mvt/common/config.py @@ -59,13 +59,16 @@ class MVTSettings(BaseSettings): dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: - yaml_source = YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH) sources: Tuple[PydanticBaseSettingsSource, ...] = ( - yaml_source, + YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH), init_settings, ) - # Always load env variables by default - sources = (env_settings,) + sources + # Load env variables only when asked to. initialise() constructs the + # settings once without them so that what gets written back to + # config.yaml never includes values taken from the environment. + # init_settings() returns the keyword arguments passed to the constructor. + if init_settings().get("load_env", True): + sources = (env_settings,) + sources return sources def save_settings( @@ -92,7 +95,7 @@ class MVTSettings(BaseSettings): Afterwards we load the settings again, this time including the env variables. """ - # Set invalid env prefix to avoid loading env variables. + # Construct the settings without env variables so they are not persisted. settings = cls(load_env=False) settings.save_settings() diff --git a/tests/common/test_config.py b/tests/common/test_config.py new file mode 100644 index 00000000..f471a583 --- /dev/null +++ b/tests/common/test_config.py @@ -0,0 +1,30 @@ +# 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 os + +import yaml + +from mvt.common import config +from mvt.common.config import MVTSettings + + +def test_env_variables_are_not_persisted_to_config_file(tmp_path, monkeypatch): + config_path = tmp_path / "config.yaml" + monkeypatch.setattr(config, "MVT_CONFIG_FOLDER", str(tmp_path)) + monkeypatch.setattr(config, "MVT_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("MVT_NETWORK_ACCESS_ALLOWED", "false") + monkeypatch.setenv("MVT_IOS_BACKUP_PASSWORD", "env-only-password") + + settings = MVTSettings.initialise() + + assert os.path.isfile(config_path) + saved = yaml.safe_load(config_path.read_text()) or {} + assert "NETWORK_ACCESS_ALLOWED" not in saved + assert "IOS_BACKUP_PASSWORD" not in saved + + # The environment must still apply to the settings in use. + assert settings.NETWORK_ACCESS_ALLOWED is False + assert settings.IOS_BACKUP_PASSWORD == "env-only-password" From 2eb40b85cfd3d3788bb4600c49906e136a6c7db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Sat, 5 Sep 2026 23:45:14 +0200 Subject: [PATCH 072/109] Add a SysdiagnoseInfo module to check-sysdiagnose (#917) * Add a SysdiagnoseInfo module to check-sysdiagnose check-sysdiagnose had no module of its own: it prepared the archive for plugin modules and refused to run without one. SysdiagnoseInfo is the first built-in module. It writes sysdiagnose_info.json with details about the device and the archive: product type and model, iOS version and build, serial number, IMEI, MEID and UDID from remotectl_dumpstate.txt and the mobile activation request, the Apple account name and email from the App Store daemon database, and the archive's original file name and creation time from sysdiagnose.log. The build is checked against the known iOS versions the way BackupInfo does. The App Store database is copied out of the archive together with its -wal and -shm sidecars before it is opened, so rows still in the write-ahead log are read. With a built-in module the command's list is never empty, so the "no custom modules" error and its test go. The module joins IOS_CHECK_IOCS_MODULES like every other module that writes a results file. * Note that newer sysdiagnoses lack the App Store daemon database * Keep refusing check-sysdiagnose runs without a custom module * Warn instead of refusing when no forensic sysdiagnose module is loaded --- docs/ios/records.md | 8 + docs/ios/sysdiagnose.md | 11 +- src/mvt/ios/cli.py | 13 +- src/mvt/ios/cmd_check_sysdiagnose.py | 3 + src/mvt/ios/command_modules.py | 3 +- src/mvt/ios/modules/sysdiagnose/__init__.py | 5 + .../modules/sysdiagnose/sysdiagnose_info.py | 228 ++++++++++++++++++ tests/common/test_command_modules.py | 5 +- tests/ios_sysdiagnose/__init__.py | 4 + .../ios_sysdiagnose/test_sysdiagnose_info.py | 161 +++++++++++++ tests/test_check_ios_sysdiagnose.py | 15 +- tests/test_cmd_check_sysdiagnose.py | 17 +- 12 files changed, 451 insertions(+), 22 deletions(-) create mode 100644 src/mvt/ios/modules/sysdiagnose/sysdiagnose_info.py create mode 100644 tests/ios_sysdiagnose/__init__.py create mode 100644 tests/ios_sysdiagnose/test_sysdiagnose_info.py diff --git a/docs/ios/records.md b/docs/ios/records.md index b34183ad..856d0db9 100644 --- a/docs/ios/records.md +++ b/docs/ios/records.md @@ -435,3 +435,11 @@ This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extr This database is often missing from incremental backups. When it cannot be found, the module logs a warning and produces no results, in which case the disappearing messages state of chats cannot be determined from the backup. + +--- + +## Records extracted by `check-sysdiagnose` + +### `sysdiagnose_info.json` + +This JSON file is created by mvt-ios' `SysdiagnoseInfo` module. The module extracts details about the device and the sysdiagnose itself: the product type and model, iOS version and build, serial number, IMEI, MEID and UDID from the remotectl dump state and the mobile activation request, the Apple account name and email from the App Store daemon database (no longer part of a sysdiagnose on newer iOS versions, still read from older archives), and the original file name and creation time of the archive from *sysdiagnose.log*. diff --git a/docs/ios/sysdiagnose.md b/docs/ios/sysdiagnose.md index d0d84fb3..0107ce6b 100644 --- a/docs/ios/sysdiagnose.md +++ b/docs/ios/sysdiagnose.md @@ -1,10 +1,13 @@ # 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. The -command runs the modules of the installed +`mvt-ios check-sysdiagnose` analyzes an iOS sysdiagnose archive. MVT's own +`SysdiagnoseInfo` module extracts details about the device and the archive +(see [`sysdiagnose_info.json`](records.md#sysdiagnose_infojson)); the checks +come from the modules of the installed [plugin packages](../development/index.md#installed-module-packages) which -declare support for it. Install at least one such package first. +declare support for the command. Without any such module the command still +records the device details, and warns that no forensic sysdiagnose modules +have been loaded so that the run cannot pass for a clean analysis. The command accepts either an extracted sysdiagnose directory or the original gzip-compressed tar archive. diff --git a/src/mvt/ios/cli.py b/src/mvt/ios/cli.py index 56c4a11d..4560010a 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -449,11 +449,14 @@ def check_sysdiagnose( 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\"),)." + # MVT's own module only records the device details; the checks come from + # custom modules, so a run without any must not look like a clean analysis. + if all(module in cmd.modules for module in cmd._available_modules()): + log.warning( + "No forensic sysdiagnose modules have been loaded: MVT's own " + "SysdiagnoseInfo module only records the device details. Install a " + "module package or load a module that declares supported_commands = " + '(("ios", "check-sysdiagnose"),) to check the sysdiagnose.' ) if list_modules: diff --git a/src/mvt/ios/cmd_check_sysdiagnose.py b/src/mvt/ios/cmd_check_sysdiagnose.py index 05d5b6b7..3450a03f 100644 --- a/src/mvt/ios/cmd_check_sysdiagnose.py +++ b/src/mvt/ios/cmd_check_sysdiagnose.py @@ -16,6 +16,8 @@ from mvt.common.command import Command from mvt.common.indicators import Indicators from mvt.common.module import MVTModule +from .modules.sysdiagnose import SYSDIAGNOSE_MODULES + log = logging.getLogger(__name__) @@ -52,6 +54,7 @@ class CmdIOSCheckSysdiagnose(Command): ) self.platform = "ios" self.name = "check-sysdiagnose" + self.modules = SYSDIAGNOSE_MODULES self.sysdiagnose_format: Optional[str] = None self.sysdiagnose_archive: Optional[tarfile.TarFile] = None self.sysdiagnose_files: list[str] = [] diff --git a/src/mvt/ios/command_modules.py b/src/mvt/ios/command_modules.py index 26fa1d61..ab12d607 100644 --- a/src/mvt/ios/command_modules.py +++ b/src/mvt/ios/command_modules.py @@ -16,7 +16,8 @@ from mvt.common.module import MVTModule from .modules.backup import BACKUP_MODULES from .modules.fs import FS_MODULES from .modules.mixed import MIXED_MODULES +from .modules.sysdiagnose import SYSDIAGNOSE_MODULES IOS_CHECK_IOCS_MODULES: list[type[MVTModule]] = ( - BACKUP_MODULES + FS_MODULES + MIXED_MODULES + BACKUP_MODULES + FS_MODULES + MIXED_MODULES + SYSDIAGNOSE_MODULES ) diff --git a/src/mvt/ios/modules/sysdiagnose/__init__.py b/src/mvt/ios/modules/sysdiagnose/__init__.py index 3963ca54..4f586ecc 100644 --- a/src/mvt/ios/modules/sysdiagnose/__init__.py +++ b/src/mvt/ios/modules/sysdiagnose/__init__.py @@ -3,4 +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 mvt.common.module import MVTModule + from .base import SysdiagnoseExtraction +from .sysdiagnose_info import SysdiagnoseInfo + +SYSDIAGNOSE_MODULES: list[type[MVTModule]] = [SysdiagnoseInfo] diff --git a/src/mvt/ios/modules/sysdiagnose/sysdiagnose_info.py b/src/mvt/ios/modules/sysdiagnose/sysdiagnose_info.py new file mode 100644 index 00000000..c37208c2 --- /dev/null +++ b/src/mvt/ios/modules/sysdiagnose/sysdiagnose_info.py @@ -0,0 +1,228 @@ +# 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 json +import logging +import os +import plistlib +import re +import sqlite3 +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Optional + +from mvt.common.module_types import ModuleResults +from mvt.common.utils import convert_datetime_to_iso +from mvt.ios.versions import ( + find_version_by_build, + get_device_desc_from_id, + is_ios_version_outdated, +) + +from .base import SysdiagnoseExtraction + +# The fields dumpsys prints in the remotectl dump state and the mobile +# activation request which are worth a log line of their own. +LOGGED_FIELDS = ( + "ProductName", + "ProductType", + "SerialNumber", + "OSVersion", + "RegionCode", + "IMEI", + "BuildVersion", +) + + +class SysdiagnoseInfo(SysdiagnoseExtraction): + """Extract details about the device and the sysdiagnose itself. + + The fields come from four files of the archive: the remotectl dump state + (product type, OS version, serial number, region and the rest of its + Properties block), the mobile activation request (UDID, IMEI, MEID and the + OS build), the App Store daemon database (the Apple account name and email) + and sysdiagnose.log (the archive's original file name and creation time). + + Newer iOS versions no longer include the App Store daemon database in a + sysdiagnose; it is still read for the analysis of older archives. + """ + + 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.results: dict = results if results is not None else {} + + def _copy_sqlite_db(self, file_path: str, directory: str) -> str: + """Copy a database and its WAL sidecars out of the archive. + + A database dumped mid-transaction keeps its latest rows in the -wal + file next to it, which SQLite only reads when both sit in the same + directory under the same name. + """ + available_files = self.tar_files if self.tar else self.files + for suffix in ("", "-wal", "-shm"): + if suffix and f"{file_path}{suffix}" not in available_files: + continue + copy_path = os.path.join(directory, f"{Path(file_path).name}{suffix}") + with open(copy_path, "wb") as handle: + handle.write(self._get_file_content(f"{file_path}{suffix}")) + + return os.path.join(directory, Path(file_path).name) + + def _process_appstored(self, file_path: str) -> None: + self.log.info("Found App Store daemon database at: %s", file_path) + with tempfile.TemporaryDirectory(prefix="mvt_sqlite_") as directory: + db_path = Path(self._copy_sqlite_db(file_path, directory)).resolve() + conn = sqlite3.connect(f"{db_path.as_uri()}?mode=ro", uri=True) + try: + self._read_appstored(conn) + finally: + conn.close() + + def _read_appstored(self, conn: sqlite3.Connection) -> None: + cur = conn.cursor() + # The account name sits in an opaque structure of every asset row. + try: + rows = cur.execute("SELECT sinfs_data FROM asset;").fetchall() + except sqlite3.DatabaseError as exc: + self.log.debug("Unable to read the asset table: %s", exc) + rows = [] + + for (sinfs_data,) in rows: + try: + sinf = plistlib.loads(sinfs_data)[0]["sinf"] + except (plistlib.InvalidFileException, IndexError, KeyError, TypeError): + continue + match = re.search(rb"name(.*?)\x00", sinf) + if match: + self.results["Account Name"] = match.group(1).decode( + "utf-8", errors="replace" + ) + break + + try: + row = cur.execute( + "SELECT store_account_name FROM job_software " + "WHERE store_account_name IS NOT NULL LIMIT 1;" + ).fetchone() + except sqlite3.DatabaseError as exc: + self.log.debug("Unable to read the job_software table: %s", exc) + return + + if row: + self.results["Email Address"] = row[0] + + def _process_activation_log(self, file_path: str) -> None: + self.log.info("Found mobile activation request at: %s", file_path) + content = self._get_file_content(file_path) + match = re.search(rb"BODY:\s+({.+?})\s", content, re.MULTILINE) + if not match: + return + + try: + body = json.loads(match.group(1)) + except json.JSONDecodeError as exc: + self.log.warning("Unable to parse the activation request body: %s", exc) + return + + self.results.update( + { + "SerialNumber": body.get("serial-number"), + "ProductType": body.get("productType"), + "ProductName": body.get("productName"), + "IMEI": body.get("imei"), + "ProductVersion": body.get("os-version"), + "UniqueIdentifier": body.get("udid"), + "MEID": body.get("meid"), + "BuildVersion": body.get("os-build"), + } + ) + + def _process_dumpstate(self, file_path: str) -> None: + self.log.info("Found remotectl dump state at: %s", file_path) + content = self._get_file_content(file_path).decode("utf-8", errors="replace") + in_properties = False + for line in content.splitlines(): + if not in_properties: + in_properties = line == "\tProperties: {" + continue + + if line == "\t}": + break + + key, separator, value = line.partition("=>") + if separator: + self.results[key.strip()] = value.strip() + + def _process_sysdiagnose_log(self, file_path: str) -> None: + self.log.info("Found sysdiagnose.log at: %s", file_path) + content = self._get_file_content(file_path).decode("utf-8", errors="replace") + match = re.search(r"sysdiagnose_\S+?\.tar\.gz", content) + if not match: + self.log.info("Could not find the original output path in sysdiagnose.log") + return + + file_name = os.path.basename(match.group(0)) + try: + created = datetime.strptime( + "_".join(file_name.split("_")[1:3]), "%Y.%m.%d_%H-%M-%S%z" + ) + except ValueError: + self.log.warning("Unexpected sysdiagnose file name: %s", file_name) + return + + self.results["OriginalFilename"] = file_name + self.results["CreatedTimestamp"] = convert_datetime_to_iso(created) + + def run(self) -> None: + for file_path in self._get_files_by_pattern( + "*/logs/appinstallation/appstored.sqlitedb" + ): + self._process_appstored(file_path) + + for file_path in self._get_files_by_pattern( + "*/logs/MobileActivation/collection_oob_request.txt" + ): + self._process_activation_log(file_path) + + for file_path in self._get_files_by_pattern("*/remotectl_dumpstate.txt"): + self._process_dumpstate(file_path) + + for file_path in self._get_files_by_pattern("*/sysdiagnose.log"): + self._process_sysdiagnose_log(file_path) + + # The activation request names the product "iPhone OS"; the model + # description is what an analyst wants to read. + product_name = get_device_desc_from_id(self.results.get("ProductType", "")) + if product_name: + self.results["ProductName"] = product_name + + for field in LOGGED_FIELDS: + if field not in self.results: + continue + value = self.results[field] + if field == "BuildVersion" and value: + self.log.info("%s: %s - %s", field, value, find_version_by_build(value)) + else: + self.log.info("%s: %s", field, value) + + if self.results.get("BuildVersion"): + is_ios_version_outdated(self.results["BuildVersion"], self.log) diff --git a/tests/common/test_command_modules.py b/tests/common/test_command_modules.py index 8f79ce9d..a79cda2c 100644 --- a/tests/common/test_command_modules.py +++ b/tests/common/test_command_modules.py @@ -12,13 +12,16 @@ from mvt.ios.command_modules import IOS_CHECK_IOCS_MODULES from mvt.ios.modules.backup import BACKUP_MODULES as IOS_BACKUP_MODULES from mvt.ios.modules.fs import FS_MODULES from mvt.ios.modules.mixed import MIXED_MODULES +from mvt.ios.modules.sysdiagnose import SYSDIAGNOSE_MODULES def test_the_check_iocs_lists_are_the_families_of_their_platform(): # The CLI reads these same lists, so nothing composing one elsewhere can # drift from what the command runs. This pins what the lists are composed # of. - assert IOS_CHECK_IOCS_MODULES == IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES + assert IOS_CHECK_IOCS_MODULES == ( + IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES + SYSDIAGNOSE_MODULES + ) assert ANDROID_CHECK_IOCS_MODULES == ( ANDROID_BACKUP_MODULES + BUGREPORT_MODULES diff --git a/tests/ios_sysdiagnose/__init__.py b/tests/ios_sysdiagnose/__init__.py new file mode 100644 index 00000000..4e7aeb6d --- /dev/null +++ b/tests/ios_sysdiagnose/__init__.py @@ -0,0 +1,4 @@ +# 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/ diff --git a/tests/ios_sysdiagnose/test_sysdiagnose_info.py b/tests/ios_sysdiagnose/test_sysdiagnose_info.py new file mode 100644 index 00000000..d5590897 --- /dev/null +++ b/tests/ios_sysdiagnose/test_sysdiagnose_info.py @@ -0,0 +1,161 @@ +# 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 json +import plistlib +import sqlite3 +import tarfile + +from mvt.common.module import run_module +from mvt.ios.cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose +from mvt.ios.modules.sysdiagnose.sysdiagnose_info import SysdiagnoseInfo +from mvt.ios.versions import get_device_desc_from_id + +# The name sysdiagnose gives its archive: the time it ran, then the OS and build. +ARCHIVE_NAME = "sysdiagnose_2024.01.02_03-04-05+0200_iPhone-OS_iPhone_21C62" + +DUMPSTATE = ( + "Found device: ...\n" + "\tProperties: {\n" + "\t\tProductType => iPhone12,1\n" + "\t\tOSVersion => 17.2\n" + "\t\tSerialNumber => C0FFEE000000\n" + "\t\tRegionCode => LL\n" + "\t}\n" + "\tServices: {\n" + "\t\tcom.apple.example => ignored\n" + "\t}\n" +) + +ACTIVATION_BODY = { + "serial-number": "C0FFEE000000", + "productType": "iPhone12,1", + "productName": "iPhone OS", + "imei": "000000000000000", + "os-version": "17.2", + "os-build": "21C62", + "udid": "00000000-0000000000000000", + "meid": "00000000000000", +} + + +def make_sysdiagnose(tmp_path, activation_body=None): + folder = tmp_path / ARCHIVE_NAME + folder.mkdir() + (folder / "sysdiagnose.log").write_text( + f"Output available at '/private/var/tmp/{ARCHIVE_NAME}.tar.gz'\n", + encoding="utf-8", + ) + (folder / "remotectl_dumpstate.txt").write_text(DUMPSTATE, encoding="utf-8") + + activation = folder / "logs" / "MobileActivation" + activation.mkdir(parents=True) + body = json.dumps( + activation_body if activation_body is not None else ACTIVATION_BODY + ) + (activation / "collection_oob_request.txt").write_text( + f"HEADERS: {{}}\nBODY: {body}\nEND\n", encoding="utf-8" + ) + + appinstallation = folder / "logs" / "appinstallation" + appinstallation.mkdir(parents=True) + conn = sqlite3.connect(appinstallation / "appstored.sqlitedb") + conn.execute("CREATE TABLE asset (sinfs_data BLOB)") + conn.execute( + "INSERT INTO asset VALUES (?)", + (plistlib.dumps([{"sinf": b"\x00\x10nameExample Person\x00\x00rest"}]),), + ) + conn.execute("CREATE TABLE job_software (store_account_name TEXT)") + conn.execute("INSERT INTO job_software VALUES (NULL)") + conn.execute("INSERT INTO job_software VALUES ('person@example.com')") + conn.commit() + conn.close() + return folder + + +def run_command(target, results_path=None): + command = CmdIOSCheckSysdiagnose(target_path=str(target), results_path=results_path) + command.run() + (module,) = [m for m in command.executed if isinstance(m, SysdiagnoseInfo)] + return module + + +def test_device_details_from_a_sysdiagnose_folder(tmp_path): + results_path = tmp_path / "results" + results_path.mkdir() + module = run_command(make_sysdiagnose(tmp_path), str(results_path)) + + assert module.results["SerialNumber"] == "C0FFEE000000" + assert module.results["ProductType"] == "iPhone12,1" + assert module.results["ProductName"] == get_device_desc_from_id("iPhone12,1") + assert module.results["ProductName"] != "iPhone OS" + assert module.results["OSVersion"] == "17.2" + assert module.results["BuildVersion"] == "21C62" + assert module.results["UniqueIdentifier"] == "00000000-0000000000000000" + assert module.results["RegionCode"] == "LL" + assert "com.apple.example" not in module.results + assert module.results["Account Name"] == "Example Person" + assert module.results["Email Address"] == "person@example.com" + assert module.results["OriginalFilename"] == f"{ARCHIVE_NAME}.tar.gz" + assert module.results["CreatedTimestamp"] == "2024-01-02 01:04:05.000000" + assert (results_path / "sysdiagnose_info.json").exists() + + +def test_device_details_from_a_sysdiagnose_archive(tmp_path): + folder = make_sysdiagnose(tmp_path) + archive_path = tmp_path / f"{ARCHIVE_NAME}.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(folder, arcname=ARCHIVE_NAME) + + module = run_command(archive_path) + + assert module.results["SerialNumber"] == "C0FFEE000000" + assert module.results["Account Name"] == "Example Person" + assert module.results["OriginalFilename"] == f"{ARCHIVE_NAME}.tar.gz" + + +def test_wal_sidecars_are_copied_beside_the_database(tmp_path): + folder = tmp_path / ARCHIVE_NAME + (folder / "logs").mkdir(parents=True) + (folder / "logs" / "db.sqlite").write_bytes(b"main") + (folder / "logs" / "db.sqlite-wal").write_bytes(b"wal") + module = SysdiagnoseInfo() + module.from_sysdiagnose_folder( + str(folder), + [f"{ARCHIVE_NAME}/logs/db.sqlite", f"{ARCHIVE_NAME}/logs/db.sqlite-wal"], + ) + copies = tmp_path / "copies" + copies.mkdir() + + db_path = module._copy_sqlite_db(f"{ARCHIVE_NAME}/logs/db.sqlite", str(copies)) + + assert db_path == str(copies / "db.sqlite") + assert (copies / "db.sqlite").read_bytes() == b"main" + assert (copies / "db.sqlite-wal").read_bytes() == b"wal" + assert not (copies / "db.sqlite-shm").exists() + + +def test_a_sysdiagnose_without_the_files_yields_nothing(tmp_path): + folder = tmp_path / ARCHIVE_NAME + folder.mkdir() + (folder / "other.txt").write_text("nothing here", encoding="utf-8") + + module = SysdiagnoseInfo() + module.from_sysdiagnose_folder(str(folder), [f"{ARCHIVE_NAME}/other.txt"]) + run_module(module) + + assert module.results == {} + + +def test_a_malformed_activation_request_is_skipped(tmp_path): + folder = make_sysdiagnose(tmp_path) + (folder / "logs" / "MobileActivation" / "collection_oob_request.txt").write_text( + "BODY: {not json}\n", encoding="utf-8" + ) + + module = run_command(folder) + + assert "IMEI" not in module.results + assert module.results["SerialNumber"] == "C0FFEE000000" diff --git a/tests/test_check_ios_sysdiagnose.py b/tests/test_check_ios_sysdiagnose.py index d73c0ffb..0cd4221a 100644 --- a/tests/test_check_ios_sysdiagnose.py +++ b/tests/test_check_ios_sysdiagnose.py @@ -1,3 +1,5 @@ +import logging + from click.testing import CliRunner from mvt.ios.cli import check_sysdiagnose @@ -50,8 +52,13 @@ def test_check_sysdiagnose_runs_explicitly_scoped_custom_module(tmp_path): 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))]) +def test_check_sysdiagnose_warns_without_a_custom_module(tmp_path, caplog): + # The built-in SysdiagnoseInfo alone performs no check, so the run goes + # ahead but says so. + with caplog.at_level(logging.WARNING, logger="mvt"): + 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 + assert result.exit_code == 0 + assert "No forensic sysdiagnose modules have been loaded" in caplog.text diff --git a/tests/test_cmd_check_sysdiagnose.py b/tests/test_cmd_check_sysdiagnose.py index 67f126bd..020c8616 100644 --- a/tests/test_cmd_check_sysdiagnose.py +++ b/tests/test_cmd_check_sysdiagnose.py @@ -45,6 +45,11 @@ def _create_sysdiagnose_archive(tmp_path, folder): return archive_path +def _test_module(command): + (module,) = [m for m in command.executed if isinstance(m, SysdiagnoseTestModule)] + return module + + def _run_command(path): command = CmdIOSCheckSysdiagnose( target_path=str(path), custom_modules=[SysdiagnoseTestModule] @@ -56,10 +61,10 @@ def _run_command(path): def test_check_sysdiagnose_from_folder(tmp_path): command = _run_command(_create_sysdiagnose_folder(tmp_path)) - assert command.executed[0].results == [ + assert _test_module(command).results == [ {"content": "artifact", "timezone_offset": timedelta(hours=2).seconds} ] - assert command.executed[0].ips_files == [ + assert _test_module(command).ips_files == [ {"file_path": str(tmp_path / "sysdiagnose" / "report.ips"), "bug_type": 210} ] @@ -68,14 +73,12 @@ 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 == [ + assert _test_module(command).results == [ {"content": "artifact", "timezone_offset": timedelta(hours=2).seconds} ] - assert command.executed[0].ips_files == [ + assert _test_module(command).ips_files == [ { - "file_path": str( - Path(command.extracted_sysdiagnose_path) / "report.ips" - ), + "file_path": str(Path(command.extracted_sysdiagnose_path) / "report.ips"), "bug_type": 210, } ] From 92f780df5a58b79981b68850587e993f81dc2c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Mon, 7 Sep 2026 23:58:43 +0100 Subject: [PATCH 073/109] Report an unreadable sysdiagnose archive instead of aborting silently (#920) A sysdiagnose tarball whose download stopped halfway ends in an EOFError from gzip while check-sysdiagnose extracts it. Click turns EOFError into click.Abort, so the command printed nothing but "Aborted!", even with -v. The extraction now catches the read errors an archive can raise, names the file and the reason at critical level, says the file may be truncated or not a gzip tarball, and exits 1. A plain .tar given to the gzip reader gets the same message rather than a traceback. --- src/mvt/ios/cmd_check_sysdiagnose.py | 17 +++++++++++++++-- tests/test_check_ios_sysdiagnose.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/mvt/ios/cmd_check_sysdiagnose.py b/src/mvt/ios/cmd_check_sysdiagnose.py index 3450a03f..88a12db0 100644 --- a/src/mvt/ios/cmd_check_sysdiagnose.py +++ b/src/mvt/ios/cmd_check_sysdiagnose.py @@ -7,7 +7,9 @@ import json import logging import os import shutil +import sys import tarfile +import zlib from pathlib import Path, PurePosixPath from tempfile import TemporaryDirectory from typing import Any, Optional @@ -97,8 +99,19 @@ class CmdIOSCheckSysdiagnose(Command): 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() + try: + self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz") + self._extract_sysdiagnose_archive() + except (tarfile.ReadError, EOFError, zlib.error, OSError) as exc: + # A truncated archive ends in EOFError from gzip, which Click would + # otherwise report as a bare "Aborted!" with no reason. + self.log.critical( + "Unable to read the sysdiagnose archive %s: %s. " + "The file may be truncated or not a gzip-compressed tarball.", + self.target_path, + exc, + ) + sys.exit(1) def _extract_sysdiagnose_archive(self) -> None: archive = self.sysdiagnose_archive diff --git a/tests/test_check_ios_sysdiagnose.py b/tests/test_check_ios_sysdiagnose.py index 0cd4221a..c81e213a 100644 --- a/tests/test_check_ios_sysdiagnose.py +++ b/tests/test_check_ios_sysdiagnose.py @@ -1,4 +1,6 @@ import logging +import os +import tarfile from click.testing import CliRunner @@ -62,3 +64,29 @@ def test_check_sysdiagnose_warns_without_a_custom_module(tmp_path, caplog): assert result.exit_code == 0 assert "No forensic sysdiagnose modules have been loaded" in caplog.text + + +def _create_truncated_sysdiagnose_archive(tmp_path): + folder = tmp_path / "sysdiagnose_2026.01.01_00-00-00+0000_iPhone-OS_iPhone_23A000" + folder.mkdir() + (folder / "sysdiagnose.log").write_bytes(os.urandom(200_000)) + archive = tmp_path / "sysdiagnose.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + tar.add(folder, arcname=folder.name) + data = archive.read_bytes() + archive.write_bytes(data[: len(data) // 2]) + return archive + + +def test_check_sysdiagnose_reports_a_truncated_archive(tmp_path, caplog): + # A download that stopped halfway ends in EOFError from gzip, which Click + # would otherwise turn into a bare "Aborted!" with no reason given. + archive = _create_truncated_sysdiagnose_archive(tmp_path) + + with caplog.at_level(logging.CRITICAL, logger="mvt"): + result = CliRunner().invoke(check_sysdiagnose, [str(archive)]) + + assert result.exit_code == 1 + assert "Unable to read the sysdiagnose archive" in caplog.text + assert "truncated" in caplog.text + assert "Aborted!" not in result.output From 60e652bafcd7a5c11de057b8da7a2a8828c394a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Tue, 8 Sep 2026 00:08:30 +0100 Subject: [PATCH 074/109] Read bugreport file timestamps in the device's timezone (#921) A bugreport zip stores each entry's time as the device's wall clock, and an unpacked bugreport's mtimes are whatever the extraction left. The bugreport modules read both as naive local datetimes, so a tombstone's file_timestamp moved with the analysing machine's timezone and sat three hours from the crash time the tombstone itself records for a device in Nairobi. check-bugreport now resolves the device's timezone once, from --timezone or from persist.sys.timezone in the dumpstate's SYSTEM PROPERTIES, and hands it to the modules as device_timezone the way check-androidqf does; a zone already known, as when androidqf drives the bugreport inside its own archive, is kept. Zip entry times are read in that zone and the mtimes of an unpacked bugreport as UTC instants, so convert_datetime_to_iso writes both in UTC. Without a zone the wall clock is kept naive and a warning says so, and an unpacked bugreport gets a warning that its file timestamps are the extraction's, not the device's. BugReportTimestamps uses the same reading instead of parsing the properties itself. --- src/mvt/android/cli.py | 17 ++++ src/mvt/android/cmd_check_bugreport.py | 54 ++++++++++- src/mvt/android/modules/bugreport/base.py | 32 +++++-- .../modules/bugreport/fs_timestamps.py | 48 ++++------ tests/test_check_android_bugreport.py | 92 +++++++++++++++++++ 5 files changed, 200 insertions(+), 43 deletions(-) diff --git a/src/mvt/android/cli.py b/src/mvt/android/cli.py index 7b78a4e4..e9f8d019 100644 --- a/src/mvt/android/cli.py +++ b/src/mvt/android/cli.py @@ -150,6 +150,16 @@ def check_adb(ctx): default=[], help=HELP_MSG_LOAD_MODULE, ) +@click.option( + "--timezone", + "-t", + default=None, + help=( + "IANA timezone name for the device, for example 'Europe/Paris'. " + "Bugreport file timestamps are the device's wall clock; by default the " + "zone is read from persist.sys.timezone in the bugreport itself." + ), +) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND) @click.argument("BUGREPORT_PATH", type=click.Path(exists=True)) @click.pass_context @@ -160,6 +170,7 @@ def check_bugreport( list_modules, module, load_module, + timezone, verbose, bugreport_path, ): @@ -167,12 +178,18 @@ def check_bugreport( set_verbose_logging(verbose or _get_verbose(ctx)) custom_modules = _load_custom_modules(load_module) + + module_options = {} + if timezone: + module_options["device_timezone"] = timezone + # Always generate hashes as bug reports are small. cmd = CmdAndroidCheckBugreport( target_path=bugreport_path, results_path=output, ioc_files=iocs, module_name=module, + module_options=module_options if module_options else None, hashes=True, disable_version_check=_get_disable_flags(ctx)[0], disable_indicator_check=_get_disable_flags(ctx)[1], diff --git a/src/mvt/android/cmd_check_bugreport.py b/src/mvt/android/cmd_check_bugreport.py index 1c03c6d2..946feb97 100644 --- a/src/mvt/android/cmd_check_bugreport.py +++ b/src/mvt/android/cmd_check_bugreport.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import List, Optional from zipfile import ZipFile +from mvt.android.artifacts.getprop import GetProp from mvt.android.modules.bugreport.base import BugReportModule from mvt.common.command import Command from mvt.common.indicators import Indicators @@ -88,13 +89,56 @@ class CmdAndroidCheckBugreport(Command): self.__files.append(file_name) def init(self) -> None: - if not self.target_path: + if self.target_path: + if os.path.isfile(self.target_path): + self.from_zip(ZipFile(self.target_path)) + elif os.path.isdir(self.target_path): + self.from_dir(self.target_path) + self.log.warning( + "Analysing an unpacked bugreport: file timestamps come from " + "the extraction, not from the device. Analyse the original " + "zip to keep the device's file timestamps." + ) + if self.__format: + self._resolve_device_timezone() + + def _resolve_device_timezone(self) -> None: + """Name the device's timezone in module_options unless it is known already. + + A bugreport's SYSTEM PROPERTIES section carries persist.sys.timezone. + Zip entry times are the device's wall clock, and modules read them in + this zone; --timezone or check-androidqf's own reading takes precedence. + """ + if self.module_options.get("device_timezone"): + self.log.info("Device timezone: %s", self.module_options["device_timezone"]) return - if os.path.isfile(self.target_path): - self.from_zip(ZipFile(self.target_path)) - elif os.path.isdir(self.target_path): - self.from_dir(self.target_path) + probe = BugReportModule(log=self.log) + self.module_init(probe) + timezone = None + try: + dumpstate = probe._get_dumpstate_file() + except Exception as exc: + self.log.warning("Could not read the bugreport's dumpstate: %s", exc) + dumpstate = None + if dumpstate: + properties = GetProp() + properties.parse( + BugReportModule.extract_command_section( + dumpstate.decode("utf-8", errors="replace"), + "------ SYSTEM PROPERTIES", + ) + ) + timezone = properties.get_device_timezone() + if timezone: + self.log.info("Device timezone identified from the bugreport: %s", timezone) + self.module_options["device_timezone"] = timezone + else: + self.log.warning( + "persist.sys.timezone not found in the bugreport; file timestamps " + "are the device's wall clock without a timezone. Pass --timezone " + "to name it." + ) def module_init(self, module: BugReportModule) -> None: # type: ignore[override] if self.__format == "zip": diff --git a/src/mvt/android/modules/bugreport/base.py b/src/mvt/android/modules/bugreport/base.py index 01af8c96..25396f95 100644 --- a/src/mvt/android/modules/bugreport/base.py +++ b/src/mvt/android/modules/bugreport/base.py @@ -9,6 +9,7 @@ import os from pathlib import Path from typing import List, Optional from zipfile import ZipFile +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from mvt.common.module import ModuleResults, MVTModule @@ -125,12 +126,31 @@ class BugReportModule(MVTModule): lines.append(line) return "\n".join(lines) + def _device_timezone(self) -> Optional[datetime.tzinfo]: + """The device's timezone named in module_options, or None when unknown.""" + name = self.module_options.get("device_timezone") + if not name: + return None + try: + return ZoneInfo(name) + except ZoneInfoNotFoundError: + self.log.warning("Unknown device timezone %s", name) + return None + def _get_file_modification_time(self, file_path: str) -> datetime.datetime: + """When the file was last modified. + + A zip entry carries the device's wall clock, so it is returned in the + device's timezone when the bugreport names one and naive otherwise. + An unpacked bugreport's mtime is whatever the extraction left, an + instant returned in UTC. + """ 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) + return datetime.datetime(*file_timetuple, tzinfo=self._device_timezone()) + 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, tz=datetime.timezone.utc + ) diff --git a/src/mvt/android/modules/bugreport/fs_timestamps.py b/src/mvt/android/modules/bugreport/fs_timestamps.py index 3c7e3248..800869a9 100644 --- a/src/mvt/android/modules/bugreport/fs_timestamps.py +++ b/src/mvt/android/modules/bugreport/fs_timestamps.py @@ -4,15 +4,12 @@ # https://license.mvt.re/1.1/ import logging -import datetime from typing import Optional -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 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 -from mvt.android.artifacts.getprop import GetProp class BugReportTimestamps(FileTimestampsArtifact, BugReportModule): @@ -41,41 +38,28 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule): def run(self) -> None: filesystem_files = self._get_files_by_pattern("FS/*") - timezone_name = None - dumpstate = self._get_dumpstate_file() - if dumpstate: - section = self.extract_command_section( - dumpstate.decode("utf-8", errors="replace"), - "------ SYSTEM PROPERTIES", - ) - properties = GetProp() - properties.parse(section) - timezone_name = properties.get_device_timezone() - timezone = None - if timezone_name: - try: - timezone = ZoneInfo(timezone_name) - except ZoneInfoNotFoundError: - self.log.warning("Unknown device timezone %s", timezone_name) - self.results = [] for file in filesystem_files: - # Only the modification time is available in the zip file metadata. - # The timezone is the local timezone of the machine the phone. + # A zip entry keeps the device's wall clock, read in the device's + # timezone when the command found one (see CmdAndroidCheckBugreport); + # an unpacked bugreport's mtime is read as a UTC instant. modification_time = self._get_file_modification_time(file) - utc_time = None - if timezone is not None: - utc_time = convert_datetime_to_iso( - modification_time.replace(tzinfo=timezone).astimezone( - datetime.timezone.utc - ) - ) self.results.append( { "path": file, - "modified_time": convert_datetime_to_iso(modification_time), - "modified_time_utc": utc_time, - "timezone": timezone_name, + "modified_time": convert_datetime_to_iso( + modification_time.replace(tzinfo=None) + ), + "modified_time_utc": ( + convert_datetime_to_iso(modification_time) + if modification_time.tzinfo + else None + ), + "timezone": ( + self.module_options.get("device_timezone") + if self.zip_archive + else "UTC" + ), "timestamp_source": ( "zip_metadata" if self.zip_archive else "filesystem_metadata" ), diff --git a/tests/test_check_android_bugreport.py b/tests/test_check_android_bugreport.py index bff47090..ba5b6dcc 100644 --- a/tests/test_check_android_bugreport.py +++ b/tests/test_check_android_bugreport.py @@ -3,11 +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 datetime +import logging import os +import shutil +import zipfile from click.testing import CliRunner from mvt.android.cli import check_bugreport +from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport from .utils import get_artifact_folder @@ -28,3 +33,90 @@ class TestCheckBugreportCommand: assert result.exit_code == 1 assert "Invalid bugreport archive" in result.output assert "Traceback" not in result.output + + +PROPERTIES = ( + "------ SYSTEM PROPERTIES (getprop) ------\n" + "[persist.sys.timezone]: [Africa/Nairobi]\n" + "------ 0.01s was the duration of 'SYSTEM PROPERTIES' ------\n" +) +TOMBSTONE = "android_data/bugreport/FS/data/tombstones/tombstone_00" + + +def _bugreport_zip(tmp_path, dumpstate=PROPERTIES): + """A bugreport zip holding one tombstone written at 11:38:10 device time. + + An even second: zip entry times have a two-second resolution. + """ + path = tmp_path / "bugreport.zip" + with open(os.path.join(get_artifact_folder(), TOMBSTONE), "rb") as handle: + tombstone = handle.read() + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("main_entry.txt", "dumpstate.txt") + archive.writestr("dumpstate.txt", dumpstate) + entry = zipfile.ZipInfo( + "FS/data/tombstones/tombstone_00", date_time=(2023, 3, 10, 11, 38, 10) + ) + archive.writestr(entry, tombstone) + return str(path) + + +def _tombstone_timestamp(target, **options): + cmd = CmdAndroidCheckBugreport( + target_path=target, + module_name="Tombstones", + disable_version_check=True, + disable_indicator_check=True, + **options, + ) + cmd.run() + return cmd, cmd.executed[0].results[0]["file_timestamp"] + + +class TestCheckBugreportTimezone: + def test_zip_entry_times_are_read_in_the_device_timezone(self, tmp_path): + cmd, file_timestamp = _tombstone_timestamp(_bugreport_zip(tmp_path)) + + assert cmd.module_options["device_timezone"] == "Africa/Nairobi" + # 11:38:10 in Nairobi is 08:38:10 UTC. + assert file_timestamp == "2023-03-10 08:38:10.000000" + + def test_timezone_option_wins_over_the_bugreport(self, tmp_path): + _, file_timestamp = _tombstone_timestamp( + _bugreport_zip(tmp_path), module_options={"device_timezone": "Europe/Paris"} + ) + + assert file_timestamp == "2023-03-10 10:38:10.000000" + + result = CliRunner().invoke( + check_bugreport, + ["-t", "Europe/Paris", "-m", "Tombstones", _bugreport_zip(tmp_path)], + ) + assert result.exit_code == 0, result.output + + def test_without_a_timezone_the_wall_clock_is_kept_and_a_warning_given( + self, tmp_path, caplog + ): + with caplog.at_level(logging.WARNING, logger="mvt"): + _, file_timestamp = _tombstone_timestamp(_bugreport_zip(tmp_path, "")) + + assert file_timestamp == "2023-03-10 11:38:10.000000" + assert "persist.sys.timezone not found" in caplog.text + + def test_unpacked_bugreport_warns_and_reads_mtimes_as_utc(self, tmp_path, caplog): + unpacked = tmp_path / "bugreport" + shutil.copytree( + os.path.join(get_artifact_folder(), "android_data/bugreport"), unpacked + ) + instant = datetime.datetime( + 2023, 3, 10, 8, 38, 11, tzinfo=datetime.timezone.utc + ).timestamp() + for name in ("tombstone_00", "tombstone_01"): + os.utime(unpacked / "FS" / "data" / "tombstones" / name, (instant, instant)) + + with caplog.at_level(logging.WARNING, logger="mvt"): + _, file_timestamp = _tombstone_timestamp(str(unpacked)) + + assert "unpacked bugreport" in caplog.text + # Whatever the zone of the machine running the analysis. + assert file_timestamp == "2023-03-10 08:38:11.000000" From 3623e430f4ecadf4634b9daafd05f5332228b9b2 Mon Sep 17 00:00:00 2001 From: Daniel Conor Sullivan Date: Mon, 7 Sep 2026 20:01:02 -0400 Subject: [PATCH 075/109] Defer indicator loading until first use (#919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Defer indicator loading until first use * Load indicators once at the start of a run The lazy property loads indicators wherever they are first read, which in Command.run() is inside the module loop, after init(). For check-androidqf that means the whole acquisition is walked before a missing --iocs file is reported, and the STIX parsing lines land between the module list and the first module. Read the indicators once after the module list is settled, so that a bad --module name still loads nothing, and hand the same object to every module. check-iocs reads them once too, so that a wrong --iocs path is reported even when no stored result matches a module. * Drop two test assertions the change does not need Retrying after a failed indicator load is incidental to the property rather than a requirement, so nothing should pin it. The exact wording of the check-backup rejection belongs to that command's own tests; exit code 1 already shows the path was rejected before indicators were loaded. * Create the output folder and command.log when a run starts Command.__init__ created the --output folder and attached the command.log handler, so `--list-modules -o out` or a rejected target path left an empty folder behind for a run that never happened. Do both at the top of run(), before the module list is resolved so that its warnings still reach the log. The nested commands run by check-androidqf re-attach the handler at the start of their runs, as they did at construction. Lines the CLI logs between construction and run(), such as the target path being checked, no longer reach command.log; info.json records the target path. This is the remaining part of #888. * Cache the indicators with functools.cached_property A property with a setter and a backing attribute does by hand what cached_property does: compute on first read, store the result on the instance, and accept assignment, which is how nested commands receive their parent's indicators. A failed load is still not cached. * Announce the target from the command so that it reaches command.log The CLI logged which backup, filesystem or acquisition it was about to check just before run(), which is now before command.log exists, so the line only reached the console. Each command logs it from init() instead, which runs once the log is attached, and run() logs the module list after init() so that the target still comes first. Nested commands without a target path log nothing, as before. * Log the Android backup path from the typed local mypy cannot determine the type of target_path in this command, which the surrounding lines already silence, so read the announced path from the local that carries the type instead of adding another ignore. --------- Co-authored-by: bitmeta69 <206962233+bitmeta69@users.noreply.github.com> Co-authored-by: besendorf Co-authored-by: Donncha Ó Cearbhaill --- src/mvt/android/cli.py | 8 -- src/mvt/android/cmd_check_androidqf.py | 2 + src/mvt/android/cmd_check_backup.py | 1 + src/mvt/android/cmd_check_bugreport.py | 1 + src/mvt/android/cmd_check_intrusion_logs.py | 2 + src/mvt/common/cmd_check_iocs.py | 8 +- src/mvt/common/command.py | 34 ++++++--- src/mvt/ios/cli.py | 5 -- src/mvt/ios/cmd_check_backup.py | 3 + src/mvt/ios/cmd_check_fs.py | 3 + src/mvt/ios/cmd_check_sysdiagnose.py | 2 + tests/common/test_command.py | 85 +++++++++++++++++++++ tests/test_check_ios_backup.py | 8 ++ 13 files changed, 137 insertions(+), 25 deletions(-) diff --git a/src/mvt/android/cli.py b/src/mvt/android/cli.py index e9f8d019..4c159857 100644 --- a/src/mvt/android/cli.py +++ b/src/mvt/android/cli.py @@ -200,8 +200,6 @@ def check_bugreport( cmd.list_modules() return - log.info("Checking Android bug report at path: %s", bugreport_path) - try: cmd.run() except BadZipFile as exc: @@ -276,8 +274,6 @@ def check_backup( cmd.list_modules() return - log.info("Checking Android backup at path: %s", backup_path) - cmd.run() cmd.show_alerts_brief() cmd.show_support_message() @@ -359,8 +355,6 @@ def check_androidqf( cmd.list_modules() return - log.info("Checking AndroidQF acquisition at path: %s", androidqf_path) - cmd.run() cmd.show_alerts_brief() cmd.show_disable_adb_warning() @@ -441,8 +435,6 @@ def check_intrusion_logs( cmd.list_modules() return - log.info("Checking intrusion logs at path: %s", logs_path) - cmd.run() cmd.show_alerts_brief() cmd.show_support_message() diff --git a/src/mvt/android/cmd_check_androidqf.py b/src/mvt/android/cmd_check_androidqf.py index 99ac5a3f..a86e643e 100644 --- a/src/mvt/android/cmd_check_androidqf.py +++ b/src/mvt/android/cmd_check_androidqf.py @@ -82,6 +82,8 @@ class CmdAndroidCheckAndroidQF(Command): if not self.target_path: raise NoAndroidQFTargetPath + self.log.info("Checking AndroidQF acquisition at path: %s", self.target_path) + if os.path.isdir(self.target_path): self.__format = "dir" parent_path = Path(self.target_path).absolute().parent.as_posix() diff --git a/src/mvt/android/cmd_check_backup.py b/src/mvt/android/cmd_check_backup.py index 94be4ee2..066cd8aa 100644 --- a/src/mvt/android/cmd_check_backup.py +++ b/src/mvt/android/cmd_check_backup.py @@ -129,6 +129,7 @@ class CmdAndroidCheckBackup(Command): 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] + self.log.info("Checking Android backup at path: %s", backup_path) if os.path.isfile(backup_path): self.__type = "ab" diff --git a/src/mvt/android/cmd_check_bugreport.py b/src/mvt/android/cmd_check_bugreport.py index 946feb97..036160d8 100644 --- a/src/mvt/android/cmd_check_bugreport.py +++ b/src/mvt/android/cmd_check_bugreport.py @@ -90,6 +90,7 @@ class CmdAndroidCheckBugreport(Command): def init(self) -> None: if self.target_path: + self.log.info("Checking Android bug report at path: %s", self.target_path) if os.path.isfile(self.target_path): self.from_zip(ZipFile(self.target_path)) elif os.path.isdir(self.target_path): diff --git a/src/mvt/android/cmd_check_intrusion_logs.py b/src/mvt/android/cmd_check_intrusion_logs.py index 95f20896..a652c229 100644 --- a/src/mvt/android/cmd_check_intrusion_logs.py +++ b/src/mvt/android/cmd_check_intrusion_logs.py @@ -63,6 +63,8 @@ class CmdAndroidCheckIntrusionLogs(Command): if not self.target_path: raise ValueError("No target path specified") + self.log.info("Checking intrusion logs at path: %s", self.target_path) + if not os.path.isdir(self.target_path) and not ( os.path.isfile(self.target_path) and self.target_path.lower().endswith(".zip") diff --git a/src/mvt/common/cmd_check_iocs.py b/src/mvt/common/cmd_check_iocs.py index c2dbdcd9..7c7e71e5 100644 --- a/src/mvt/common/cmd_check_iocs.py +++ b/src/mvt/common/cmd_check_iocs.py @@ -56,6 +56,10 @@ class CmdCheckIOCS(Command): if entry not in all_modules: all_modules.append(entry) + # Read the indicators once, so that a missing indicators file is + # reported even when no stored result matches a module. + iocs = self.iocs + log.info("Checking stored results against provided indicators...") total_detections = 0 @@ -83,8 +87,8 @@ class CmdCheckIOCS(Command): log.warning("No result from this module, skipping it") continue - if self.iocs.total_ioc_count > 0: - m.indicators = self.iocs + if iocs.total_ioc_count > 0: + m.indicators = iocs m.indicators.log = m.log try: diff --git a/src/mvt/common/command.py b/src/mvt/common/command.py index 12429417..6848e8c0 100644 --- a/src/mvt/common/command.py +++ b/src/mvt/common/command.py @@ -8,6 +8,7 @@ import logging import os import sys from datetime import datetime +from functools import cached_property from heapq import heappop, heappush from typing import Any, Optional @@ -84,18 +85,18 @@ class Command: self.timeline: ModuleTimeline = [] self.url_results: list[URLResult] = [] - # Load IOCs - self._create_storage() - self._setup_logging() - if iocs is not None: self.iocs = iocs - else: - self.iocs = Indicators(self.log) - self.iocs.load_indicators_files(self.ioc_files) self.alertstore = AlertStore() + @cached_property + def iocs(self) -> Indicators: + """Load indicators on first use. Nested commands share their parent's.""" + iocs = Indicators(self.log) + iocs.load_indicators_files(self.ioc_files) + return iocs + def _create_storage(self) -> None: if self.results_path and not os.path.exists(self.results_path): try: @@ -710,17 +711,30 @@ class Command: return ordered def run(self) -> None: + # The output folder and its command.log exist for a run, so that + # listing modules or rejecting a target leaves nothing behind. + # Resolving the module list can warn, so the log comes first. + self._create_storage() + self._setup_logging() + ordered_modules = self._ordered_modules() if ordered_modules is None: return - self._log_loaded_modules(ordered_modules) + # Read the indicators once the run is certain to happen, before + # init() does any work on the target, so that a missing indicators + # file is reported first and every module gets the same object. + iocs = self.iocs + # Commands announce their target from init(), so it goes before the + # module list. try: self.init() except NotImplementedError: pass + self._log_loaded_modules(ordered_modules) + executed_by_type: dict[type[MVTModule], MVTModule] = {} for module in ordered_modules: @@ -740,8 +754,8 @@ class Command: for dependency, resolved in self._module_dependencies(module) } - if self.iocs.total_ioc_count: - m.indicators = self.iocs + if iocs.total_ioc_count: + m.indicators = iocs m.indicators.log = m.log if self.serial: diff --git a/src/mvt/ios/cli.py b/src/mvt/ios/cli.py index 4560010a..f9f58885 100644 --- a/src/mvt/ios/cli.py +++ b/src/mvt/ios/cli.py @@ -318,8 +318,6 @@ def check_backup( if not cmd.resolve_backup_path(): ctx.exit(1) - log.info("Checking iTunes backup located at: %s", cmd.target_path) - cmd.run() cmd.show_alerts_brief() cmd.show_support_message() @@ -386,8 +384,6 @@ def check_fs( cmd.list_modules() return - log.info("Checking iOS filesystem located at: %s", dump_path) - cmd.run() cmd.show_alerts_brief() cmd.show_support_message() @@ -463,7 +459,6 @@ def check_sysdiagnose( cmd.list_modules() return - log.info("Checking iOS sysdiagnose at path: %s", sysdiagnose_path) cmd.run() cmd.show_alerts_brief() cmd.show_support_message() diff --git a/src/mvt/ios/cmd_check_backup.py b/src/mvt/ios/cmd_check_backup.py index 1b90584b..42b13c56 100644 --- a/src/mvt/ios/cmd_check_backup.py +++ b/src/mvt/ios/cmd_check_backup.py @@ -59,6 +59,9 @@ class CmdIOSCheckBackup(Command): self.name = "check-backup" self.modules = BACKUP_MODULES + MIXED_MODULES + def init(self) -> None: + self.log.info("Checking iTunes backup located at: %s", self.target_path) + def resolve_backup_path(self) -> bool: target_path = getattr(self, "target_path", None) if not isinstance(target_path, str) or not target_path: diff --git a/src/mvt/ios/cmd_check_fs.py b/src/mvt/ios/cmd_check_fs.py index e76146ec..3daed974 100644 --- a/src/mvt/ios/cmd_check_fs.py +++ b/src/mvt/ios/cmd_check_fs.py @@ -51,5 +51,8 @@ class CmdIOSCheckFS(Command): self.name = "check-fs" self.modules = FS_MODULES + MIXED_MODULES + def init(self) -> None: + self.log.info("Checking iOS filesystem located at: %s", self.target_path) + def module_init(self, module): module.is_fs_dump = True diff --git a/src/mvt/ios/cmd_check_sysdiagnose.py b/src/mvt/ios/cmd_check_sysdiagnose.py index 88a12db0..c700e6bb 100644 --- a/src/mvt/ios/cmd_check_sysdiagnose.py +++ b/src/mvt/ios/cmd_check_sysdiagnose.py @@ -81,6 +81,8 @@ class CmdIOSCheckSysdiagnose(Command): if not self.target_path: raise ValueError("A sysdiagnose path is required") + self.log.info("Checking iOS sysdiagnose at path: %s", self.target_path) + if os.path.isdir(self.target_path): self.sysdiagnose_format = "dir" parent_path = Path(self.target_path).absolute().parent diff --git a/tests/common/test_command.py b/tests/common/test_command.py index 8e09f57c..357d3b7d 100644 --- a/tests/common/test_command.py +++ b/tests/common/test_command.py @@ -5,8 +5,13 @@ import json import logging +from unittest.mock import patch + +import pytest +from click.testing import CliRunner from mvt.common.command import Command +from mvt.common.indicators import Indicators from mvt.common.module import MVTModule @@ -197,6 +202,86 @@ class RecordingCommand(Command): class TestCommand: + def test_listing_modules_does_not_load_indicators(self): + with patch("mvt.common.command.Indicators.load_indicators_files") as load: + cmd = RecordingCommand() + cmd.list_modules() + load.assert_not_called() + + def test_output_folder_is_created_by_a_run_not_by_listing(self, tmp_path): + output_path = tmp_path / "out" + with patch("mvt.common.command.Indicators.load_indicators_files"): + cmd = RecordingCommand(results_path=str(output_path)) + cmd.list_modules() + assert not output_path.exists() + cmd.run() + assert (output_path / "command.log").is_file() + + def test_indicators_load_once_and_are_shared(self, indicator_file, monkeypatch): + from mvt.common.config import settings + + monkeypatch.setattr(settings, "STIX2", "") + monkeypatch.setattr(Indicators, "_load_downloaded_indicators", lambda self: None) + original = Indicators.load_indicators_files + with patch.object( + Indicators, "load_indicators_files", autospec=True, side_effect=original + ) as load: + cmd = RecordingCommand(ioc_files=[indicator_file]) + load.assert_not_called() + indicators = cmd.iocs + assert indicators.total_ioc_count == 9 + assert len(indicators.ioc_collections) == 1 + assert cmd.iocs is indicators + child = RecordingCommand(iocs=indicators) + assert child.iocs is indicators + load.assert_called_once_with(indicators, [indicator_file]) + + @pytest.mark.parametrize("assign", [False, True]) + def test_supplied_empty_indicators_are_not_loaded(self, assign): + indicators = Indicators(logging.getLogger(__name__)) + with patch.object(Indicators, "load_indicators_files") as load: + cmd = RecordingCommand(iocs=None if assign else indicators) + if assign: + cmd.iocs = indicators + assert cmd.iocs is indicators + assert cmd.iocs.total_ioc_count == 0 + load.assert_not_called() + + @pytest.mark.parametrize("list_modules", [False, True]) + def test_backup_cli_does_not_load_indicators_before_analysis( + self, tmp_path, list_modules + ): + from mvt.ios.cli import check_backup + + args = [str(tmp_path)] + if list_modules: + args.insert(0, "--list-modules") + with patch.object(Indicators, "load_indicators_files") as load: + result = CliRunner().invoke(check_backup, args) + assert result.exit_code == (0 if list_modules else 1) + load.assert_not_called() + + def test_run_checks_synthetic_indicators(self, indicator_file, monkeypatch): + from mvt.common.config import settings + + monkeypatch.setattr(settings, "STIX2", "") + monkeypatch.setattr(Indicators, "_load_downloaded_indicators", lambda self: None) + + class MatchingModule(RecordingModule): + def run(self): + self.results = ["https://example.org/test"] + + def check_indicators(self): + self.detected = [ + url for url in self.results if self.indicators.check_domain(url) + ] + + cmd = RecordingCommand(ioc_files=[indicator_file]) + cmd.modules = [MatchingModule] + cmd.run() + assert cmd.executed[0].detected == ["https://example.org/test"] + assert cmd.executed[0].indicators is cmd.iocs + def setup_method(self): RecordingModule.run_order = [] diff --git a/tests/test_check_ios_backup.py b/tests/test_check_ios_backup.py index eb10b131..3290e6d1 100644 --- a/tests/test_check_ios_backup.py +++ b/tests/test_check_ios_backup.py @@ -19,6 +19,14 @@ class TestCheckBackupCommand: result = runner.invoke(check_backup, [path]) assert result.exit_code == 0 + def test_check_logs_the_backup_path_to_the_command_log(self, tmp_path): + path = get_ios_backup_folder() + output_path = tmp_path / "out" + result = CliRunner().invoke(check_backup, ["--output", str(output_path), path]) + assert result.exit_code == 0 + command_log = (output_path / "command.log").read_text(encoding="utf-8") + assert f"Checking iTunes backup located at: {path}" in command_log + def test_check_finds_backup_in_subfolder(self, tmp_path, caplog): runner = CliRunner() backup_path = tmp_path / "MobileSync" / "Backup" / "device-id" From ffae240355a9245450ecbbc9f089c592b3423000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Tue, 8 Sep 2026 01:03:05 +0100 Subject: [PATCH 076/109] Skip AppleDouble sidecars when listing a sysdiagnose (#922) Device-generated sysdiagnose archives carry a ._name entry beside every file that has extended attributes, an ACL or Finder info; one iOS 26 archive held 1234 of them among 3648 members, and the count grows with each release. bsdtar folds them back into the file on extraction and hides them from listings, but tarfile returns them as regular members, so check-sysdiagnose extracted them and handed them to every module. A module that globs for plists or logs then tries to parse AppleDouble headers and logs one warning per sidecar. Leave them out of the file list, both for archives and for folders extracted on a system that keeps them as files. --- src/mvt/ios/cmd_check_sysdiagnose.py | 7 +++++++ tests/test_cmd_check_sysdiagnose.py | 3 +++ 2 files changed, 10 insertions(+) diff --git a/src/mvt/ios/cmd_check_sysdiagnose.py b/src/mvt/ios/cmd_check_sysdiagnose.py index c700e6bb..26e4d49f 100644 --- a/src/mvt/ios/cmd_check_sysdiagnose.py +++ b/src/mvt/ios/cmd_check_sysdiagnose.py @@ -88,6 +88,8 @@ class CmdIOSCheckSysdiagnose(Command): parent_path = Path(self.target_path).absolute().parent for root, _, filenames in os.walk(self.target_path): for filename in filenames: + if filename.startswith("._"): + continue absolute_path = os.path.join(root, filename) file_path = os.path.relpath(absolute_path, parent_path) self.sysdiagnose_files.append(file_path) @@ -137,6 +139,11 @@ class CmdIOSCheckSysdiagnose(Command): if not member_path.parts: continue + # AppleDouble sidecars (._name) carry a file's extended attributes, + # not sysdiagnose content. Device archives hold hundreds of them; + # bsdtar hides them from listings, tarfile does not. + if member_path.name.startswith("._"): + continue archive_roots.add(member_path.parts[0]) if member.isdir(): diff --git a/tests/test_cmd_check_sysdiagnose.py b/tests/test_cmd_check_sysdiagnose.py index 020c8616..b00b648b 100644 --- a/tests/test_cmd_check_sysdiagnose.py +++ b/tests/test_cmd_check_sysdiagnose.py @@ -34,6 +34,7 @@ def _create_sysdiagnose_folder(tmp_path): "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") + (folder / "._artifact.txt").write_bytes(b"\x00\x05\x16\x07AppleDouble") return folder @@ -67,6 +68,7 @@ def test_check_sysdiagnose_from_folder(tmp_path): assert _test_module(command).ips_files == [ {"file_path": str(tmp_path / "sysdiagnose" / "report.ips"), "bug_type": 210} ] + assert "sysdiagnose/._artifact.txt" not in command.sysdiagnose_files def test_check_sysdiagnose_from_archive_closes_archive(tmp_path): @@ -83,6 +85,7 @@ def test_check_sysdiagnose_from_archive_closes_archive(tmp_path): } ] assert command.sysdiagnose_archive is None + assert "sysdiagnose/._artifact.txt" not in command.sysdiagnose_files def test_archive_is_extracted_once_and_unsafe_members_are_skipped(tmp_path): From eceafdc785db4c77ae839db1b3885ba307caa2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Tue, 8 Sep 2026 01:04:43 +0100 Subject: [PATCH 077/109] Keep the test suite out of the user's MVT folders (#923) The tests read the settings file of whoever runs them, and importing mvt.common.config writes it back. On a machine whose config.yaml sets NETWORK_ACCESS_ALLOWED to false, the plugin update and URL batch tests fail before their mocked requests are reached, and every Command run in the suite parses the indicators downloaded on that machine, which made the suite take minutes instead of seconds. MVT_CONFIG_FOLDER and MVT_DATA_FOLDER in the environment now relocate the settings file and the downloaded indicators with their update-check state. The test conftest points both at a throwaway folder before any mvt module is imported, and removes it at exit. Subprocesses started by the tests inherit the variables; the isolated interpreter helper already gives them a temporary home. On the machine that prompted this the suite goes from 6 failures in six minutes to none in seven seconds, and config.yaml is left alone. --- src/mvt/common/config.py | 4 +++- src/mvt/common/indicators.py | 12 +++++++----- tests/conftest.py | 27 ++++++++++++++++++++------- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/mvt/common/config.py b/src/mvt/common/config.py index 24bf4072..fc4b2ea5 100644 --- a/src/mvt/common/config.py +++ b/src/mvt/common/config.py @@ -12,7 +12,9 @@ from pydantic_settings import ( YamlConfigSettingsSource, ) -MVT_CONFIG_FOLDER = user_config_dir("mvt") +# MVT_CONFIG_FOLDER in the environment relocates the settings file, so that +# a test run or a scripted install never touches the user's own. +MVT_CONFIG_FOLDER = os.environ.get("MVT_CONFIG_FOLDER") or user_config_dir("mvt") MVT_CONFIG_PATH = os.path.join(MVT_CONFIG_FOLDER, "config.yaml") diff --git a/src/mvt/common/indicators.py b/src/mvt/common/indicators.py index 94afadf1..3886e34f 100644 --- a/src/mvt/common/indicators.py +++ b/src/mvt/common/indicators.py @@ -18,7 +18,9 @@ from appdirs import user_data_dir from .config import settings from .url import URL -MVT_DATA_FOLDER = user_data_dir("mvt") +# MVT_DATA_FOLDER in the environment relocates the downloaded indicators and +# the update-check state kept next to them. +MVT_DATA_FOLDER = os.environ.get("MVT_DATA_FOLDER") or user_data_dir("mvt") MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators") logger = logging.getLogger(__name__) @@ -71,7 +73,9 @@ class Indicators: if os.path.isfile(path) and path.lower().endswith(".stix2"): self.parse_stix2(path) elif os.path.isdir(path): - for file in glob.glob(os.path.join(path, "**", "*.stix2"), recursive=True): + for file in glob.glob( + os.path.join(path, "**", "*.stix2"), recursive=True + ): self.parse_stix2(file) else: self.log.error( @@ -518,9 +522,7 @@ class Indicators: 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) - ) + unique_urls = list(dict.fromkeys(url for urls in batches for url in urls)) if not unique_urls: return [None] * len(batches) diff --git a/tests/conftest.py b/tests/conftest.py index 06a890a9..40a084a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,20 +3,26 @@ # Use of this software is governed by the MVT License 1.1 that can be found at # https://license.mvt.re/1.1/ +import atexit import logging import os +import shutil +import tempfile import pytest -from mvt.common.cli_plugins import ( - MVT_ANDROID_CUSTOM_COMMANDS_ENV, - MVT_CUSTOM_COMMANDS_ENV, - MVT_IOS_CUSTOM_COMMANDS_ENV, -) -from mvt.common.indicators import Indicators - from .artifacts.generate_stix import generate_test_stix_file +# The suite must neither read nor write the developer's own MVT settings, +# downloaded indicators or update-check state, and mvt.common.config saves +# the settings file as soon as it is imported. Both folders are redirected +# before any mvt module is imported, which is why this file imports none at +# the top; the subprocesses the tests start inherit the variables. +MVT_TEST_HOME = tempfile.mkdtemp(prefix="mvt-tests-") +atexit.register(shutil.rmtree, MVT_TEST_HOME, ignore_errors=True) +os.environ["MVT_CONFIG_FOLDER"] = os.path.join(MVT_TEST_HOME, "config") +os.environ["MVT_DATA_FOLDER"] = os.path.join(MVT_TEST_HOME, "data") + @pytest.fixture(scope="session", autouse=True) def indicator_file(request, tmp_path_factory): @@ -47,6 +53,8 @@ def indicators_factory(indicator_file): android_property_names=[], files_sha256=[], ): + from mvt.common.indicators import Indicators + ind = Indicators(log=logging.getLogger()) ind.parse_stix2(indicator_file) @@ -77,6 +85,11 @@ def restore_cli_commands(monkeypatch): """ from mvt.android.cli import cli as android_cli from mvt.cli import cli as neutral_cli + from mvt.common.cli_plugins import ( + MVT_ANDROID_CUSTOM_COMMANDS_ENV, + MVT_CUSTOM_COMMANDS_ENV, + MVT_IOS_CUSTOM_COMMANDS_ENV, + ) from mvt.ios.cli import cli as ios_cli groups = (neutral_cli, ios_cli, android_cli) From b4b0a0647080bd0bbcddb7508e6427fd51ea6847 Mon Sep 17 00:00:00 2001 From: DonnchaC <3081375+DonnchaC@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:16:19 +0000 Subject: [PATCH 078/109] Add new iOS versions and build numbers --- src/mvt/ios/data/ios_versions.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mvt/ios/data/ios_versions.json b/src/mvt/ios/data/ios_versions.json index 9b4613bb..d8a93c91 100644 --- a/src/mvt/ios/data/ios_versions.json +++ b/src/mvt/ios/data/ios_versions.json @@ -1271,5 +1271,9 @@ { "version": "26.6.1", "build": "23G83" + }, + { + "version": "26.6.2", + "build": "23G90" } ] \ No newline at end of file From 86b217a926b51d97d9cfb6c29f69e4ed7dac6180 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Fri, 11 Sep 2026 11:14:53 +0200 Subject: [PATCH 079/109] Recommend pymobiledevice3 for iOS acquisition --- docs/docker.md | 4 ++- docs/ios/backup/libimobiledevice.md | 18 +--------- docs/ios/backup/pymobiledevice3.md | 37 +++++++++++++++++++++ docs/ios/install.md | 51 +++++++++++------------------ mkdocs.yml | 5 +-- 5 files changed, 64 insertions(+), 51 deletions(-) create mode 100644 docs/ios/backup/pymobiledevice3.md diff --git a/docs/docker.md b/docs/docker.md index 453c245f..9082b92b 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,4 +1,6 @@ -Using Docker simplifies having all the required dependencies and tools (including most recent versions of [libimobiledevice](https://libimobiledevice.org)) readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container). +Using Docker simplifies running MVT with its dependencies readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container). + +For iOS acquisition, we recommend [installing pymobiledevice3](ios/install.md) on the host and [creating a backup](ios/backup/pymobiledevice3.md) before analyzing it with MVT. The Docker images currently include libimobiledevice, not pymobiledevice3. Install Docker following the [official documentation](https://docs.docker.com/get-docker/). diff --git a/docs/ios/backup/libimobiledevice.md b/docs/ios/backup/libimobiledevice.md index d72868a5..39eacee7 100644 --- a/docs/ios/backup/libimobiledevice.md +++ b/docs/ios/backup/libimobiledevice.md @@ -1,19 +1,3 @@ # Backup with libimobiledevice -If you have correctly [installed libimobiledevice](../install.md) you can easily generate an iTunes backup using the `idevicebackup2` tool included in the suite. First, you might want to ensure that backup encryption is enabled (**note: encrypted backup contain more data than unencrypted backups**): - -```bash -idevicebackup2 -i encryption on -``` - -Note that if a backup password was previously set on this device, you might need to use the same or change it. You can try changing password using `idevicebackup2 -i changepw`, or by turning off encryption (`idevicebackup2 -i encryption off`) and turning it back on again. - -If you are not able to recover or change the password, you should try to disable encryption and obtain an unencrypted backup. - -If all else fails, as a *last resort* you can try resetting the password by [resetting all the settings through the iPhone's Settings app](https://support.apple.com/en-us/HT205220), via `Settings » General » Reset » Reset All Settings`. Note that resetting the settings through the iPhone's Settings app will wipe some of the files that contain useful forensic traces, so try the options explained above first. - -Once ready, you can proceed performing the backup: - -```bash -idevicebackup2 backup --full /path/to/backup/ -``` +We now recommend pymobiledevice3 for iOS backups. See [Backup with pymobiledevice3](pymobiledevice3.md) for the current instructions. diff --git a/docs/ios/backup/pymobiledevice3.md b/docs/ios/backup/pymobiledevice3.md new file mode 100644 index 00000000..73c6a2a5 --- /dev/null +++ b/docs/ios/backup/pymobiledevice3.md @@ -0,0 +1,37 @@ +# Backup with pymobiledevice3 + +After [installing pymobiledevice3](../install.md) and pairing your device, you can generate an iTunes-compatible backup using its `backup2` commands. + +## Enable backup encryption + +We recommend encrypted backups because they contain more data than unencrypted backups. If encryption is not already enabled, enable it with: + +```bash +pymobiledevice3 backup2 encryption on 'YOUR_BACKUP_PASSWORD' +``` + +Replace `YOUR_BACKUP_PASSWORD` with a strong password and keep it safe: you will need it to decrypt the backup for analysis. Passwords supplied on the command line may be saved in shell history or visible to other processes. + +If a backup password was previously set, use that password. To change a known password: + +```bash +pymobiledevice3 backup2 change-password 'CURRENT_PASSWORD' 'NEW_PASSWORD' +``` + +You can also disable encryption with `pymobiledevice3 backup2 encryption off 'CURRENT_PASSWORD'` and then enable it again. Disabling encryption requires the current password; it is not a way to bypass an unknown password. + +!!! warning + If you cannot recover the password, resetting it through [Reset All Settings in the iPhone's Settings app](https://support.apple.com/en-us/HT205220) should be a last resort. Resetting settings can remove files containing useful forensic traces. + +## Create a backup + +Choose a new destination directory to avoid overwriting an earlier acquisition: + +```bash +mkdir -p /path/to/backup/ +pymobiledevice3 backup2 backup --full /path/to/backup/ +``` + +The backup is saved in a subdirectory named after the device's UDID, such as `/path/to/backup/udid/`. Follow [Check a Backup with mvt-ios](check.md) to decrypt and analyze that directory. + +For additional options, see the [pymobiledevice3 backup2 reference](https://doronz88.github.io/pymobiledevice3/cli/backup2/). diff --git a/docs/ios/install.md b/docs/ios/install.md index 1898f3f5..d5e1574f 100644 --- a/docs/ios/install.md +++ b/docs/ios/install.md @@ -1,55 +1,44 @@ -# Install libimobiledevice +# Install pymobiledevice3 -Before proceeding with doing any acquisition of iOS devices we recommend installing [libimobiledevice](https://libimobiledevice.org/) utilities. These utilities will become useful when extracting crash logs and generating iTunes backups. Because the utilities and its libraries are subject to frequent changes in response to new versions of iOS, you might want to consider compiling libimobiledevice utilities from sources. Otherwise, if available, you can try installing packages available in your distribution: +Before acquiring data from an iOS device, we recommend installing [pymobiledevice3](https://github.com/doronz88/pymobiledevice3). It provides command-line tools for generating iTunes-compatible backups and extracting crash logs. + +## Installation + +Install `pipx` following the [MVT installation instructions](../install.md), then run: ```bash -sudo apt install libimobiledevice-utils +pipx install pymobiledevice3 ``` -On Mac, you can try installing it from brew: +To update an existing installation: ```bash -brew install --HEAD libimobiledevice +pipx upgrade pymobiledevice3 ``` -If you have a reasonably recent version of libimobiledevice in your package manager, it might work straight out of the box. Try connecting your iOS device to your computer via USB and run: +On Linux, you also need `usbmuxd` to communicate with devices over USB. On Debian and Ubuntu, install it with: ```bash -ideviceinfo +sudo apt install usbmuxd ``` -If you encounter unexpected issues, uninstall the packages and try compiling libimobiledevcice from sources. +macOS includes the required USB device service. For other platforms and additional requirements, see the [pymobiledevice3 installation documentation](https://doronz88.github.io/pymobiledevice3/installation/). -## Compile libimobiledevice from sources +## Verify connectivity -!!! warning - The following instructions are a best effort. The installation from source requires several steps, and it is likely some have been forgotten here and that won't work for you. You will likely need to fiddle around a bit before getting this right. - -Make sure you have uninstalled all the libimobiledevice tools from your package manage: +Connect the iOS device to your computer with a USB cable and unlock it. Accept the **Trust This Computer** prompt and enter the device passcode if requested. Then run: ```bash -sudo apt remove --purge libimobiledevice-utils libimobiledevice-dev libimobiledevice6 libplist-dev libplist3 libusbmuxd-dev libusbmuxd-tools libusbmuxd4 libusbmuxd6 usbmuxd +pymobiledevice3 usbmux list +pymobiledevice3 lockdown info ``` -Firstly you need to install [libplist](https://github.com/libimobiledevice/libplist). Then you can install [libusbmuxd](https://github.com/libimobiledevice/libusbmuxd). - -Now you should be able to to download and install the actual suite of tools at [https://github.com/libimobiledevice/libimobiledevice](https://github.com/libimobiledevice/libimobiledevice). - -You can now also build and install [usbmuxd](https://github.com/libimobiledevice/usbmuxd). - -## Making sure everything works fine. - -Once the idevice tools are available you can check if everything works fine by connecting your iOS device and running: +These commands should list the connected device and display its details. If pairing is needed, run: ```bash -ideviceinfo +pymobiledevice3 lockdown pair ``` -This should show many details on the connected iOS device. If you are connecting the device to your laptop for the first time, it will require to unlock and enter the PIN code on the mobile device. If it complains that no device is connected and the mobile device is indeed plugged in through the USB cable, you might need to do this first, although typically the pairing is automatically done when connecting the device: +If no device is found, check the USB connection, make sure the device is unlocked, and, on Linux, check that `usbmuxd` is running. See the [upstream troubleshooting guide](https://doronz88.github.io/pymobiledevice3/guides/troubleshooting/) for further help. -```bash -sudo usbmuxd -f -v -idevicepair pair -``` - -Again, it will ask to unlock the phone and enter the PIN code. +Once connected, follow the [backup instructions](backup/pymobiledevice3.md). diff --git a/mkdocs.yml b/mkdocs.yml index 66def17e..4cbdb47b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,7 @@ copyright: Copyright © 2021-2023 MVT Project Developers site_description: Mobile Verification Toolkit Documentation not_in_nav: | android/adb.md + ios/backup/libimobiledevice.md markdown_extensions: - attr_list - admonition @@ -34,10 +35,10 @@ nav: - Using Docker: "docker.md" - MVT for iOS: - iOS Forensic Methodology: "ios/methodology.md" - - Install libimobiledevice: "ios/install.md" + - Install pymobiledevice3: "ios/install.md" - Check an iTunes Backup: - Backup with iTunes app: "ios/backup/itunes.md" - - Backup with libimobiledevice: "ios/backup/libimobiledevice.md" + - Backup with pymobiledevice3: "ios/backup/pymobiledevice3.md" - Check a Backup with mvt-ios: "ios/backup/check.md" - Check a Filesystem Dump: - Dumping the filesystem: "ios/filesystem/dump.md" From d0cf303aa27ae7b0427af080c30e9b853456f5d2 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Fri, 11 Sep 2026 11:21:57 +0200 Subject: [PATCH 080/109] Replace libimobiledevice with pymobiledevice3 in Docker images --- Dockerfile | 129 +++++--------------------------------------- Dockerfile.ios | 144 ++++++++----------------------------------------- docs/docker.md | 25 +++++++-- 3 files changed, 57 insertions(+), 241 deletions(-) diff --git a/Dockerfile b/Dockerfile index 138edc7e..a83ef115 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,107 +1,3 @@ -# Base image for building libraries -# --------------------------------- -FROM ubuntu:22.04 AS build-base - -ARG DEBIAN_FRONTEND=noninteractive - -# Install build tools and dependencies -RUN apt-get update \ - && apt-get install -y \ - build-essential \ - git \ - autoconf \ - automake \ - libtool-bin \ - pkg-config \ - libcurl4-openssl-dev \ - libusb-1.0-0-dev \ - libssl-dev \ - udev \ - && rm -rf /var/lib/apt/lists/* - - -# libplist -# -------- -FROM build-base AS build-libplist - -# Build -RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \ - && ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf libplist - - -# libimobiledevice-glue -# --------------------- -FROM build-base AS build-libimobiledevice-glue - -# Install dependencies -COPY --from=build-libplist /build / - -# Build -RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd libimobiledevice-glue \ - && ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf libimobiledevice-glue - - -# libtatsu -# -------- -FROM build-base AS build-libtatsu - -# Install dependencies -COPY --from=build-libplist /build / - -# Build -RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \ - && ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf libtatsu - - -# libusbmuxd -# ---------- -FROM build-base AS build-libusbmuxd - -# Install dependencies -COPY --from=build-libplist /build / -COPY --from=build-libimobiledevice-glue /build / - -# Build -RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \ - && ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf libusbmuxd - - -# libimobiledevice -# ---------------- -FROM build-base AS build-libimobiledevice - -# Install dependencies -COPY --from=build-libplist /build / -COPY --from=build-libtatsu /build / -COPY --from=build-libimobiledevice-glue /build / -COPY --from=build-libusbmuxd /build / - -# Build -RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimobiledevice \ - && ./autogen.sh --enable-debug && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf libimobiledevice - - -# usbmuxd -# ------- -FROM build-base AS build-usbmuxd - -# Install dependencies -COPY --from=build-libplist /build / -COPY --from=build-libimobiledevice-glue /build / -COPY --from=build-libusbmuxd /build / -COPY --from=build-libimobiledevice /build / - -# Build -RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \ - && ./autogen.sh --sysconfdir=/etc --localstatedir=/var --runstatedir=/run && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf usbmuxd && mv /build/lib /build/usr/lib - - # Create main image FROM ubuntu:24.04 AS main COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/ @@ -112,7 +8,7 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt" LABEL org.opencontainers.image.title="Mobile Verification Toolkit" LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices." LABEL org.opencontainers.image.licenses="MVT License 1.1" -LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:22.04 +LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:24.04 # Install runtime dependencies ARG DEBIAN_FRONTEND=noninteractive @@ -125,7 +21,6 @@ RUN apt-get update \ git \ jq \ less \ - libcurl4 \ libimage-exiftool-perl \ libssl3 \ libusb-1.0-0 \ @@ -136,18 +31,20 @@ RUN apt-get update \ sqlite3 \ tree \ unzip \ - xxd -COPY --from=build-libplist /build / -COPY --from=build-libimobiledevice-glue /build / -COPY --from=build-libtatsu /build / -COPY --from=build-libusbmuxd /build / -COPY --from=build-libimobiledevice /build / -COPY --from=build-usbmuxd /build / + xxd \ + && rm -rf /var/lib/apt/lists/* -# Install mvt using the locally checked out source +# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64. +ARG PYMOBILEDEVICE3_VERSION=11.12.4 COPY . mvt/ -RUN uv pip install --system --break-system-packages --no-cache ./mvt \ - && rm -rf mvt +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \ + && uv pip install --system --break-system-packages --no-cache ./mvt "pymobiledevice3==${PYMOBILEDEVICE3_VERSION}" \ + && apt-get purge -y --auto-remove build-essential python3-dev libssl-dev \ + && rm -rf mvt /var/lib/apt/lists/* \ + && pymobiledevice3 --help > /dev/null \ + && pymobiledevice3 backup2 --help > /dev/null \ + && mvt-ios --help > /dev/null # Installing ABE ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \ diff --git a/Dockerfile.ios b/Dockerfile.ios index fccbe45b..90ef4e4c 100644 --- a/Dockerfile.ios +++ b/Dockerfile.ios @@ -1,110 +1,6 @@ -# Base image for building libraries -# --------------------------------- -FROM ubuntu:22.04 AS build-base - -ARG DEBIAN_FRONTEND=noninteractive - -# Install build tools and dependencies -RUN apt-get update \ - && apt-get install -y \ - build-essential \ - git \ - autoconf \ - automake \ - libtool-bin \ - pkg-config \ - libcurl4-openssl-dev \ - libusb-1.0-0-dev \ - libssl-dev \ - udev \ - && rm -rf /var/lib/apt/lists/* - - -# libplist -# -------- -FROM build-base AS build-libplist - -# Build -RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \ - && ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf libplist - - -# libimobiledevice-glue -# --------------------- -FROM build-base AS build-libimobiledevice-glue - -# Install dependencies -COPY --from=build-libplist /build / - -# Build -RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd libimobiledevice-glue \ - && ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf libimobiledevice-glue - - -# libtatsu -# -------- -FROM build-base AS build-libtatsu - -# Install dependencies -COPY --from=build-libplist /build / - -# Build -RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \ - && ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf libtatsu - - -# libusbmuxd -# ---------- -FROM build-base AS build-libusbmuxd - -# Install dependencies -COPY --from=build-libplist /build / -COPY --from=build-libimobiledevice-glue /build / - -# Build -RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \ - && ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf libusbmuxd - - -# libimobiledevice -# ---------------- -FROM build-base AS build-libimobiledevice - -# Install dependencies -COPY --from=build-libplist /build / -COPY --from=build-libtatsu /build / -COPY --from=build-libimobiledevice-glue /build / -COPY --from=build-libusbmuxd /build / - -# Build -RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimobiledevice \ - && ./autogen.sh --enable-debug && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf libimobiledevice - - -# usbmuxd -# ------- -FROM build-base AS build-usbmuxd - -# Install dependencies -COPY --from=build-libplist /build / -COPY --from=build-libimobiledevice-glue /build / -COPY --from=build-libusbmuxd /build / -COPY --from=build-libimobiledevice /build / - -# Build -RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \ - && ./autogen.sh --sysconfdir=/etc --localstatedir=/var --runstatedir=/run && make -j "$(nproc)" && make install DESTDIR=/build \ - && cd .. && rm -rf usbmuxd && mv /build/lib /build/usr/lib - - # Main image # ---------- -FROM python:3.10.14-alpine3.20 AS main +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" @@ -113,26 +9,30 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt" LABEL org.opencontainers.image.title="Mobile Verification Toolkit (iOS)" LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices." LABEL org.opencontainers.image.licenses="MVT License 1.1" -LABEL org.opencontainers.image.base.name=docker.io/library/python:3.10.14-alpine3.20 +LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:24.04 # Install runtime dependencies -RUN apk add --no-cache \ - gcompat \ - libcurl \ +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ libssl3 \ - libusb \ - sqlite -COPY --from=build-libplist /build / -COPY --from=build-libimobiledevice-glue /build / -COPY --from=build-libtatsu /build / -COPY --from=build-libusbmuxd /build / -COPY --from=build-libimobiledevice /build / -COPY --from=build-usbmuxd /build / + libusb-1.0-0 \ + python3 \ + sqlite3 \ + && rm -rf /var/lib/apt/lists/* -# Install mvt using the locally checked out source -COPY ./ mvt -RUN apk add --no-cache --virtual .build-deps git gcc musl-dev \ - && uv pip install --system --no-cache ./mvt \ - && apk del .build-deps git gcc musl-dev && rm -rf ./mvt +# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64. +ARG PYMOBILEDEVICE3_VERSION=11.12.4 +COPY . mvt/ +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \ + && uv pip install --system --break-system-packages --no-cache ./mvt "pymobiledevice3==${PYMOBILEDEVICE3_VERSION}" \ + && apt-get purge -y --auto-remove build-essential python3-dev libssl-dev \ + && rm -rf mvt /var/lib/apt/lists/* \ + && pymobiledevice3 --help > /dev/null \ + && pymobiledevice3 backup2 --help > /dev/null \ + && mvt-ios --help > /dev/null ENTRYPOINT [ "/usr/local/bin/mvt-ios" ] diff --git a/docs/docker.md b/docs/docker.md index 9082b92b..10d9cd09 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,6 +1,6 @@ Using Docker simplifies running MVT with its dependencies readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container). -For iOS acquisition, we recommend [installing pymobiledevice3](ios/install.md) on the host and [creating a backup](ios/backup/pymobiledevice3.md) before analyzing it with MVT. The Docker images currently include libimobiledevice, not pymobiledevice3. +The main and iOS Docker images include pymobiledevice3 for [creating iOS backups](ios/backup/pymobiledevice3.md). You do not need to install it separately on the host. Older image tags may still include libimobiledevice instead; build from the updated source if pymobiledevice3 is unavailable. Install Docker following the [official documentation](https://docs.docker.com/get-docker/). @@ -39,12 +39,31 @@ If a prompt is spawned successfully, you can close it with `exit`. On the Linux host, install and start [usbmuxd](https://github.com/libimobiledevice/usbmuxd), then connect and unlock the iOS device. The daemon exposes the device through the `/var/run/usbmuxd` socket. -Bind that socket into the container to let MVT communicate with the device: +Bind that socket into the container to let pymobiledevice3 communicate with the device. Also mount a local directory so acquired backups persist after the container exits: ```bash -docker run -it \ +mkdir -p "$PWD/cases" +docker run --rm -it \ --mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \ + --mount type=bind,source="$PWD/cases",target=/home/cases \ ghcr.io/mvt-project/mvt ``` +Inside the container, verify connectivity: + +```bash +pymobiledevice3 lockdown info +``` + +Accept the trust prompt on the unlocked device if requested. Then follow the [backup instructions](ios/backup/pymobiledevice3.md), using a destination under `/home/cases`, such as `/home/cases/backup`. + +The iOS-only image defaults to running `mvt-ios`. To use pymobiledevice3 instead, override its entrypoint: + +```bash +docker run --rm -it \ + --mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \ + --entrypoint pymobiledevice3 \ + ghcr.io/mvt-project/mvt:latest-ios lockdown info +``` + If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`. From 518becb82563cc17cd0143876417a89b84e2842a Mon Sep 17 00:00:00 2001 From: besendorf Date: Sun, 13 Sep 2026 20:06:15 +0200 Subject: [PATCH 081/109] Remove adb-shell dependency from pyproject.toml not used anymore --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9f423d2e..cd7d0bec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ "appdirs==1.4.4", "iphone_backup_decrypt==0.9.0", "pycryptodome>=3.20.0", - "adb-shell[usb]==0.4.4", "libusb1==3.4.0", "cryptography==50.0.0", "PyYAML>=6.0.2", From a9d787c44696a14f1cf3ffc2cd4231ebabb063d0 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Mon, 14 Sep 2026 00:30:48 +0200 Subject: [PATCH 082/109] Remove unused libusb1 and refresh dependency lockfile --- pyproject.toml | 1 - uv.lock | 52 -------------------------------------------------- 2 files changed, 53 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cd7d0bec..b8000488 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ "appdirs==1.4.4", "iphone_backup_decrypt==0.9.0", "pycryptodome>=3.20.0", - "libusb1==3.4.0", "cryptography==50.0.0", "PyYAML>=6.0.2", "pyahocorasick==2.3.1", diff --git a/uv.lock b/uv.lock index a1d370e7..a1cc5fa4 100644 --- a/uv.lock +++ b/uv.lock @@ -6,22 +6,6 @@ resolution-markers = [ "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" @@ -777,17 +761,6 @@ wheels = [ { 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" @@ -1016,13 +989,11 @@ wheels = [ 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" }, @@ -1063,13 +1034,11 @@ docs = [ [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" }, @@ -1274,15 +1243,6 @@ wheels = [ { 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" @@ -1684,18 +1644,6 @@ 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" From f20fd140753a7af7a9142fe30bbd25292174a27a Mon Sep 17 00:00:00 2001 From: DonnchaC <3081375+DonnchaC@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:37:07 +0000 Subject: [PATCH 083/109] Add new iOS versions and build numbers --- src/mvt/ios/data/ios_versions.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mvt/ios/data/ios_versions.json b/src/mvt/ios/data/ios_versions.json index d8a93c91..8cea66c2 100644 --- a/src/mvt/ios/data/ios_versions.json +++ b/src/mvt/ios/data/ios_versions.json @@ -1275,5 +1275,9 @@ { "version": "26.6.2", "build": "23G90" + }, + { + "version": "27.0", + "build": "24A437" } ] \ No newline at end of file From 935747519ff89b4390e2f391c7d1237fa7bb35ef Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Fri, 18 Sep 2026 10:22:21 +0200 Subject: [PATCH 084/109] Bump iphone_backup_decrypt to 0.10.0 --- pyproject.toml | 2 +- src/mvt/ios/decrypt.py | 2 +- uv.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9f423d2e..29c605c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "simplejson==4.1.1", "packaging==26.3", "appdirs==1.4.4", - "iphone_backup_decrypt==0.9.0", + "iphone_backup_decrypt==0.10.0", "pycryptodome>=3.20.0", "adb-shell[usb]==0.4.4", "libusb1==3.4.0", diff --git a/src/mvt/ios/decrypt.py b/src/mvt/ios/decrypt.py index dc30d9c9..ffc7bfa0 100644 --- a/src/mvt/ios/decrypt.py +++ b/src/mvt/ios/decrypt.py @@ -52,7 +52,7 @@ class MVTEncryptedBackup(EncryptedBackup): NOTE: This subclass relies on internal APIs of iphone_backup_decrypt (specifically _read_and_unlock_keybag, _keybag, and the Keybag class - internals). Pinned to iphone_backup_decrypt==0.9.0. + internals). Pinned to iphone_backup_decrypt==0.10.0. """ def __init__(self, *, backup_directory, passphrase=None, derived_key=None): diff --git a/uv.lock b/uv.lock index a1d370e7..8e4255c3 100644 --- a/uv.lock +++ b/uv.lock @@ -625,14 +625,14 @@ wheels = [ [[package]] name = "iphone-backup-decrypt" -version = "0.9.0" +version = "0.10.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" } +sdist = { url = "https://files.pythonhosted.org/packages/01/b8/a2ec2b10d792403d36bb6661fea6357179c31ece1ed3285c442e636d6335/iphone_backup_decrypt-0.10.0.tar.gz", hash = "sha256:5f2799e58f908cce28d7929dd82231abc8acd57c25b37bce6a2d6a82e018d343", size = 20000, upload-time = "2026-09-17T21:46:44Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/89/10/349b2a720e012921d1457e1d1c2b921b16300a5f3570c4c64d0d3787539f/iphone_backup_decrypt-0.10.0-py3-none-any.whl", hash = "sha256:1ac4345a07b104a3eacab20932b8621cd1e0164e0f03d913e66eed6ea5ee173e", size = 17400, upload-time = "2026-09-17T21:46:42Z" }, ] [[package]] @@ -1068,7 +1068,7 @@ requires-dist = [ { 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 = "iphone-backup-decrypt", specifier = "==0.10.0" }, { name = "libusb1", specifier = "==3.4.0" }, { name = "nskeyedunarchiver", specifier = "==1.5.2" }, { name = "packaging", specifier = "==26.3" }, From d0fc0379b94fbe89e5f8561591d5b42cf370f770 Mon Sep 17 00:00:00 2001 From: va-resident Date: Fri, 18 Sep 2026 18:43:06 +0300 Subject: [PATCH 085/109] Fix mountinfo read-write detection across both option layers /proc/PID/mountinfo carries two option sets with different meaning: fields[5] are the per-mount (VFS) flags, the field after the "-" separator belongs to the superblock. A mount is writable only if both allow it. parse_mountinfo() merged both into one list and set is_read_write = "rw" in options, so a "rw" VFS mount over a read-only superblock was reported as writable. On stock Xiaomi-family builds that made every read-only mi_ext customisation overlay a HIGH "system partition is mounted as read-write". Require "rw" in both layers, and let "rw" count as a suspicious mount option only when the mount is actually writable; remount, noatime and nodiratime keep their current meaning in either layer. mount_options and options_list still carry both layers, so nothing downstream loses data. Measured on 50 bug reports carrying mountinfo - the 28 where the rule changes the output plus 22 controls, 14 brands, Android 10-16: HIGH 94 -> 0, MEDIUM 119 -> 25, the 22 controls identical, and 36233 mount entries parsed either way. Every removed alert is a read-only superblock under a "rw" VFS mount; no report gains an alert. A partition that really is writable still raises the HIGH, which the new test asserts explicitly. Fixes #936 Co-Authored-By: Claude Opus 5 (1M context) --- src/mvt/android/artifacts/mounts.py | 15 ++++++++-- tests/android/test_artifact_mounts.py | 43 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/android/test_artifact_mounts.py diff --git a/src/mvt/android/artifacts/mounts.py b/src/mvt/android/artifacts/mounts.py index 5a5537b7..0380a785 100644 --- a/src/mvt/android/artifacts/mounts.py +++ b/src/mvt/android/artifacts/mounts.py @@ -136,6 +136,10 @@ class Mounts(AndroidArtifact): mount_options = fields[5].split(",") super_options = fields[separator + 3].split(",") options = list(dict.fromkeys(mount_options + super_options)) + # fields[5] are the per-mount (VFS) flags, the field after the + # separator the superblock's; a mount is writable only if both + # allow it. The merged list made every read-only overlay a HIGH. + is_read_write = "rw" in mount_options and "rw" in super_options mount_point = fields[4].replace("\\040", " ") device = fields[separator + 2].replace("\\040", " ") filesystem_type = fields[separator + 1] @@ -156,7 +160,7 @@ class Mounts(AndroidArtifact): "options_list": options, "optional_fields": fields[6:separator], "is_system_partition": is_system, - "is_read_write": "rw" in options, + "is_read_write": is_read_write, "process_ids": [process_id], } ) @@ -191,7 +195,14 @@ class Mounts(AndroidArtifact): ) # Check for other suspicious mount options - suspicious_opts = [opt for opt in options if opt in SUSPICIOUS_OPTIONS] + # `rw` sitting in the merged option list is not evidence of + # writability (see is_read_write in parse_mountinfo); every other + # suspicious option is meaningful in either list. + suspicious_opts = [ + opt + for opt in options + if opt in SUSPICIOUS_OPTIONS and (opt != "rw" or mount["is_read_write"]) + ] if suspicious_opts and mount["is_system_partition"]: if ( "noatime" in mount["mount_options"] diff --git a/tests/android/test_artifact_mounts.py b/tests/android/test_artifact_mounts.py new file mode 100644 index 00000000..6b7d1faf --- /dev/null +++ b/tests/android/test_artifact_mounts.py @@ -0,0 +1,43 @@ +# 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/ +"""A mount is writable only if both mountinfo option layers allow it.""" + +from mvt.android.artifacts.mounts import Mounts +from mvt.common.alerts import AlertLevel + +# fields[5] is the per-mount (VFS) layer, the field after "-" the superblock. +RO_OVERLAY = ( + "304 166 0:130 / /product/usr rw,relatime shared:63 - overlay overlay " + "ro,seclabel,lowerdir=/mnt/vendor/ext/product/usr:/product/usr" +) +RW_SYSTEM = ( + "305 166 0:131 / /system rw,relatime shared:64 - ext4 /dev/block/dm-1 " + "rw,seclabel,errors=panic" +) + + +def _run(line): + mounts = Mounts() + mounts.results = Mounts.parse_mountinfo(line, 1) + mounts.check_indicators() + return mounts + + +class TestMountsReadWriteLayers: + def test_rw_vfs_over_ro_superblock_is_not_writable(self): + mounts = _run(RO_OVERLAY) + assert mounts.results[0]["is_read_write"] is False + assert mounts.alertstore.count(AlertLevel.HIGH) == 0 + assert mounts.alertstore.count(AlertLevel.MEDIUM) == 0 + + def test_rw_in_both_layers_still_alerts(self): + mounts = _run(RW_SYSTEM) + assert mounts.results[0]["is_read_write"] is True + assert mounts.alertstore.count(AlertLevel.HIGH) == 1 + + def test_both_option_layers_are_still_reported(self): + mounts = _run(RO_OVERLAY) + options = mounts.results[0]["options_list"] + assert "rw" in options and "ro" in options From 2426178baab564ca05381f2fc3b6af74525a1130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Tue, 22 Sep 2026 13:02:49 +0200 Subject: [PATCH 086/109] Enable automated PyPI deployments for tagged releases (#943) * Limit PyPi deploys to tag relaeses * Remove unnecessary tests and docs from sdist build --- .github/workflows/release.yml | 1 + MANIFEST.in | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 MANIFEST.in diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c20dc71..5acb3b51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,7 @@ jobs: publish: if: github.ref_type == 'tag' runs-on: ubuntu-latest + environment: pypi # only tag refs may deploy; the PyPI trusted publisher requires it permissions: id-token: write # PyPI trusted publishing steps: diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..cc7f0b97 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +prune tests +prune docs +prune .github +exclude uv.lock Dockerfile* mkdocs.yml .readthedocs.yaml .safety-policy.yml .python-version .gitignore Makefile From 6f65eee85a7a248fb08eb25b3fba580332665271 Mon Sep 17 00:00:00 2001 From: va-resident Date: Tue, 22 Sep 2026 18:11:42 +0300 Subject: [PATCH 087/109] Do not treat a section's timing line as a section boundary extract_command_section() ends a section at any line starting with "------". dumpstate prints a section's timing line when THAT section finishes, and it can land in the middle of the section currently being written, so the section is truncated at an arbitrary point and the rest is silently dropped. Skip the timing line instead of returning it: it never reaches a parser as content, and the real "------ ------" boundary still ends the section. Measured over 40 bug reports carrying a dumpstate: SYSTEM PROPERTIES was truncated on 6 of them, losing 6559 properties, and 3 parsed no property at all. On one Samsung archive getprop goes from 418 to 1259 properties and from 0 to 473 ro.* ones, bringing back ro.product.model, ro.build.version.security_patch and ro.boot.verifiedbootstate. The other 34 archives are byte-identical. Fixes #938 --- src/mvt/android/modules/bugreport/base.py | 11 +++++ .../test_bugreport_section_extraction.py | 42 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 tests/android/test_bugreport_section_extraction.py diff --git a/src/mvt/android/modules/bugreport/base.py b/src/mvt/android/modules/bugreport/base.py index 25396f95..9e8954d6 100644 --- a/src/mvt/android/modules/bugreport/base.py +++ b/src/mvt/android/modules/bugreport/base.py @@ -6,6 +6,7 @@ import datetime import fnmatch import logging import os +import re from pathlib import Path from typing import List, Optional from zipfile import ZipFile @@ -13,6 +14,10 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from mvt.common.module import ModuleResults, MVTModule +# `------ 0.101s was the duration of 'SOME SECTION' ------`, printed when that +# section finishes and not necessarily between two sections. +SECTION_DURATION = re.compile(r"^-{3,}\s*[0-9.]+s was the duration of", re.IGNORECASE) + class BugReportModule(MVTModule): """This class provides a base for all Android Bug Report modules.""" @@ -122,6 +127,12 @@ class BugReportModule(MVTModule): in_section = True continue if stripped.startswith("------"): + # dumpstate prints a section's timing line when that section + # finishes, which can land in the middle of the one being + # written. Treating it as a boundary truncates the section at + # an arbitrary point, silently. + if SECTION_DURATION.match(stripped): + continue break lines.append(line) return "\n".join(lines) diff --git a/tests/android/test_bugreport_section_extraction.py b/tests/android/test_bugreport_section_extraction.py new file mode 100644 index 00000000..5778f947 --- /dev/null +++ b/tests/android/test_bugreport_section_extraction.py @@ -0,0 +1,42 @@ +# 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/ +"""A section ends at the next section, not at a timing line printed inside it.""" + +from mvt.android.modules.bugreport.base import BugReportModule + +# dumpstate prints a section's duration when that section finishes, which can +# land in the middle of the section currently being written. +DUMPSTATE = """\ +------ SYSTEM PROPERTIES (getprop) ------ +[nfc.initialized]: [true] +------ 0.101s was the duration of 'DROPBOX SYSTEM SERVER CRASHES' ------ +[ro.build.version.sdk]: [30] +[ro.product.model]: [SM-A305F] +------ 0.064s was the duration of 'SYSTEM PROPERTIES' ------ +------ STORAGE INFO (df) ------ +/dev/root 2.9G +""" + + +class TestExtractCommandSection: + def test_a_foreign_timing_line_does_not_end_the_section(self): + section = BugReportModule.extract_command_section( + DUMPSTATE, "------ SYSTEM PROPERTIES" + ) + assert "[ro.product.model]: [SM-A305F]" in section + assert section.count("\n") == 2 + + def test_the_next_section_is_still_the_boundary(self): + section = BugReportModule.extract_command_section( + DUMPSTATE, "------ SYSTEM PROPERTIES" + ) + assert "STORAGE INFO" not in section + assert "/dev/root" not in section + + def test_timing_lines_are_not_returned_as_content(self): + section = BugReportModule.extract_command_section( + DUMPSTATE, "------ SYSTEM PROPERTIES" + ) + assert "was the duration of" not in section From bdbc07a3b32b7f97b24e1c25e97a21c59d09ad98 Mon Sep 17 00:00:00 2001 From: va-resident Date: Tue, 22 Sep 2026 18:13:57 +0300 Subject: [PATCH 088/109] Carry the accessibility service count the dump states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most builds never print the `installed services: {...}` block. They state installedServiceCount=N in the user's attributes line and list nothing, so the parser recorded no service at all and MVT logged "Identified a total of 0 accessibility services" — an artifact that says "no accessibility services" about a dump that said there are five. The dump pasted in #744 is itself an example: it states installedServiceCount=6 and names none. Parse the count per user and, for a user whose services the dump did not list, keep one record carrying it, raised as LOW: a count is a coverage statement, not a running service. Name the service state in the alert and split its severity, as asked on #744: a service the dump states is switched off is LOW, enabled or bound is MEDIUM, and a dump that does not state the enabled state stays MEDIUM, because "not stated" is not "not enabled". A state section the dump never printed now reads None rather than False. IOC matching is unaffected by the state: a disabled service still returns CRITICAL on a match. Measured over 163 bug reports carrying an accessibility section: MEDIUM alerts 305 -> 4, LOW 0 -> 407. The four that stay MEDIUM are the only records in the set with enabled=True. Fixes #744 --- .../artifacts/dumpsys_accessibility.py | 119 ++++++++++++++--- .../bugreport/dumpsys_accessibility.py | 17 ++- .../test_artifact_dumpsys_accessibility.py | 11 +- ...st_artifact_dumpsys_accessibility_count.py | 100 ++++++++++++++ ...st_artifact_dumpsys_accessibility_state.py | 125 ++++++++++++++++++ 5 files changed, 351 insertions(+), 21 deletions(-) create mode 100644 tests/android/test_artifact_dumpsys_accessibility_count.py create mode 100644 tests/android/test_artifact_dumpsys_accessibility_state.py diff --git a/src/mvt/android/artifacts/dumpsys_accessibility.py b/src/mvt/android/artifacts/dumpsys_accessibility.py index da46043c..1a4a05ac 100644 --- a/src/mvt/android/artifacts/dumpsys_accessibility.py +++ b/src/mvt/android/artifacts/dumpsys_accessibility.py @@ -10,8 +10,37 @@ from .artifact import AndroidArtifact class DumpsysAccessibilityArtifact(AndroidArtifact): + # One list for both record shapes — a service record and a count-only + # record must stay the same shape. + _FIELDS = ( + "user_id", + "component", + "package_name", + "service_name", + "installed", + "enabled", + "binding", + "bound", + "crashed", + "accessibility_tool", + "installed_service_count", + ) + def check_indicators(self) -> None: for result in self.results: + # A stated count with no component names is a coverage statement, + # not a service: low, but not silent. + if not result.get("component"): + self.alertstore.low( + f"The accessibility dump states " + f"{result['installed_service_count']} installed " + f"service(s) for user {result['user_id']} but does not " + f"list their component names", + "", + result, + ) + continue + if self.indicators: ioc_match = self.indicators.check_app_id(result["package_name"]) if ioc_match: @@ -20,11 +49,22 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): ) continue - self.alertstore.medium( - f'Found accessibility service: "{result["component"]}"', - "", - result, + # Installed is not enabled. A service can sit installed for years + # without ever being switched on, and that is the difference this + # channel is read for — an alert that says only "found" makes every + # device look equally exposed. A service the dump says is switched + # OFF is reported LOW, so it still reaches the analyst without + # competing with one that is actually running; a dump that does not + # state the enabled state stays MEDIUM, because "not stated" is not + # "not enabled". + message = ( + f'Found accessibility service: "{result["component"]}" ' + f"({self._describe_state(result)})" ) + if result.get("enabled") is False and not result.get("bound"): + self.alertstore.low(message, "", result) + else: + self.alertstore.medium(message, "", result) def parse(self, content: str) -> None: """ @@ -36,6 +76,11 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): self.results: list[dict[str, Any]] = [] services: dict[tuple[int | None, str], dict] = {} + seen_states: set[str] = set() + # `installedServiceCount=N` from the user's `attributes:{…}` line is on + # most builds the only statement about installed services in the dump: + # few print the `installed services: {…}` block. + installed_counts: dict[int | None, int] = {} user_id: int | None = None state: str | None = None @@ -44,6 +89,10 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): if user_match: user_id = int(user_match.group(1)) + count_match = re.search(r"installedServiceCount=(\d+)", line) + if count_match: + installed_counts[user_id] = int(count_match.group(1)) + stripped = line.strip() state_match = re.match( r"(?i)(installed|enabled|binding|bound|crashed) services\s*:\s*\{(.*)", @@ -51,6 +100,7 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): ) if state_match: state = state_match.group(1).lower() + seen_states.add(self._state_field(state)) inline = state_match.group(2) for component in re.findall( r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline @@ -79,24 +129,59 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): service[self._state_field(state)] = True service["accessibility_tool"] = "(A11yTool)" in stripped + # A section that was never printed is NOT the same as one printed + # empty: the first says nothing, the second says nothing is enabled. + # Defaulting every flag to False would turn "not stated" into "not + # enabled". Flags for sections this dump never printed stay None. + for (service_user, _component), service in services.items(): + for state in ("installed", "enabled", "binding", "bound", "crashed"): + if self._state_field(state) not in seen_states: + service[self._state_field(state)] = None + service["installed_service_count"] = installed_counts.get(service_user) + self.results.extend(services.values()) + # A stated count whose services were never listed would leave no trace: + # the module would log "a total of 0" about a dump that said five. + listed_users = {service_user for service_user, _component in services} + for count_user, count in installed_counts.items(): + if count_user in listed_users or count == 0: + continue + self.results.append(self._new_unlisted(count_user, count)) + + @staticmethod + def _describe_state(result: dict) -> str: + if result.get("bound"): + return "enabled and bound" + if result.get("enabled"): + return "enabled" + if result.get("enabled") is None: + return "installed, enabled state not stated" + return "installed, not enabled" + @staticmethod def _state_field(state: str) -> str: return {"binding": "binding", "bound": "bound"}.get(state, state) + @staticmethod + def _new_unlisted(user_id: int | None, count: int) -> dict: + """The dump's own count for a user whose services it did not list. + + Every other field stays unknown: the dump named no service to carry it. + """ + record: dict[str, Any] = dict.fromkeys(DumpsysAccessibilityArtifact._FIELDS) + record["user_id"] = user_id + record["installed_service_count"] = count + return record + @staticmethod def _new_service(component: str, user_id: int | None) -> dict: - package_name, service_name = component.split("/", 1) - return { - "user_id": user_id, - "component": component, - "package_name": package_name, - "service_name": service_name, - "installed": False, - "enabled": False, - "binding": False, - "bound": False, - "crashed": False, - "accessibility_tool": False, - } + record: dict[str, Any] = dict.fromkeys( + DumpsysAccessibilityArtifact._FIELDS, False + ) + record["user_id"] = user_id + record["component"] = component + record["package_name"], record["service_name"] = component.split("/", 1) + # Filled in after parsing: the count is per user. + record["installed_service_count"] = None + return record diff --git a/src/mvt/android/modules/bugreport/dumpsys_accessibility.py b/src/mvt/android/modules/bugreport/dumpsys_accessibility.py index 02d85a30..80991d37 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_accessibility.py +++ b/src/mvt/android/modules/bugreport/dumpsys_accessibility.py @@ -48,9 +48,22 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule): ) self.parse(content) + listed = stated = 0 for result in self.results: - self.log.info('Found accessibility service "%s"', result.get("component")) + if result.get("component"): + listed += 1 + self.log.info( + 'Found accessibility service "%s"', result.get("component") + ) + continue + # The operator gets this per user as a LOW alert from + # check_indicators(); here it only has to survive into the summary, + # so that a stated count never reads as "a total of 0". + stated += result.get("installed_service_count") or 0 self.log.info( - "Identified a total of %d accessibility services", len(self.results) + "Identified a total of %d accessibility services (%d more stated by " + "the dump without a component name)", + listed, + stated, ) diff --git a/tests/android/test_artifact_dumpsys_accessibility.py b/tests/android/test_artifact_dumpsys_accessibility.py index c727c571..f21c79ed 100644 --- a/tests/android/test_artifact_dumpsys_accessibility.py +++ b/tests/android/test_artifact_dumpsys_accessibility.py @@ -39,7 +39,10 @@ class TestDumpsysAccessibilityArtifact: assert da.results[0]["package_name"] == "com.malware.accessibility" assert da.results[0]["service_name"] == "com.malware.service.malwareservice" assert da.results[0]["enabled"] is True - assert da.results[0]["installed"] is False + # This fixture never prints an `installed services:` section, so the + # dump does not state the installed status. Reporting False would turn + # "not stated" into "not installed", so it reads None here. + assert da.results[0]["installed"] is None def test_accessibility_service_alert(self): da = DumpsysAccessibilityArtifact() @@ -84,7 +87,11 @@ User state[attributes:{id=10 assert len(da.alertstore.alerts) == 0 da.check_indicators() assert len(da.alertstore.alerts) == len(da.results) - assert da.alertstore.count(AlertLevel.MEDIUM) == 3 + # Every service in this fixture is installed and switched off + # (`enabled services:{}` is printed and empty), so the three non-IOC + # findings are LOW, not MEDIUM. The IOC match is unaffected by the + # state. + assert da.alertstore.count(AlertLevel.LOW) == 3 assert da.alertstore.count(AlertLevel.CRITICAL) == 1 critical_alert = next( alert diff --git a/tests/android/test_artifact_dumpsys_accessibility_count.py b/tests/android/test_artifact_dumpsys_accessibility_count.py new file mode 100644 index 00000000..59765073 --- /dev/null +++ b/tests/android/test_artifact_dumpsys_accessibility_count.py @@ -0,0 +1,100 @@ +# 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/ +"""The dump's own installed-service count must survive into the artifact. + +Most builds never print the `installed services: {…}` block; they state `installedServiceCount=N` in the user's `attributes:{…}` line and +list nothing. Dropping that number makes an artifact that says "no +accessibility services" about a dump that said there are five. +""" + +from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact +from mvt.common.alerts import AlertLevel + +AOSP_NO_LIST = """\ +ACCESSIBILITY MANAGER (dumpsys accessibility) +User state[ + attributes:{id=0, touchExplorationEnabled=false, installedServiceCount=5} + Bound services:{} + Enabled services:{} + Binding services:{} + Crashed services:{} +""" + +ONE_UI_WITH_LIST = """\ +ACCESSIBILITY MANAGER (dumpsys accessibility) +User state[attributes:{id=0, installedServiceCount=2} + installed services: { + 0 : com.example.app/com.example.app.Service + 1 : com.other.app/.Helper + } + enabled services: { + } +""" + +TWO_USERS = """\ +ACCESSIBILITY MANAGER (dumpsys accessibility) +User state[attributes:{id=0, installedServiceCount=1} + installed services: { + 0 : com.example.app/com.example.app.Service + } +User state[attributes:{id=95, installedServiceCount=3} + Enabled services:{} +""" + +ZERO_COUNT = """\ +ACCESSIBILITY MANAGER (dumpsys accessibility) +User state[attributes:{id=0, installedServiceCount=0} + Enabled services:{} +""" + + +def _parse(content): + artifact = DumpsysAccessibilityArtifact() + artifact.results = [] + artifact.parse(content) + return artifact + + +class TestAccessibilityInstalledServiceCount: + def test_stated_count_without_a_list_is_kept(self): + artifact = _parse(AOSP_NO_LIST) + assert len(artifact.results) == 1 + record = artifact.results[0] + assert record["installed_service_count"] == 5 + assert record["component"] is None + # Every state flag stays unknown: the dump named no service to which a + # state could belong. + assert record["installed"] is None + assert record["enabled"] is None + + def test_a_stated_count_is_reported_as_low(self): + artifact = _parse(AOSP_NO_LIST) + artifact.check_indicators() + alerts = artifact.alertstore.alerts + assert len(alerts) == 1 + # A count is a coverage statement, not a running service: it must not + # compete with a service the dump says is enabled. + assert alerts[0].level == AlertLevel.LOW + assert "does not list their component names" in alerts[0].message + assert "5 installed" in alerts[0].message + + def test_a_listed_user_carries_the_count_on_each_service(self): + artifact = _parse(ONE_UI_WITH_LIST) + assert len(artifact.results) == 2 + assert {record["installed_service_count"] for record in artifact.results} == {2} + assert all(record["component"] for record in artifact.results) + + def test_only_the_unlisted_user_gets_a_count_record(self): + artifact = _parse(TWO_USERS) + listed = [record for record in artifact.results if record["component"]] + unlisted = [record for record in artifact.results if not record["component"]] + assert [record["user_id"] for record in listed] == [0] + assert [record["user_id"] for record in unlisted] == [95] + assert unlisted[0]["installed_service_count"] == 3 + + def test_a_zero_count_adds_nothing(self): + # "Zero installed" is a negative result the empty section already + # states; a record for it would be noise. + assert _parse(ZERO_COUNT).results == [] diff --git a/tests/android/test_artifact_dumpsys_accessibility_state.py b/tests/android/test_artifact_dumpsys_accessibility_state.py new file mode 100644 index 00000000..b60368ab --- /dev/null +++ b/tests/android/test_artifact_dumpsys_accessibility_state.py @@ -0,0 +1,125 @@ +# 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/ +"""Installed is not the same as enabled, and the dump says which. + +Two things this guards: + + * a section the dump never printed must read as None ("not stated"), not as + False ("not enabled"); + * the alert must name the state, instead of firing identically on a device + where nothing is switched on and one where something is bound. +""" + +from types import SimpleNamespace + +from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact +from mvt.common.alerts import AlertLevel + +from ..utils import get_artifact + +NO_STATE_SECTIONS = """\ +ACCESSIBILITY MANAGER (dumpsys accessibility) +User state[attributes:{id=0, currentUser=true} + installed services: { + 0 : com.example.app/com.example.app.Service + } +""" + +ENABLED_BLOCK = """\ +ACCESSIBILITY MANAGER (dumpsys accessibility) +User state[attributes:{id=0, currentUser=true} + installed services: { + 0 : com.example.app/com.example.app.Service + 1 : com.other.app/.Helper + } + enabled services: { + 0 : com.other.app/.Helper + } + bound services:{ + 0 : com.other.app/.Helper + } +""" + + +class _IndicatorsMatching: + """Minimal stand-in: matches one package id, like the STIX2 loader would.""" + + def __init__(self, package_name: str) -> None: + self.package_name = package_name + + def check_app_id(self, app_id): + if app_id != self.package_name: + return None + return SimpleNamespace( + message=f"Found a known suspicious app: {app_id}", ioc={"value": app_id} + ) + + +class TestAccessibilityServiceState: + def _parse(self, content): + artifact = DumpsysAccessibilityArtifact() + artifact.results = [] + artifact.parse(content) + return {r["component"]: r for r in artifact.results} + + def test_absent_sections_leave_the_state_unknown(self): + # None, not False: a build that does not print the sections says + # nothing about what is enabled, and that must not read as "nothing". + state = self._parse(NO_STATE_SECTIONS)[ + "com.example.app/com.example.app.Service" + ] + assert state["installed"] is True + assert state["enabled"] is None + assert state["bound"] is None + + def test_enabled_and_bound_are_attributed_per_service(self): + results = self._parse(ENABLED_BLOCK) + installed_only = results["com.example.app/com.example.app.Service"] + active = results["com.other.app/.Helper"] + assert (installed_only["enabled"], installed_only["bound"]) == (False, False) + assert (active["enabled"], active["bound"]) == (True, True) + + def test_alert_message_carries_the_state(self): + artifact = DumpsysAccessibilityArtifact() + artifact.results = [] + with open(get_artifact("android_data/dumpsys_accessibility.txt")) as handle: + artifact.parse(handle.read()) + artifact.check_indicators() + assert artifact.alertstore.alerts + assert all("installed" in alert.message for alert in artifact.alertstore.alerts) + + def test_a_switched_off_service_is_low_and_a_running_one_medium(self): + # A service the dump says is OFF still reaches the analyst, but must not + # compete with one that is actually bound. "Not stated" is not "off". + artifact = DumpsysAccessibilityArtifact() + artifact.results = [] + artifact.parse(ENABLED_BLOCK) + artifact.check_indicators() + by_level = {} + for alert in artifact.alertstore.alerts: + by_level.setdefault(alert.level, []).append(alert.message) + assert artifact.alertstore.count(AlertLevel.LOW) == 1 + assert artifact.alertstore.count(AlertLevel.MEDIUM) == 1 + assert "com.example.app" in by_level[AlertLevel.LOW][0] + assert "com.other.app" in by_level[AlertLevel.MEDIUM][0] + + def test_an_unstated_enabled_state_stays_medium(self): + artifact = DumpsysAccessibilityArtifact() + artifact.results = [] + artifact.parse(NO_STATE_SECTIONS) + artifact.check_indicators() + assert artifact.alertstore.count(AlertLevel.MEDIUM) == 1 + assert artifact.alertstore.count(AlertLevel.LOW) == 0 + + def test_a_disabled_service_is_still_matched_against_indicators(self): + # The state decides the severity of an ordinary finding, never whether + # the package is compared with the IOC feeds. + artifact = DumpsysAccessibilityArtifact() + artifact.results = [] + artifact.parse(ENABLED_BLOCK) + artifact.indicators = _IndicatorsMatching("com.example.app") + artifact.check_indicators() + assert artifact.alertstore.count(AlertLevel.CRITICAL) == 1 + assert artifact.alertstore.count(AlertLevel.LOW) == 0 From ff5ebf73cccf86e2cc1a391443f863e6c6f00593 Mon Sep 17 00:00:00 2001 From: StarRailHub <3151336214@qq.com> Date: Wed, 23 Sep 2026 12:11:58 +0800 Subject: [PATCH 089/109] fix: parse dumpsys ADB output with CRLF line endings --- src/mvt/android/artifacts/dumpsys_adb.py | 12 ++++++++++-- tests/android/test_artifact_dumpsys_adb.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_adb.py b/src/mvt/android/artifacts/dumpsys_adb.py index cbd80e21..f2b68bcc 100644 --- a/src/mvt/android/artifacts/dumpsys_adb.py +++ b/src/mvt/android/artifacts/dumpsys_adb.py @@ -29,7 +29,8 @@ class DumpsysADBArtifact(AndroidArtifact): stack = [res] cur_indent = 0 in_multiline = False - for line in dump_data.strip(b"\n").split(b"\n"): + for line in dump_data.strip(b"\r\n").split(b"\n"): + line = line.removesuffix(b"\r") # Track the level of indentation indent = len(line) - len(line.lstrip()) if indent < cur_indent: @@ -180,12 +181,19 @@ class DumpsysADBArtifact(AndroidArtifact): self.log.error("Unable to find ADB manager state in dumpsys output") return + line_ending_length = 1 end_of_json = content.rfind(b"}\n") + crlf_end_of_json = content.rfind(b"}\r\n") + if crlf_end_of_json > end_of_json: + line_ending_length = 2 + end_of_json = crlf_end_of_json 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() + json_content = content[ + start_of_json + 2 : end_of_json - line_ending_length - 1 + ].rstrip() parsed = self.indented_dump_parser(json_content) if parsed.get("debugging_manager") is None: diff --git a/tests/android/test_artifact_dumpsys_adb.py b/tests/android/test_artifact_dumpsys_adb.py index 8d501b7d..beeaba3a 100644 --- a/tests/android/test_artifact_dumpsys_adb.py +++ b/tests/android/test_artifact_dumpsys_adb.py @@ -130,6 +130,18 @@ class TestDumpsysADBArtifact: assert key_store_entry["fingerprint"] == expected_fingerprint assert key_store_entry["last_connected"] == "1628501829898" + def test_parsing_adb_xml_with_crlf_line_endings(self): + da_adb = DumpsysADBArtifact() + file = get_artifact("android_data/dumpsys_adb_xml.txt") + with open(file, "rb") as f: + data = f.read().replace(b"\r\n", b"\n").replace(b"\n", b"\r\n") + + da_adb.parse(data) + + assert len(da_adb.results) == 1 + assert da_adb.results[0]["user_keys"][0]["user"] == "user@laptop" + assert da_adb.results[0]["keystore"][0]["last_connected"] == "1628501829898" + class TestDumpsysADBStateAlerts: def test_no_androidqf_context_preserves_existing_behavior(self): From 7f19d96297a6dc06919ad23e7527dc7e573c3547 Mon Sep 17 00:00:00 2001 From: itzzdev09 Date: Wed, 23 Sep 2026 19:27:08 +0530 Subject: [PATCH 090/109] Strip only a whole www. prefix from parsed domains --- src/mvt/common/url.py | 4 +++- tests/common/test_url.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/mvt/common/url.py b/src/mvt/common/url.py index 426d64bd..d432e3ed 100644 --- a/src/mvt/common/url.py +++ b/src/mvt/common/url.py @@ -349,7 +349,9 @@ class URL: return tld_obj if tld_obj is None: return "" - return tld_obj.parsed_url.netloc.lower().lstrip("www.") + # removeprefix, not lstrip: lstrip takes a set of characters, so it ate the + # leading "w"s and dots of any domain ("web.evil.com" -> "eb.evil.com"). + return tld_obj.parsed_url.netloc.lower().removeprefix("www.") def get_top_level(self) -> str: """Get only the top-level domain from a URL. diff --git a/tests/common/test_url.py b/tests/common/test_url.py index ce0fb635..52bfb5e3 100644 --- a/tests/common/test_url.py +++ b/tests/common/test_url.py @@ -22,3 +22,18 @@ def test_google_maps_url_is_not_shortened(url): def test_other_google_short_url_is_shortened(): assert URL("https://goo.gl/example").check_if_shortened() is True + + +@pytest.mark.parametrize( + "url, domain", + [ + ("https://www.example.com/path", "example.com"), + # Only the whole "www." prefix comes off, not any leading "w" or "." character. + ("https://web.example.com", "web.example.com"), + ("https://wow.com", "wow.com"), + ("https://wired.com", "wired.com"), + ("https://www.wow.com", "wow.com"), + ], +) +def test_get_domain_strips_only_a_whole_www_prefix(url, domain): + assert URL(url).domain == domain From 3bb652f2d224d501dec41f5770fbf8fa77567f84 Mon Sep 17 00:00:00 2001 From: besendorf Date: Thu, 24 Sep 2026 04:29:33 -0700 Subject: [PATCH 091/109] Test w-prefixed shortener detection --- tests/common/test_url.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/common/test_url.py b/tests/common/test_url.py index 52bfb5e3..5a10c414 100644 --- a/tests/common/test_url.py +++ b/tests/common/test_url.py @@ -37,3 +37,8 @@ def test_other_google_short_url_is_shortened(): ) def test_get_domain_strips_only_a_whole_www_prefix(url, domain): assert URL(url).domain == domain + + +def test_shortener_starting_with_w_is_detected(): + assert URL("https://w3t.org/example").check_if_shortened() is True + assert URL("https://www.w3t.org/example").check_if_shortened() is True From df3b130ca8ee9d2578822827009cb856331684ea Mon Sep 17 00:00:00 2001 From: besendorf Date: Thu, 24 Sep 2026 04:34:52 -0700 Subject: [PATCH 092/109] Test w-prefixed subdomain indicator matching Add test for URL matching with prefixed subdomain. --- tests/common/test_indicators.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/common/test_indicators.py b/tests/common/test_indicators.py index ac50f82b..a7495606 100644 --- a/tests/common/test_indicators.py +++ b/tests/common/test_indicators.py @@ -311,3 +311,31 @@ class TestIndicators: ind = Indicators(log=logging) ind.load_indicators_files([], load_default=False) assert ind.total_ioc_count == 9 + + def test_check_url_matches_w_prefixed_subdomain(self, tmp_path): + import json + + stix_file = tmp_path / "w-domain.stix2" + stix_file.write_text( + json.dumps( + { + "objects": [ + { + "type": "indicator", + "pattern": "[domain-name:value = 'web.evil.com']", + } + ] + } + ), + encoding="utf-8", + ) + ind = Indicators(log=logging) + ind.load_indicators_files([str(stix_file)], load_default=False) + + for url in ( + "https://web.evil.com/path", + "https://www.web.evil.com/path", + ): + match = ind.check_url(url) + assert match is not None + assert match.ioc.value == "web.evil.com" From 8c5278f5405ceb26889428166b3c1fe43fa32879 Mon Sep 17 00:00:00 2001 From: besendorf Date: Thu, 24 Sep 2026 04:37:25 -0700 Subject: [PATCH 093/109] Refactor JSON extraction logic in dumpsys_adb.py --- src/mvt/android/artifacts/dumpsys_adb.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_adb.py b/src/mvt/android/artifacts/dumpsys_adb.py index f2b68bcc..ac397cfb 100644 --- a/src/mvt/android/artifacts/dumpsys_adb.py +++ b/src/mvt/android/artifacts/dumpsys_adb.py @@ -181,19 +181,18 @@ class DumpsysADBArtifact(AndroidArtifact): self.log.error("Unable to find ADB manager state in dumpsys output") return - line_ending_length = 1 - end_of_json = content.rfind(b"}\n") - crlf_end_of_json = content.rfind(b"}\r\n") - if crlf_end_of_json > end_of_json: - line_ending_length = 2 - end_of_json = crlf_end_of_json + end_of_json = max(content.rfind(b"}\n"), content.rfind(b"}\r\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 - line_ending_length - 1 - ].rstrip() + # Exclude the final nested closing brace regardless of its line ending. + # The indented parser finishes the open debugging_manager at EOF. + inner_end = content.rfind(b"}", start_of_json + 2, end_of_json) + if inner_end == -1: + self.log.error("Unable to find complete ADB manager state in dumpsys output") + return + json_content = content[start_of_json + 2 : inner_end].rstrip() parsed = self.indented_dump_parser(json_content) if parsed.get("debugging_manager") is None: From f1e52b297a0d97ebbf6af037c136b1080f7ab2e8 Mon Sep 17 00:00:00 2001 From: besendorf Date: Thu, 24 Sep 2026 04:40:48 -0700 Subject: [PATCH 094/109] test: cover mixed ADB state line endings Regression for CRLF after the manager brace and LF after the outer brace. --- tests/android/test_artifact_dumpsys_adb.py | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/android/test_artifact_dumpsys_adb.py b/tests/android/test_artifact_dumpsys_adb.py index beeaba3a..3f6f892d 100644 --- a/tests/android/test_artifact_dumpsys_adb.py +++ b/tests/android/test_artifact_dumpsys_adb.py @@ -143,6 +143,29 @@ class TestDumpsysADBArtifact: assert da_adb.results[0]["keystore"][0]["last_connected"] == "1628501829898" + def test_parsing_adb_wifi_with_mixed_line_endings(self): + da_adb = DumpsysADBArtifact() + data = ( + b"ADB MANAGER STATE (dumpsys adb):\n" + b"{\n" + b" debugging_manager={\n" + b" connected_to_adb=true\n" + b" user_keys=QUJDRA== host@example\n" + b" adb_wifi={\n" + b" enabled=false\n" + b" }\n" + b" }\r\n" + b"}\n" + b"--------- duration\n" + ) + + da_adb.parse(data) + + assert len(da_adb.results) == 1 + assert da_adb.results[0]["user_keys"][0]["user"] == "host@example" + assert da_adb.results[0]["adb_wifi"]["enabled"] == b"false" + + class TestDumpsysADBStateAlerts: def test_no_androidqf_context_preserves_existing_behavior(self): module = DumpsysADBState( From 9ca4254649a104fcd14420f1bb5a832c47702bfc Mon Sep 17 00:00:00 2001 From: besendorf Date: Thu, 24 Sep 2026 09:38:18 -0700 Subject: [PATCH 095/109] Fix formatting in test_artifact_dumpsys_adb.py --- tests/android/test_artifact_dumpsys_adb.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/android/test_artifact_dumpsys_adb.py b/tests/android/test_artifact_dumpsys_adb.py index 3f6f892d..2fcd0bba 100644 --- a/tests/android/test_artifact_dumpsys_adb.py +++ b/tests/android/test_artifact_dumpsys_adb.py @@ -142,7 +142,6 @@ class TestDumpsysADBArtifact: assert da_adb.results[0]["user_keys"][0]["user"] == "user@laptop" assert da_adb.results[0]["keystore"][0]["last_connected"] == "1628501829898" - def test_parsing_adb_wifi_with_mixed_line_endings(self): da_adb = DumpsysADBArtifact() data = ( From 99b3b5f0140fe2ca59e8581c5ae1f4b579d9eb5b Mon Sep 17 00:00:00 2001 From: SomeoneUnlicensed Date: Thu, 24 Sep 2026 21:29:45 +0300 Subject: [PATCH 096/109] Make the test suite pass on Windows The Filesystem module stored the paths of an iOS dump with the separator of the system checking it, so on Windows the process and file path indicators, which split paths on "/", never matched. It now stores them as POSIX paths. The rest are test fixes: - the completion install tests also redirect USERPROFILE, which Path.home() reads on Windows; they wrote to the real home folder; - the plugin table helpers accept the light header Rich draws on consoles that cannot show the heavy one; - the completion test quotes the command path, whose backslashes were dropped when COMP_WORDS was split; - tests that need symbolic links or the sqlite3 binary are skipped when those are not available; - two assertions no longer depend on the path separator or on the line ending text mode writes. --- src/mvt/ios/modules/fs/filesystem.py | 8 ++++++-- tests/common/test_cli_plugins.py | 3 ++- tests/common/test_cmd_plugins.py | 20 ++++++++++++-------- tests/ios_backup/test_decrypt.py | 6 +++++- tests/ios_backup/test_sqlite_handling.py | 5 +++++ tests/test_check_android_androidqf.py | 2 +- tests/test_cmd_check_sysdiagnose.py | 2 +- tests/test_completion.py | 2 ++ 8 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/mvt/ios/modules/fs/filesystem.py b/src/mvt/ios/modules/fs/filesystem.py index 563d0394..b3cc1134 100644 --- a/src/mvt/ios/modules/fs/filesystem.py +++ b/src/mvt/ios/modules/fs/filesystem.py @@ -82,7 +82,9 @@ class Filesystem(IOSExtraction): try: dir_path = os.path.join(root, dir_name) result = { - "path": os.path.relpath(dir_path, self.target_path), + "path": os.path.relpath(dir_path, self.target_path).replace( + os.sep, "/" + ), "modified": convert_unix_to_iso(os.stat(dir_path).st_mtime), } except Exception: @@ -94,7 +96,9 @@ class Filesystem(IOSExtraction): try: file_path = os.path.join(root, file_name) result = { - "path": os.path.relpath(file_path, self.target_path), + "path": os.path.relpath(file_path, self.target_path).replace( + os.sep, "/" + ), "modified": convert_unix_to_iso(os.stat(file_path).st_mtime), } except Exception: diff --git a/tests/common/test_cli_plugins.py b/tests/common/test_cli_plugins.py index 5abb2c98..0618b8d6 100644 --- a/tests/common/test_cli_plugins.py +++ b/tests/common/test_cli_plugins.py @@ -1,3 +1,4 @@ +import shlex from types import SimpleNamespace import click @@ -110,7 +111,7 @@ def test_load_command_option_supports_folders_and_repeated_paths(tmp_path): 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" + words = f"group --load-command {shlex.quote(str(command_path))} he" result = CliRunner().invoke( group, diff --git a/tests/common/test_cmd_plugins.py b/tests/common/test_cmd_plugins.py index adf93227..3767491a 100644 --- a/tests/common/test_cmd_plugins.py +++ b/tests/common/test_cmd_plugins.py @@ -1,4 +1,5 @@ import json +import re from types import SimpleNamespace import pytest @@ -54,20 +55,23 @@ def _run(command, arguments): return CliRunner().invoke(command, arguments, env={"COLUMNS": "200"}) -def _table_rows(output): - """Return the content of the table rows, without the header and the box.""" +def _table_lines(output): + """Return the cells of each line of the table, header first.""" return [ - [cell.strip() for cell in line.strip().strip("│").split("│")] + [cell.strip() for cell in re.split("[│┃]", line.strip().strip("│┃"))] for line in output.splitlines() - if "│" in line + if "│" in line or "┃" in line ] +def _table_rows(output): + """Return the content of the table rows, without the header and the box.""" + return _table_lines(output)[1:] + + def _table_header(output): - for line in output.splitlines(): - if "┃" in line: - return [cell.strip() for cell in line.strip().strip("┃").split("┃")] - return [] + lines = _table_lines(output) + return lines[0] if lines else [] def _install(monkeypatch, distributions, entry_points): diff --git a/tests/ios_backup/test_decrypt.py b/tests/ios_backup/test_decrypt.py index 25decce5..a03e687f 100644 --- a/tests/ios_backup/test_decrypt.py +++ b/tests/ios_backup/test_decrypt.py @@ -7,6 +7,7 @@ import logging import threading from pathlib import Path +import pytest from Crypto.Cipher import AES from mvt.ios.decrypt import DecryptBackup, MVTEncryptedBackup @@ -96,7 +97,10 @@ def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_pat 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) + try: + (destination / "ab").symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("creating symbolic links is not permitted on this system") cursor = mocker.MagicMock() cursor.__iter__.return_value = iter( diff --git a/tests/ios_backup/test_sqlite_handling.py b/tests/ios_backup/test_sqlite_handling.py index f7806401..2774098e 100644 --- a/tests/ios_backup/test_sqlite_handling.py +++ b/tests/ios_backup/test_sqlite_handling.py @@ -9,6 +9,8 @@ import plistlib import shutil import sqlite3 +import pytest + from mvt.ios.modules.base import IOSExtraction from mvt.ios.modules.fs.analytics import Analytics @@ -45,6 +47,9 @@ def test_open_sqlite_reads_wal_without_modifying_evidence(tmp_path): assert not os.path.exists(str(evidence_path) + "-shm") +@pytest.mark.skipif( + shutil.which("sqlite3") is None, reason="the recovery needs the sqlite3 binary" +) def test_recovery_preserves_source_database(tmp_path): database_path = tmp_path / "source.db" conn = sqlite3.connect(database_path) diff --git a/tests/test_check_android_androidqf.py b/tests/test_check_android_androidqf.py index 4e6ef984..65973109 100644 --- a/tests/test_check_android_androidqf.py +++ b/tests/test_check_android_androidqf.py @@ -85,7 +85,7 @@ class TestCheckAndroidqfCommand: 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") + (data_path / "adb_host_key.pub").write_bytes(b"QUJDRA== acquisition@host\n") command = CmdAndroidCheckAndroidQF(target_path=str(data_path)) command.init() diff --git a/tests/test_cmd_check_sysdiagnose.py b/tests/test_cmd_check_sysdiagnose.py index b00b648b..702e985f 100644 --- a/tests/test_cmd_check_sysdiagnose.py +++ b/tests/test_cmd_check_sysdiagnose.py @@ -116,7 +116,7 @@ def test_archive_is_extracted_once_and_unsafe_members_are_skipped(tmp_path): module = SysdiagnoseExtraction() command.module_init(module) assert module.tar is None - assert module.parent_path == str(extracted_path.parent) + assert Path(module.parent_path) == extracted_path.parent finally: command.finish() diff --git a/tests/test_completion.py b/tests/test_completion.py index 08ad3ce3..d240e492 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -43,6 +43,7 @@ class TestCompletionCommand: def test_completion_install_updates_bashrc_once(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) runner = CliRunner() result = runner.invoke(mvt_cli, ["completion", "bash", "--install"]) @@ -67,6 +68,7 @@ class TestCompletionCommand: self, tmp_path, monkeypatch ): monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) runner = CliRunner() result = runner.invoke(mvt_cli, ["completion", "fish", "--install"]) From ad6caa155ff3778bbfa1a07ae8efdbacdfbdd61f Mon Sep 17 00:00:00 2001 From: va-resident Date: Fri, 25 Sep 2026 20:55:01 +0400 Subject: [PATCH 097/109] Track accessibility state sections and unnamed services per user Two issues from review: * The set of printed state sections was global, so a section printed for one user decided the flags of another. A user with an installed list and no enabled section was marked enabled=False and got a LOW "installed, not enabled" alert. Sections are now tracked per user. * A count-only record was added only when a user had no named component at all. A dump stating installedServiceCount=2 and naming one service read as complete. The count-only record is now added whenever the stated count exceeds the distinct named components for that user, and carries the difference in a new unnamed_service_count field. The module summary adds up that remainder. --- .../artifacts/dumpsys_accessibility.py | 50 ++++++++++++------- .../bugreport/dumpsys_accessibility.py | 6 +-- .../test_artifact_dumpsys_accessibility.py | 8 ++- ...st_artifact_dumpsys_accessibility_count.py | 49 ++++++++++++++++++ ...st_artifact_dumpsys_accessibility_state.py | 30 +++++++++++ 5 files changed, 121 insertions(+), 22 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_accessibility.py b/src/mvt/android/artifacts/dumpsys_accessibility.py index 1a4a05ac..b6fe0e25 100644 --- a/src/mvt/android/artifacts/dumpsys_accessibility.py +++ b/src/mvt/android/artifacts/dumpsys_accessibility.py @@ -24,18 +24,26 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): "crashed", "accessibility_tool", "installed_service_count", + "unnamed_service_count", ) def check_indicators(self) -> None: for result in self.results: - # A stated count with no component names is a coverage statement, - # not a service: low, but not silent. + # A stated count the dump does not back with component names is a + # coverage statement, not a service: low, but not silent. if not result.get("component"): + stated = result["installed_service_count"] + unnamed = result["unnamed_service_count"] + if unnamed == stated: + detail = "does not list their component names" + else: + detail = ( + f"lists the component names of only {stated - unnamed} " + f"of them ({unnamed} unnamed)" + ) self.alertstore.low( - f"The accessibility dump states " - f"{result['installed_service_count']} installed " - f"service(s) for user {result['user_id']} but does not " - f"list their component names", + f"The accessibility dump states {stated} installed " + f"service(s) for user {result['user_id']} but {detail}", "", result, ) @@ -76,7 +84,9 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): self.results: list[dict[str, Any]] = [] services: dict[tuple[int | None, str], dict] = {} - seen_states: set[str] = set() + # Which state sections the dump printed, per user: one user's printed + # `enabled services` says nothing about another user's. + seen_states: dict[int | None, set[str]] = {} # `installedServiceCount=N` from the user's `attributes:{…}` line is on # most builds the only statement about installed services in the dump: # few print the `installed services: {…}` block. @@ -100,7 +110,7 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): ) if state_match: state = state_match.group(1).lower() - seen_states.add(self._state_field(state)) + seen_states.setdefault(user_id, set()).add(self._state_field(state)) inline = state_match.group(2) for component in re.findall( r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline @@ -133,21 +143,24 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): # empty: the first says nothing, the second says nothing is enabled. # Defaulting every flag to False would turn "not stated" into "not # enabled". Flags for sections this dump never printed stay None. + named: dict[int | None, int] = {} for (service_user, _component), service in services.items(): + printed = seen_states.get(service_user, set()) for state in ("installed", "enabled", "binding", "bound", "crashed"): - if self._state_field(state) not in seen_states: + if self._state_field(state) not in printed: service[self._state_field(state)] = None service["installed_service_count"] = installed_counts.get(service_user) + named[service_user] = named.get(service_user, 0) + 1 self.results.extend(services.values()) - # A stated count whose services were never listed would leave no trace: - # the module would log "a total of 0" about a dump that said five. - listed_users = {service_user for service_user, _component in services} + # A stated count the named services do not add up to would leave no + # trace of the rest: the module would log "a total of 0" about a dump + # that said five, or "a total of 1" about one that said two. for count_user, count in installed_counts.items(): - if count_user in listed_users or count == 0: - continue - self.results.append(self._new_unlisted(count_user, count)) + unnamed = count - named.get(count_user, 0) + if unnamed > 0: + self.results.append(self._new_unlisted(count_user, count, unnamed)) @staticmethod def _describe_state(result: dict) -> str: @@ -164,14 +177,16 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): return {"binding": "binding", "bound": "bound"}.get(state, state) @staticmethod - def _new_unlisted(user_id: int | None, count: int) -> dict: - """The dump's own count for a user whose services it did not list. + def _new_unlisted(user_id: int | None, count: int, unnamed: int) -> dict: + """The dump's own count for a user, and how many of those services it + did not name. Every other field stays unknown: the dump named no service to carry it. """ record: dict[str, Any] = dict.fromkeys(DumpsysAccessibilityArtifact._FIELDS) record["user_id"] = user_id record["installed_service_count"] = count + record["unnamed_service_count"] = unnamed return record @staticmethod @@ -184,4 +199,5 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): record["package_name"], record["service_name"] = component.split("/", 1) # Filled in after parsing: the count is per user. record["installed_service_count"] = None + record["unnamed_service_count"] = None return record diff --git a/src/mvt/android/modules/bugreport/dumpsys_accessibility.py b/src/mvt/android/modules/bugreport/dumpsys_accessibility.py index 80991d37..fddee84a 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_accessibility.py +++ b/src/mvt/android/modules/bugreport/dumpsys_accessibility.py @@ -48,7 +48,7 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule): ) self.parse(content) - listed = stated = 0 + listed = unnamed = 0 for result in self.results: if result.get("component"): listed += 1 @@ -59,11 +59,11 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule): # The operator gets this per user as a LOW alert from # check_indicators(); here it only has to survive into the summary, # so that a stated count never reads as "a total of 0". - stated += result.get("installed_service_count") or 0 + unnamed += result.get("unnamed_service_count") or 0 self.log.info( "Identified a total of %d accessibility services (%d more stated by " "the dump without a component name)", listed, - stated, + unnamed, ) diff --git a/tests/android/test_artifact_dumpsys_accessibility.py b/tests/android/test_artifact_dumpsys_accessibility.py index f21c79ed..6ba1a478 100644 --- a/tests/android/test_artifact_dumpsys_accessibility.py +++ b/tests/android/test_artifact_dumpsys_accessibility.py @@ -35,7 +35,9 @@ class TestDumpsysAccessibilityArtifact: assert len(da.results) == 0 da.parse(data) - assert len(da.results) == 1 + # One named service, plus one count-only record: the dump states + # `installedServiceCount=2` and names only one component. + assert len(da.results) == 2 assert da.results[0]["package_name"] == "com.malware.accessibility" assert da.results[0]["service_name"] == "com.malware.service.malwareservice" assert da.results[0]["enabled"] is True @@ -53,9 +55,11 @@ class TestDumpsysAccessibilityArtifact: da.check_indicators() - assert len(da.alertstore.alerts) == 1 + assert len(da.alertstore.alerts) == 2 assert da.alertstore.alerts[0].level == AlertLevel.MEDIUM assert da.alertstore.alerts[0].event == da.results[0] + assert da.alertstore.alerts[1].level == AlertLevel.LOW + assert da.alertstore.alerts[1].event == da.results[1] def test_same_component_is_kept_for_each_user(self): da = DumpsysAccessibilityArtifact() diff --git a/tests/android/test_artifact_dumpsys_accessibility_count.py b/tests/android/test_artifact_dumpsys_accessibility_count.py index 59765073..8c191d32 100644 --- a/tests/android/test_artifact_dumpsys_accessibility_count.py +++ b/tests/android/test_artifact_dumpsys_accessibility_count.py @@ -12,6 +12,8 @@ accessibility services" about a dump that said there are five. from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact from mvt.common.alerts import AlertLevel +from ..utils import get_artifact + AOSP_NO_LIST = """\ ACCESSIBILITY MANAGER (dumpsys accessibility) User state[ @@ -43,6 +45,14 @@ User state[attributes:{id=95, installedServiceCount=3} Enabled services:{} """ +PARTIAL_TWO_USERS = """\ +ACCESSIBILITY MANAGER (dumpsys accessibility) +User state[attributes:{id=0, installedServiceCount=3} + Enabled services:{{com.example.app/com.example.app.Service}} +User state[attributes:{id=10, installedServiceCount=1} + Enabled services:{{com.other.app/.Helper}} +""" + ZERO_COUNT = """\ ACCESSIBILITY MANAGER (dumpsys accessibility) User state[attributes:{id=0, installedServiceCount=0} @@ -98,3 +108,42 @@ class TestAccessibilityInstalledServiceCount: # "Zero installed" is a negative result the empty section already # states; a record for it would be noise. assert _parse(ZERO_COUNT).results == [] + + def test_a_partly_named_count_reports_the_unnamed_rest(self): + # The Android 14 fixture states `installedServiceCount=2` and names one + # enabled component. The listing is incomplete, and must not read as + # complete. + artifact = DumpsysAccessibilityArtifact() + artifact.results = [] + with open( + get_artifact("android_data/dumpsys_accessibility_v14_or_later.txt") + ) as handle: + artifact.parse(handle.read()) + unlisted = [record for record in artifact.results if not record["component"]] + assert len(unlisted) == 1 + assert unlisted[0]["installed_service_count"] == 2 + assert unlisted[0]["unnamed_service_count"] == 1 + + artifact.check_indicators() + low = [ + alert + for alert in artifact.alertstore.alerts + if alert.level == AlertLevel.LOW + ] + assert len(low) == 1 + assert "only 1 of them (1 unnamed)" in low[0].message + + def test_the_unnamed_rest_is_counted_per_user(self): + # User 0 names one of three, user 10 names its only one: the gap + # belongs to user 0 alone. + artifact = _parse(PARTIAL_TWO_USERS) + unlisted = [record for record in artifact.results if not record["component"]] + assert [ + (record["user_id"], record["unnamed_service_count"]) for record in unlisted + ] == [(0, 2)] + + def test_a_fully_named_count_adds_nothing(self): + artifact = _parse(ONE_UI_WITH_LIST) + assert all( + record["unnamed_service_count"] is None for record in artifact.results + ) diff --git a/tests/android/test_artifact_dumpsys_accessibility_state.py b/tests/android/test_artifact_dumpsys_accessibility_state.py index b60368ab..d6bbe789 100644 --- a/tests/android/test_artifact_dumpsys_accessibility_state.py +++ b/tests/android/test_artifact_dumpsys_accessibility_state.py @@ -42,6 +42,18 @@ User state[attributes:{id=0, currentUser=true} } """ +# User 0 prints only `installed services`, user 10 only `Enabled services`. +# Neither section speaks for the other user. +TWO_USERS_DIFFERENT_SECTIONS = """\ +ACCESSIBILITY MANAGER (dumpsys accessibility) +User state[attributes:{id=0, currentUser=true} + installed services: { + 0 : com.example.app/com.example.app.Service + } +User state[attributes:{id=10, currentUser=false} + Enabled services:{{com.other.app/.Helper}} +""" + class _IndicatorsMatching: """Minimal stand-in: matches one package id, like the STIX2 loader would.""" @@ -123,3 +135,21 @@ class TestAccessibilityServiceState: artifact.check_indicators() assert artifact.alertstore.count(AlertLevel.CRITICAL) == 1 assert artifact.alertstore.count(AlertLevel.LOW) == 0 + + def test_printed_sections_are_tracked_per_user(self): + artifact = DumpsysAccessibilityArtifact() + artifact.results = [] + artifact.parse(TWO_USERS_DIFFERENT_SECTIONS) + by_user = {r["user_id"]: r for r in artifact.results} + # User 0's enabled state is not stated, so it is unknown, not off. + assert (by_user[0]["installed"], by_user[0]["enabled"]) == (True, None) + # User 10's installed state is not stated either. + assert (by_user[10]["installed"], by_user[10]["enabled"]) == (None, True) + + artifact.check_indicators() + assert artifact.alertstore.count(AlertLevel.LOW) == 0 + assert artifact.alertstore.count(AlertLevel.MEDIUM) == 2 + assert not any( + "installed, not enabled" in alert.message + for alert in artifact.alertstore.alerts + ) From b1b9958f4d64b10b45603137711c7b1289ae860d Mon Sep 17 00:00:00 2001 From: va-resident Date: Tue, 22 Sep 2026 18:09:36 +0300 Subject: [PATCH 098/109] Descend into an OEM wrapper archive in check-bugreport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MIUI / HyperOS hands out a zip of app logs, ANR traces and tcpdump captures with the real bugreport--.zip nested inside. The outer archive carries none of the entry points the bug report modules read, so every module reported it found no files and check-bugreport still exited 0 with an empty result — an empty analysis that looks like a finished one. Name the entry points once in modules/bugreport/base.py, next to the _get_dumpstate_file() that tries them, and when an archive has none of them, open its zip members in memory and use the first one that does. Measured over 44 bug report collections: the 14 wrapper collections go from 0 artifacts to 260 artifacts and 586638 records; the 30 normal collections are unchanged, the descent being unreachable for them. Fixes #935 --- src/mvt/android/cmd_check_bugreport.py | 51 ++++++++++++++++- src/mvt/android/modules/bugreport/base.py | 13 ++++- tests/android/test_check_bugreport_wrapper.py | 56 +++++++++++++++++++ 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/android/test_check_bugreport_wrapper.py diff --git a/src/mvt/android/cmd_check_bugreport.py b/src/mvt/android/cmd_check_bugreport.py index 036160d8..34dddbde 100644 --- a/src/mvt/android/cmd_check_bugreport.py +++ b/src/mvt/android/cmd_check_bugreport.py @@ -3,14 +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 fnmatch +import io import logging import os from pathlib import Path from typing import List, Optional -from zipfile import ZipFile +from zipfile import BadZipFile, ZipFile from mvt.android.artifacts.getprop import GetProp -from mvt.android.modules.bugreport.base import BugReportModule +from mvt.android.modules.bugreport.base import ( + DUMPSTATE_ENTRY_POINTS, + BugReportModule, +) from mvt.common.command import Command from mvt.common.indicators import Indicators from mvt.common.module import MVTModule @@ -88,6 +93,48 @@ class CmdAndroidCheckBugreport(Command): for file_name in self.__zip.namelist(): self.__files.append(file_name) + if not self._has_dumpstate(self.__files): + nested = self._nested_bugreport(bugreport_zip) + if nested: + self.__zip = nested + self.__files = list(nested.namelist()) + + @staticmethod + def _has_dumpstate(file_names: List[str]) -> bool: + """Whether these members carry any of the entry points the bug report + modules read (see `BugReportModule._get_dumpstate_file`).""" + return any( + fnmatch.filter(file_names, pattern) for pattern in DUMPSTATE_ENTRY_POINTS + ) + + def _nested_bugreport(self, outer: ZipFile) -> Optional[ZipFile]: + """Descend one level into an OEM wrapper archive. + + MIUI / HyperOS hands out a zip of app logs, ANR traces and tcpdump + captures with the real `bugreport--.zip` nested + inside. Without this descent the outer archive has no entry point, + every module reports it found no files, and the command still exits 0 + with an empty result. + """ + candidates = [ + name for name in outer.namelist() if name.lower().endswith(".zip") + ] + candidates.sort( + key=lambda name: ( + "bugreport" not in name.lower() and "dumpstate" not in name.lower() + ) + ) + for name in candidates: + try: + inner = ZipFile(io.BytesIO(outer.read(name))) + except (BadZipFile, OSError): + continue + if self._has_dumpstate(inner.namelist()): + log.info("Found the bug report nested inside the archive: %s", name) + return inner + inner.close() + return None + def init(self) -> None: if self.target_path: self.log.info("Checking Android bug report at path: %s", self.target_path) diff --git a/src/mvt/android/modules/bugreport/base.py b/src/mvt/android/modules/bugreport/base.py index 9e8954d6..8d4f4ace 100644 --- a/src/mvt/android/modules/bugreport/base.py +++ b/src/mvt/android/modules/bugreport/base.py @@ -18,6 +18,11 @@ from mvt.common.module import ModuleResults, MVTModule # section finishes and not necessarily between two sections. SECTION_DURATION = re.compile(r"^-{3,}\s*[0-9.]+s was the duration of", re.IGNORECASE) +# The members a bug report archive can be entered through, in the order +# _get_dumpstate_file() tries them. An archive carrying none of them is not a +# bug report at this level (see CmdAndroidCheckBugreport._has_dumpstate). +DUMPSTATE_ENTRY_POINTS = ("main_entry.txt", "dumpState_*.log", "*/dumpsys.txt") + class BugReportModule(MVTModule): """This class provides a base for all Android Bug Report modules.""" @@ -92,7 +97,9 @@ class BugReportModule(MVTModule): return data def _get_dumpstate_file(self) -> Optional[bytes]: - main = self._get_files_by_pattern("main_entry.txt") + main_entry, dumpstate_log, dumpsys_txt = DUMPSTATE_ENTRY_POINTS + + main = self._get_files_by_pattern(main_entry) if main: main_content = self._get_file_content(main[0]) try: @@ -100,11 +107,11 @@ class BugReportModule(MVTModule): except KeyError: return None - dumpstate_logs = self._get_files_by_pattern("dumpState_*.log") + dumpstate_logs = self._get_files_by_pattern(dumpstate_log) if dumpstate_logs: return self._get_file_content(dumpstate_logs[0]) - dumpsys_files = self._get_files_by_pattern("*/dumpsys.txt") + dumpsys_files = self._get_files_by_pattern(dumpsys_txt) if dumpsys_files: return self._get_file_content(dumpsys_files[0]) diff --git a/tests/android/test_check_bugreport_wrapper.py b/tests/android/test_check_bugreport_wrapper.py new file mode 100644 index 00000000..741641f2 --- /dev/null +++ b/tests/android/test_check_bugreport_wrapper.py @@ -0,0 +1,56 @@ +# 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/ +"""An OEM wrapper archive must not read as an empty bug report. + +MIUI / HyperOS hands out a zip of app logs with the real +`bugreport--.zip` nested inside. The outer archive has +none of the entry points the modules read, so every module +reported it found nothing and the command still exited 0 — an empty analysis +that looks like a finished one. +""" + +import io +import zipfile + +from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport + +DUMPSTATE = "== dumpstate: 2026-01-01 00:00:00\nDUMP OF SERVICE package:\n" + + +def _inner_zip() -> bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as inner: + inner.writestr("main_entry.txt", "bugreport-test-2026-01-01-00-00-00.txt") + inner.writestr("bugreport-test-2026-01-01-00-00-00.txt", DUMPSTATE) + return buffer.getvalue() + + +def _wrapper_zip() -> zipfile.ZipFile: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as outer: + outer.writestr("app_logs/hilog.txt", "unrelated OEM log\n") + outer.writestr("bugreport-test-2026-01-01-00-00-00.zip", _inner_zip()) + return zipfile.ZipFile(io.BytesIO(buffer.getvalue())) + + +class TestCheckBugreportWrapper: + def test_nested_bugreport_is_used(self, tmp_path): + cmd = CmdAndroidCheckBugreport(results_path=str(tmp_path)) + cmd.from_zip(_wrapper_zip()) + module = cmd.modules[0](results_path=str(tmp_path)) + cmd.module_init(module) + assert "main_entry.txt" in module.zip_files + + def test_plain_bugreport_is_left_alone(self, tmp_path): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("main_entry.txt", "bugreport.txt") + archive.writestr("bugreport.txt", DUMPSTATE) + archive.writestr("attachments/extra.zip", _inner_zip()) + cmd = CmdAndroidCheckBugreport(results_path=str(tmp_path)) + cmd.from_zip(zipfile.ZipFile(io.BytesIO(buffer.getvalue()))) + module = cmd.modules[0](results_path=str(tmp_path)) + cmd.module_init(module) + assert "attachments/extra.zip" in module.zip_files From d2e992e40e3b7d9f68329e1667bc127227af8bf2 Mon Sep 17 00:00:00 2001 From: itzzdev09 Date: Mon, 28 Sep 2026 00:07:17 +0530 Subject: [PATCH 099/109] Skip a malformed battery daily Update line instead of aborting --- .../android/artifacts/dumpsys_battery_daily.py | 8 ++++++-- .../test_artifact_dumpsys_battery_daily.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_battery_daily.py b/src/mvt/android/artifacts/dumpsys_battery_daily.py index 2ccb6b70..905b4e18 100644 --- a/src/mvt/android/artifacts/dumpsys_battery_daily.py +++ b/src/mvt/android/artifacts/dumpsys_battery_daily.py @@ -73,8 +73,12 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact): continue line = line.strip().replace("Update ", "") - package_name, vers = line.split(" ", 1) - vers_raw = vers.split("=", 1)[1] + # A truncated or vendor-specific line must not abort the parse and + # lose every record after it. + package_name, _, vers = line.partition(" ") + vers_raw = vers.partition("=")[2] + if not package_name or not vers_raw: + continue try: version_code: int | str = int(vers_raw) except ValueError: diff --git a/tests/android/test_artifact_dumpsys_battery_daily.py b/tests/android/test_artifact_dumpsys_battery_daily.py index 5f7f997c..d232d219 100644 --- a/tests/android/test_artifact_dumpsys_battery_daily.py +++ b/tests/android/test_artifact_dumpsys_battery_daily.py @@ -144,3 +144,18 @@ class TestDumpsysBatteryDailyArtifact: "Detected uninstall of package com.example.app (vers 0)" ) assert uninstall_alert.event_time == "2026-01-10" + + def test_malformed_update_line_does_not_lose_later_records(self): + # A truncated "Update" line, or one from a vendor that omits "vers=", + # used to raise out of parse() and lose every record after it. + dba = DumpsysBatteryDailyArtifact() + dba.parse( + " Daily from 2021-05-10-08-00-00 to 2021-05-11-08-00-00:\n" + " Update com.first vers=1\n" + " Update com.truncated\n" + " Update com.no.equals vers 2\n" + " Update com.last vers=3\n" + ) + + assert [r["package_name"] for r in dba.results] == ["com.first", "com.last"] + assert [r["version_code"] for r in dba.results] == [1, 3] From 9316ac7fb0d0175008e538980ead3bf48cd49e9c Mon Sep 17 00:00:00 2001 From: itzzdev09 Date: Mon, 28 Sep 2026 02:07:03 +0530 Subject: [PATCH 100/109] Bound the ADB manager state by matching braces --- src/mvt/android/artifacts/dumpsys_adb.py | 43 +++++++++++++++++-- tests/android/test_artifact_dumpsys_adb.py | 50 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/mvt/android/artifacts/dumpsys_adb.py b/src/mvt/android/artifacts/dumpsys_adb.py index cbd80e21..f3c76461 100644 --- a/src/mvt/android/artifacts/dumpsys_adb.py +++ b/src/mvt/android/artifacts/dumpsys_adb.py @@ -34,6 +34,15 @@ class DumpsysADBArtifact(AndroidArtifact): indent = len(line) - len(line.lstrip()) if indent < cur_indent: # If the current line is less indented than the previous one, back out + if len(stack) <= 1: + # Dedenting below the outermost level means this is not the + # well-formed block the parser expects. Stop here rather than + # raise IndexError out of the module on the next line. + self.log.error( + "Unexpected indentation in ADB manager state, " + "stopping the parse of this section" + ) + break stack.pop() cur_indent = indent else: @@ -63,6 +72,12 @@ class DumpsysADBArtifact(AndroidArtifact): current_dict = stack[-1] if key == "}": + if len(stack) <= 1: + self.log.error( + "Unbalanced closing brace in ADB manager state, " + "stopping the parse of this section" + ) + break stack.pop() continue @@ -161,6 +176,26 @@ class DumpsysADBArtifact(AndroidArtifact): f"'{user_key['fingerprint']}'" ) + @staticmethod + def _find_state_end(content: bytes, open_brace: int) -> int: + """Index of the brace closing the one at ``open_brace``, or -1. + + The end of the ADB manager state is found by matching braces rather than + by looking for the last one in the output: a bug report holds many + dumpsys sections, and a brace in a later one would extend this section + past its end. + """ + depth = 0 + for index in range(open_brace, len(content)): + char = content[index : index + 1] + if char == b"{": + depth += 1 + elif char == b"}": + depth -= 1 + if depth == 0: + return index + return -1 + def parse(self, content: bytes) -> None: """ Parse the Dumpsys ADB section @@ -180,12 +215,14 @@ class DumpsysADBArtifact(AndroidArtifact): 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: + end_of_json = self._find_state_end(content, start_of_json + 1) + if end_of_json == -1: 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() + # The brace that opens the state and the one that closes it are not part + # of the indented body. + json_content = content[start_of_json + 2 : end_of_json].rstrip() parsed = self.indented_dump_parser(json_content) if parsed.get("debugging_manager") is None: diff --git a/tests/android/test_artifact_dumpsys_adb.py b/tests/android/test_artifact_dumpsys_adb.py index 8d501b7d..232da076 100644 --- a/tests/android/test_artifact_dumpsys_adb.py +++ b/tests/android/test_artifact_dumpsys_adb.py @@ -131,6 +131,56 @@ class TestDumpsysADBArtifact: assert key_store_entry["last_connected"] == "1628501829898" + + ADB_STATE = ( + b"ADB MANAGER STATE (dumpsys adb):\n" + b"{\n" + b" debugging_manager={\n" + b" connected_to_adb=true\n" + b" user_keys=QUJDRA== host@example\n" + b" }\n" + b"}\n" + b"--------- 0.5s was the duration of 'dumpsys adb'\n" + ) + + def test_a_later_dumpsys_section_does_not_extend_the_adb_state(self): + # A bug report holds many sections. Looking for the last closing brace + # in the whole output pulled a later section into this one, which threw + # IndexError out of parse() and lost the ADB records entirely. + da_adb = DumpsysADBArtifact() + da_adb.parse( + self.ADB_STATE + + b"DUMP OF SERVICE other:\n" + b" debugging_manager={\n" + b" connected_to_adb=false\n" + b" user_keys=RVZJTA== attacker@host\n" + b" }\n" + b"}\n" + ) + + assert len(da_adb.results) == 1 + assert [key["user"] for key in da_adb.results[0]["user_keys"]] == [ + "host@example" + ] + assert da_adb.results[0]["connected_to_adb"] is True + + def test_unbalanced_state_is_reported_rather_than_raising(self): + da_adb = DumpsysADBArtifact() + da_adb.parse( + b"ADB MANAGER STATE (dumpsys adb):\n" + b"{\n" + b" debugging_manager={\n" + b" connected_to_adb=true\n" + b" }\n" + b" }\n" + b"}\n" + ) + + # No exception, and whatever was read before the bad line is kept. + assert len(da_adb.results) == 1 + assert da_adb.results[0]["connected_to_adb"] is True + + class TestDumpsysADBStateAlerts: def test_no_androidqf_context_preserves_existing_behavior(self): module = DumpsysADBState( From 658c7aa327cc0ecfed18be143640ce04674d6ffb Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Mon, 28 Sep 2026 15:47:15 +0200 Subject: [PATCH 101/109] Test Windows path normalization and preserve backup safety coverage --- tests/ios_backup/test_decrypt.py | 36 +++++++++++++++++++------------- tests/ios_fs/test_filesystem.py | 30 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/tests/ios_backup/test_decrypt.py b/tests/ios_backup/test_decrypt.py index a03e687f..1e2ce148 100644 --- a/tests/ios_backup/test_decrypt.py +++ b/tests/ios_backup/test_decrypt.py @@ -82,7 +82,10 @@ def test_extract_file_by_id_copies_unencrypted_files(mocker, tmp_path): assert output_path.read_bytes() == b"plain content" -def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_path): +@pytest.mark.parametrize("with_symlink", [False, True], ids=["file-id", "symlink"]) +def test_process_backup_rejects_unsafe_file_ids_and_destinations( + mocker, tmp_path, with_symlink +): backup_path = tmp_path / "backup" destination = tmp_path / "destination" outside = tmp_path / "outside" @@ -93,23 +96,27 @@ def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_pat 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): + file_ids = [safe_file_id] + if with_symlink: + file_ids.append(symlink_file_id) + for file_id in file_ids: source_path = backup_path / file_id[:2] / file_id source_path.parent.mkdir(parents=True, exist_ok=True) source_path.write_bytes(b"encrypted") - try: - (destination / "ab").symlink_to(outside, target_is_directory=True) - except OSError: - pytest.skip("creating symbolic links is not permitted on this system") + if with_symlink: + try: + (destination / "ab").symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("creating symbolic links is not permitted on this system") 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"), - ] - ) + records = [ + (safe_file_id, "Domain", "safe", b"plist"), + (unsafe_file_id, "Domain", "unsafe", b"plist"), + ] + if with_symlink: + records.append((symlink_file_id, "Domain", "symlink", b"plist")) + cursor.__iter__.return_value = iter(records) cursor_context = mocker.MagicMock() cursor_context.__enter__.return_value = cursor @@ -128,7 +135,8 @@ def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_pat decryptor.process_backup() assert (destination / safe_file_id[:2] / safe_file_id).read_bytes() == b"decrypted" - assert not (outside / symlink_file_id).exists() + if with_symlink: + 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_fs/test_filesystem.py b/tests/ios_fs/test_filesystem.py index 636c004a..fe3ecf68 100644 --- a/tests/ios_fs/test_filesystem.py +++ b/tests/ios_fs/test_filesystem.py @@ -3,15 +3,45 @@ # 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 ntpath +from types import SimpleNamespace from mvt.common.indicators import Indicators from mvt.common.module import run_module +from mvt.ios.modules.fs import filesystem from mvt.ios.modules.fs.filesystem import Filesystem from ..utils import get_ios_backup_folder class TestFilesystem: + def test_windows_paths_are_normalized_for_indicators( + self, monkeypatch, indicators_factory + ): + m = Filesystem(target_path=r"C:\dump") + m.indicators = indicators_factory( + file_paths=["matched/directory"], processes=["matched"] + ) + monkeypatch.setattr( + filesystem, + "os", + SimpleNamespace( + sep="\\", + path=ntpath, + walk=lambda _: [(r"C:\dump\matched", ["directory"], ["file.txt"])], + stat=lambda _: SimpleNamespace(st_mtime=0), + ), + ) + + m.run() + m.check_indicators() + + assert {result["path"] for result in m.results} == { + "matched/directory", + "matched/file.txt", + } + assert len(m.alertstore.alerts) == 3 + def test_filesystem(self): m = Filesystem(target_path=get_ios_backup_folder()) run_module(m) From 3e99412c3237b897a1de5585b52b8c6266006599 Mon Sep 17 00:00:00 2001 From: DonnchaC <3081375+DonnchaC@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:17:44 +0000 Subject: [PATCH 102/109] Add new iOS versions and build numbers --- src/mvt/ios/data/ios_versions.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mvt/ios/data/ios_versions.json b/src/mvt/ios/data/ios_versions.json index 8cea66c2..839aa200 100644 --- a/src/mvt/ios/data/ios_versions.json +++ b/src/mvt/ios/data/ios_versions.json @@ -1279,5 +1279,9 @@ { "version": "27.0", "build": "24A437" + }, + { + "version": "27.0.1", + "build": "24A446" } ] \ No newline at end of file From f98f4f6cd21c8a8d8fdf56aea0121851d1bc2618 Mon Sep 17 00:00:00 2001 From: Yi-111-a Date: Tue, 29 Sep 2026 23:59:15 +0800 Subject: [PATCH 103/109] fix(manifest): flag backup files listed in the manifest but not stored An incomplete iTunes backup still lists in its Manifest.db the files it failed to acquire, so a module which found nothing for one of them looks exactly like a module which found nothing on a device that never had the artifact. The Manifest module now records a "missing" flag on those records and reports the total, so a gap in the acquisition is visible in manifest.json and in the command output. The stored file IDs are collected by walking the backup folder once, which keeps the check off the per-entry filesystem lookup path: on a 20k entry manifest the module runs in the same time as before the change. --- docs/ios/records.md | 2 + src/mvt/ios/modules/backup/manifest.py | 25 +++++++++++ src/mvt/ios/modules/base.py | 35 +++++++++++++++ tests/ios_backup/test_manifest.py | 62 ++++++++++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/docs/ios/records.md b/docs/ios/records.md index 856d0db9..6de89b20 100644 --- a/docs/ios/records.md +++ b/docs/ios/records.md @@ -204,6 +204,8 @@ This JSON file is created by mvt-ios' `Manifest` module. The module extracts rec If indicators are provided through the command-line, they are checked against the original relative path in case. In some cases, there might be records of files created containing a domain name in their name, for example in the case of browser cache folders. Any matches are stored in *manifest_detected.json*. +An incomplete backup still lists in its manifest the files it failed to acquire. Those records carry a `"missing": true` field, and the module reports how many of them it found. Use it to tell a module which returned nothing because the artifact was not acquired apart from one which found nothing on a device that never had it. + --- ### `os_analytics_ad_daily.json` diff --git a/src/mvt/ios/modules/backup/manifest.py b/src/mvt/ios/modules/backup/manifest.py index cc74cbb6..e4ef17b5 100644 --- a/src/mvt/ios/modules/backup/manifest.py +++ b/src/mvt/ios/modules/backup/manifest.py @@ -147,6 +147,12 @@ class Manifest(IOSExtraction): ) names = [description[0] for description in cur.description] + # An incomplete backup still lists the files it failed to acquire in + # its manifest. Only regular files are stored in the backup folder, so + # directories and symlinks (flags 2 and 4) are not looked up. + stored_file_ids = self._get_stored_backup_file_ids() + missing_files = 0 + for file_entry in cur: file_data = {} for index, value in enumerate(file_entry): @@ -160,6 +166,18 @@ class Manifest(IOSExtraction): "created": "", } + if file_data["flags"] == 1 and file_data["fileID"] not in stored_file_ids: + # Without this, a module which found nothing for one of these + # files would look like a negative result rather than a gap in + # the acquisition. + cleaned_metadata["missing"] = True + missing_files += 1 + self.log.debug( + "File %s is listed in the manifest but was not found in the " + "backup folder", + cleaned_metadata["relative_path"], + ) + if file_data["file"]: try: file_plist = plistlib.load(io.BytesIO(file_data["file"])) @@ -197,3 +215,10 @@ class Manifest(IOSExtraction): conn.close() self.log.info("Extracted a total of %d file metadata items", len(self.results)) + + if missing_files: + self.log.info( + "Found %d files listed in the manifest but missing from the backup " + "folder. The backup might be incomplete.", + missing_files, + ) diff --git a/src/mvt/ios/modules/base.py b/src/mvt/ios/modules/base.py index a5c3354b..6cce6318 100644 --- a/src/mvt/ios/modules/base.py +++ b/src/mvt/ios/modules/base.py @@ -216,6 +216,41 @@ class IOSExtraction(MVTModule): return None + def _get_stored_backup_file_ids(self) -> set[str]: + """List the IDs of the files actually stored in the backup folder. + + A backup folder stores each file under a two character subfolder named + after the first two characters of its file ID. Walking the folders once + is cheaper than a filesystem lookup per file ID, and it keeps callers + which compare a whole manifest against the folder from paying a + `resolve()` for every entry. + + :returns: The file IDs found in the backup folder, empty if there is + no backup folder to walk. + """ + if not self.target_path: + return set() + + file_ids: set[str] = set() + try: + with os.scandir(self.target_path) as entries: + for entry in entries: + if not entry.is_dir(): + continue + with os.scandir(entry.path) as sub_entries: + for sub_entry in sub_entries: + if sub_entry.is_file(): + file_ids.add(sub_entry.name) + except OSError as exc: + self.log.debug( + "Unable to list the files stored in the backup folder %s: %s", + self.target_path, + exc, + ) + return set() + + return file_ids + def _get_fs_files_from_patterns(self, root_paths: list) -> Iterator[str]: if not self.target_path: return diff --git a/tests/ios_backup/test_manifest.py b/tests/ios_backup/test_manifest.py index aaa747d0..8601a667 100644 --- a/tests/ios_backup/test_manifest.py +++ b/tests/ios_backup/test_manifest.py @@ -5,8 +5,11 @@ import gc import logging +import shutil import warnings +import pytest + from mvt.common.indicators import Indicators from mvt.common.module import run_module from mvt.ios.modules.base import IOSExtraction @@ -14,6 +17,30 @@ from mvt.ios.modules.backup.manifest import Manifest from ..utils import get_ios_backup_folder +# fileID of HomeDomain::Library/SMS/sms.db in the test backup. It is one of the +# few files the test backup actually stores. +SMS_FILE_ID = "3d0d7e5fb2ce288813306e4d4636395e047a3d28" + + +@pytest.fixture +def backup_without_stored_files(tmp_path): + """A copy of the test backup with every stored file removed. + + The test backup only ships a handful of the files its manifest lists, so + dropping what it does store leaves a backup where every regular file the + manifest mentions is missing from the folder. + """ + backup_path = tmp_path / "backup" + shutil.copytree(get_ios_backup_folder(), backup_path) + + for file_id_folder in backup_path.iterdir(): + if not file_id_folder.is_dir(): + continue + for backup_file in file_id_folder.iterdir(): + backup_file.unlink() + + return str(backup_path) + class TestIOSExtraction: def test_get_backup_files_from_manifest_closes_connection(self): @@ -41,6 +68,41 @@ class TestManifestModule: assert len(m.timeline) == 5881 assert len(m.alertstore.alerts) == 0 + def test_manifest_flags_the_files_missing_from_an_incomplete_backup(self): + m = Manifest(target_path=get_ios_backup_folder()) + run_module(m) + + missing = [result for result in m.results if result.get("missing")] + # The test backup only stores a handful of the files its manifest + # lists, and every one of the rest is a regular file (flags 1). + assert len(missing) == 1079 + assert all(result["flags"] == 1 for result in missing) + + stored = [result for result in m.results if result["file_id"] == SMS_FILE_ID] + assert len(stored) == 1 + assert "missing" not in stored[0] + + def test_manifest_flags_every_stored_file_removed_from_the_backup_folder( + self, backup_without_stored_files + ): + m = Manifest(target_path=backup_without_stored_files) + run_module(m) + + missing = [result for result in m.results if result.get("missing")] + assert len(missing) == 1089 + + def test_manifest_flags_a_file_removed_from_the_backup_folder(self, tmp_path): + backup_path = tmp_path / "backup" + shutil.copytree(get_ios_backup_folder(), backup_path) + (backup_path / SMS_FILE_ID[:2] / SMS_FILE_ID).unlink() + + m = Manifest(target_path=str(backup_path)) + run_module(m) + + removed = [result for result in m.results if result["file_id"] == SMS_FILE_ID] + assert len(removed) == 1 + assert removed[0]["missing"] is True + def test_detection(self, indicator_file): m = Manifest(target_path=get_ios_backup_folder()) ind = Indicators(log=logging.getLogger()) From a80b0fff2e3d0bed8d5fa16776d997bed5f36261 Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Tue, 29 Sep 2026 18:40:02 +0200 Subject: [PATCH 104/109] fix(manifest): validate backup inventory before marking files missing --- docs/ios/records.md | 2 + src/mvt/ios/modules/backup/manifest.py | 6 +- src/mvt/ios/modules/base.py | 28 ++++++-- tests/ios_backup/test_manifest.py | 90 ++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 8 deletions(-) diff --git a/docs/ios/records.md b/docs/ios/records.md index 6de89b20..495db368 100644 --- a/docs/ios/records.md +++ b/docs/ios/records.md @@ -206,6 +206,8 @@ If indicators are provided through the command-line, they are checked against th An incomplete backup still lists in its manifest the files it failed to acquire. Those records carry a `"missing": true` field, and the module reports how many of them it found. Use it to tell a module which returned nothing because the artifact was not acquired apart from one which found nothing on a device that never had it. +Files must be stored at their expected paths inside the backup folder. If the module cannot finish listing the backup files, it logs a warning and skips the missing-file check; the manifest metadata is still extracted. + --- ### `os_analytics_ad_daily.json` diff --git a/src/mvt/ios/modules/backup/manifest.py b/src/mvt/ios/modules/backup/manifest.py index e4ef17b5..d0a6680d 100644 --- a/src/mvt/ios/modules/backup/manifest.py +++ b/src/mvt/ios/modules/backup/manifest.py @@ -166,7 +166,11 @@ class Manifest(IOSExtraction): "created": "", } - if file_data["flags"] == 1 and file_data["fileID"] not in stored_file_ids: + if ( + stored_file_ids is not None + and file_data["flags"] == 1 + and file_data["fileID"] not in stored_file_ids + ): # Without this, a module which found nothing for one of these # files would look like a negative result rather than a gap in # the acquisition. diff --git a/src/mvt/ios/modules/base.py b/src/mvt/ios/modules/base.py index 6cce6318..e5281fda 100644 --- a/src/mvt/ios/modules/base.py +++ b/src/mvt/ios/modules/base.py @@ -216,7 +216,7 @@ class IOSExtraction(MVTModule): return None - def _get_stored_backup_file_ids(self) -> set[str]: + def _get_stored_backup_file_ids(self) -> Optional[set[str]]: """List the IDs of the files actually stored in the backup folder. A backup folder stores each file under a two character subfolder named @@ -225,29 +225,43 @@ class IOSExtraction(MVTModule): which compare a whole manifest against the folder from paying a `resolve()` for every entry. - :returns: The file IDs found in the backup folder, empty if there is - no backup folder to walk. + :returns: The file IDs found at their expected paths within the backup + folder, or None if the inventory could not be completed. """ if not self.target_path: - return set() + return None file_ids: set[str] = set() try: + backup_root = Path(self.target_path).resolve() with os.scandir(self.target_path) as entries: for entry in entries: + if len(entry.name) != 2 or any( + char not in "0123456789abcdef" for char in entry.name + ): + continue if not entry.is_dir(): continue + if not Path(entry.path).resolve().is_relative_to(backup_root): + continue with os.scandir(entry.path) as sub_entries: for sub_entry in sub_entries: + if sub_entry.name[:2] != entry.name: + continue + if sub_entry.is_symlink() and not Path( + sub_entry.path + ).resolve().is_relative_to(backup_root): + continue if sub_entry.is_file(): file_ids.add(sub_entry.name) except OSError as exc: - self.log.debug( - "Unable to list the files stored in the backup folder %s: %s", + self.log.warning( + "Unable to list the files stored in the backup folder %s: %s. " + "Skipping the missing-file check.", self.target_path, exc, ) - return set() + return None return file_ids diff --git a/tests/ios_backup/test_manifest.py b/tests/ios_backup/test_manifest.py index 8601a667..9a52ee78 100644 --- a/tests/ios_backup/test_manifest.py +++ b/tests/ios_backup/test_manifest.py @@ -5,8 +5,10 @@ import gc import logging +import os import shutil import warnings +from pathlib import Path import pytest @@ -43,6 +45,32 @@ def backup_without_stored_files(tmp_path): class TestIOSExtraction: + @pytest.mark.parametrize("link_directory", [False, True], ids=["file", "directory"]) + @pytest.mark.parametrize("inside_backup", [False, True], ids=["outside", "inside"]) + def test_stored_file_inventory_matches_symlink_resolution( + self, tmp_path, link_directory, inside_backup + ): + backup_path = tmp_path / "backup" + backup_path.mkdir() + destination = (backup_path if inside_backup else tmp_path) / "contents" + destination.mkdir() + (destination / SMS_FILE_ID).write_bytes(b"backup file") + bucket = backup_path / SMS_FILE_ID[:2] + try: + if link_directory: + bucket.symlink_to(destination, target_is_directory=True) + else: + bucket.mkdir() + (bucket / SMS_FILE_ID).symlink_to(destination / SMS_FILE_ID) + except OSError: + pytest.skip("creating symbolic links is not permitted on this system") + + m = IOSExtraction(target_path=str(backup_path)) + assert bool(m._get_backup_file_from_id(SMS_FILE_ID)) is inside_backup + assert m._get_stored_backup_file_ids() == ( + {SMS_FILE_ID} if inside_backup else set() + ) + def test_get_backup_files_from_manifest_closes_connection(self): m = IOSExtraction(target_path=get_ios_backup_folder()) @@ -103,6 +131,68 @@ class TestManifestModule: assert len(removed) == 1 assert removed[0]["missing"] is True + @pytest.mark.parametrize("failed_folder", ["", "3d"], ids=["root", "bucket"]) + def test_manifest_skips_missing_check_when_inventory_fails( + self, monkeypatch, caplog, failed_folder + ): + backup_path = Path(get_ios_backup_folder()) + failed_path = backup_path / failed_folder + scandir = os.scandir + + def fail_listing(path): + if Path(path) == failed_path: + raise PermissionError("cannot list backup folder") + return scandir(path) + + monkeypatch.setattr(os, "scandir", fail_listing) + m = Manifest(target_path=str(backup_path)) + with caplog.at_level(logging.INFO): + m.run() + + assert len(m.results) == 3721 + assert m._get_backup_file_from_id(SMS_FILE_ID) is not None + assert all("missing" not in result for result in m.results) + assert "Skipping the missing-file check" in caplog.text + assert "The backup might be incomplete" not in caplog.text + + def test_manifest_ignores_unreadable_unrelated_folders(self, tmp_path, monkeypatch): + backup_path = tmp_path / "backup" + shutil.copytree(get_ios_backup_folder(), backup_path) + unrelated = backup_path / "notes" + unrelated.mkdir() + scandir = os.scandir + + def fail_listing(path): + if Path(path) == unrelated: + raise PermissionError("cannot list unrelated folder") + return scandir(path) + + monkeypatch.setattr(os, "scandir", fail_listing) + m = Manifest(target_path=str(backup_path)) + m.run() + + assert sum(bool(result.get("missing")) for result in m.results) == 1079 + stored = next( + result for result in m.results if result["file_id"] == SMS_FILE_ID + ) + assert "missing" not in stored + + @pytest.mark.parametrize("wrong_folder", ["ab", "notes"]) + def test_manifest_flags_files_in_the_wrong_folder(self, tmp_path, wrong_folder): + backup_path = tmp_path / "backup" + shutil.copytree(get_ios_backup_folder(), backup_path) + destination = backup_path / wrong_folder + destination.mkdir(exist_ok=True) + (backup_path / SMS_FILE_ID[:2] / SMS_FILE_ID).rename(destination / SMS_FILE_ID) + + m = Manifest(target_path=str(backup_path)) + m.run() + + assert m._get_backup_file_from_id(SMS_FILE_ID) is None + moved = next(result for result in m.results if result["file_id"] == SMS_FILE_ID) + assert moved["missing"] is True + assert sum(bool(result.get("missing")) for result in m.results) == 1080 + def test_detection(self, indicator_file): m = Manifest(target_path=get_ios_backup_folder()) ind = Indicators(log=logging.getLogger()) From 60d1b414fee6ce1cd72a22c65418825667b4b017 Mon Sep 17 00:00:00 2001 From: Forest Savage <96553407+forest-savage1234@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:13:11 -0800 Subject: [PATCH 105/109] Correct the Python minimum in installation documentation --- docs/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.md b/docs/install.md index c024338c..7c5ee6ee 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,6 +1,6 @@ # Installation -Before proceeding, please note that MVT requires Python 3.6+ to run. While it should be available on most operating systems, please make sure of that before proceeding. +Before proceeding, please note that MVT requires Python 3.10 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding. ## Dependencies on Linux From 3a462e1e2c4b56542c0d55a1966e923f7559b925 Mon Sep 17 00:00:00 2001 From: "va@resident" Date: Tue, 6 Oct 2026 15:29:41 +0500 Subject: [PATCH 106/109] Fix spurious ADB state parse error on devices without stored keys Since #957 the parsed body keeps the closing brace of debugging_manager and the blank lines before it. The brace popped two levels, and a blank line after system_keys raised IndexError. Don't pop when the indentation drops on a closing brace, and skip blank lines outside multiline values. Fixes #967. --- src/mvt/android/artifacts/dumpsys_adb.py | 10 ++- tests/android/test_artifact_dumpsys_adb.py | 79 ++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/mvt/android/artifacts/dumpsys_adb.py b/src/mvt/android/artifacts/dumpsys_adb.py index c57cf8d6..2e493c27 100644 --- a/src/mvt/android/artifacts/dumpsys_adb.py +++ b/src/mvt/android/artifacts/dumpsys_adb.py @@ -33,7 +33,12 @@ class DumpsysADBArtifact(AndroidArtifact): line = line.removesuffix(b"\r") # Track the level of indentation indent = len(line) - len(line.lstrip()) - if indent < cur_indent: + # A closing brace is dedented from the block it closes, and the + # brace handler below pops that block. Popping on the dedent as + # well would close two levels for one brace. A blank line has no + # indentation to compare: it ends a multiline value (handled + # below) and is otherwise skipped. + if indent < cur_indent and line.strip() not in (b"", b"}"): # If the current line is less indented than the previous one, back out if len(stack) <= 1: # Dedenting below the outermost level means this is not the @@ -72,6 +77,9 @@ class DumpsysADBArtifact(AndroidArtifact): stack.pop() current_dict = stack[-1] + if not line.strip(): + continue + if key == "}": if len(stack) <= 1: self.log.error( diff --git a/tests/android/test_artifact_dumpsys_adb.py b/tests/android/test_artifact_dumpsys_adb.py index 0e5b59f4..b85c3973 100644 --- a/tests/android/test_artifact_dumpsys_adb.py +++ b/tests/android/test_artifact_dumpsys_adb.py @@ -209,6 +209,85 @@ class TestDumpsysADBArtifact: assert len(da_adb.results) == 1 assert da_adb.results[0]["user_keys"][0]["user"] == user.decode() + def test_state_without_keys_is_parsed_without_error(self, caplog): + # Shape printed by every device with no stored ADB keys (Samsung + # SM-A525F, Android 12). The block's closing brace is dedented from + # the last entry and must close exactly one level. + da_adb = DumpsysADBArtifact() + da_adb.parse( + b"ADB MANAGER STATE (dumpsys adb):\n" + b"{\n" + b" debugging_manager={\n" + b" connected_to_adb=false\n" + b" }\n" + b"}\n" + b"--------- 0.015s was the duration of dumpsys adb\n" + ) + + assert len(da_adb.results) == 1 + assert da_adb.results[0]["connected_to_adb"] is False + assert not [r for r in caplog.records if r.levelname == "ERROR"] + + def test_nested_block_as_last_entry_is_parsed_without_error(self, caplog): + # Shape printed by Pixel devices with no stored ADB keys. + da_adb = DumpsysADBArtifact() + da_adb.parse( + b"ADB MANAGER STATE (dumpsys adb):\n" + b"{\n" + b" debugging_manager={\n" + b" connected_to_adb=false\n" + b" adb_wifi={\n" + b" enabled=false\n" + b" tls_port=0\n" + b" }\n" + b" }\n" + b"}\n" + ) + + assert len(da_adb.results) == 1 + assert da_adb.results[0]["connected_to_adb"] is False + assert da_adb.results[0]["adb_wifi"]["tls_port"] == b"0" + assert not [r for r in caplog.records if r.levelname == "ERROR"] + + def test_entry_after_nested_block_stays_in_debugging_manager(self): + da_adb = DumpsysADBArtifact() + da_adb.parse( + b"ADB MANAGER STATE (dumpsys adb):\n" + b"{\n" + b" debugging_manager={\n" + b" adb_wifi={\n" + b" enabled=false\n" + b" }\n" + b" connected_to_adb=true\n" + b" }\n" + b"}\n" + ) + + assert len(da_adb.results) == 1 + assert da_adb.results[0]["connected_to_adb"] is True + + def test_blank_line_after_single_line_value_does_not_raise(self): + # `system_keys` is the content of /adb_keys and ends with a newline, + # so a blank line follows it. It is not a multiline field, so the + # blank line is not a multiline terminator either. + da_adb = DumpsysADBArtifact() + da_adb.parse( + b"ADB MANAGER STATE (dumpsys adb):\n" + b"{\n" + b" debugging_manager={\n" + b" connected_to_adb=true\n" + b" user_keys=QUJDRA== host@example\n" + b"\n" + b" system_keys=RUZHSA== system@example\n" + b"\n" + b" }\n" + b"}\n" + ) + + assert len(da_adb.results) == 1 + assert da_adb.results[0]["user_keys"][0]["user"] == "host@example" + assert da_adb.results[0]["system_keys"] == "RUZHSA== system@example" + def test_unbalanced_state_is_reported_rather_than_raising(self): da_adb = DumpsysADBArtifact() da_adb.parse( From 10f739d66949e017db42ef2ac2455cf6c9db3fef Mon Sep 17 00:00:00 2001 From: Muhammad Mohid Shafiq Date: Thu, 8 Oct 2026 19:35:29 +0500 Subject: [PATCH 107/109] Keep settings history timestamps on 29 February dumpsys prints the settings change history without a year, so _resolve_timestamp() parsed the value with strptime("%m-%d ...") and only then replaced the year. strptime() parses a value without a year in 1900, which has no 29 February, so every change made on a leap day raised ValueError and lost its timestamp, dropping it from the timeline and from the alert of a dangerous setting. Parse the value together with a year instead, starting from the year the section was dumped and going back until the date exists and is not later than the dump. Eight years always reach a leap year. This also removes the DeprecationWarning Python 3.13 raises for parsing a day of month without a year (python/cpython#70647). --- src/mvt/android/artifacts/settings.py | 21 +++++++++------ tests/android/test_artifact_settings.py | 35 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/mvt/android/artifacts/settings.py b/src/mvt/android/artifacts/settings.py index 39257c06..8237ee34 100644 --- a/src/mvt/android/artifacts/settings.py +++ b/src/mvt/android/artifacts/settings.py @@ -255,19 +255,24 @@ class Settings(AndroidArtifact): dumpsys prints the change history without a year, so it is resolved against the time the section was dumped: the most recent matching date at or before that time. + + The year is parsed together with the value, because strptime() would + otherwise parse it in 1900, which has no 29 February. Going back eight + years always reaches a leap year. """ if section_end is None: return None - try: - partial = datetime.strptime(value, "%m-%d %H:%M:%S.%f") - timestamp = partial.replace(year=section_end.year) - if timestamp > section_end: - timestamp = partial.replace(year=section_end.year - 1) - except ValueError: - return None + for year in range(section_end.year, section_end.year - 8, -1): + try: + timestamp = datetime.strptime(f"{year}-{value}", "%Y-%m-%d %H:%M:%S.%f") + except ValueError: + continue - return convert_datetime_to_iso(timestamp) + if timestamp <= section_end: + return convert_datetime_to_iso(timestamp) + + return None def _parse_history( self, line: str, section_end: Optional[datetime] diff --git a/tests/android/test_artifact_settings.py b/tests/android/test_artifact_settings.py index 2192c12b..f114cab7 100644 --- a/tests/android/test_artifact_settings.py +++ b/tests/android/test_artifact_settings.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/ +from typing import Optional + from mvt.android.artifacts.settings import Settings from ..utils import get_artifact @@ -21,6 +23,21 @@ def find(settings: Settings, name: str) -> list: return [result for result in settings.results if result["name"] == name] +def resolve_history_time(time: str, section_end: str) -> Optional[str]: + settings = Settings() + settings.parse( + "SECURE SETTINGS (user 0)\n" + "_id:240 name:accessibility_enabled pkg:android value:1\n" + "\tHistory (accessibility_enabled)\n" + f"\t\ttime:{time} mode:update oldValue:0 newValue:1 " + "package:com.example.helper\n" + "\n" + "--------- 0.019s was the duration of dumpsys settings, " + f"ending at: {section_end}\n" + ) + return settings.results[0]["history"][0]["timestamp"] + + class TestSettingsArtifact: def test_parsing(self): settings = parse_bugreport_settings() @@ -159,6 +176,24 @@ class TestSettingsArtifact: } ] + def test_leap_day_history_keeps_its_timestamp(self): + assert ( + resolve_history_time("02-29 22:41:07.980", "2024-03-05 23:14:28") + == "2024-02-29 22:41:07.980000" + ) + + def test_leap_day_history_resolved_to_the_last_leap_year(self): + # The most recent 29 February at or before the dump can be several + # years back when the dump was taken in a year without one. + assert ( + resolve_history_time("02-29 22:41:07.980", "2025-03-01 10:00:00") + == "2024-02-29 22:41:07.980000" + ) + assert ( + resolve_history_time("02-29 22:41:07.980", "2024-01-10 10:00:00") + == "2020-02-29 22:41:07.980000" + ) + def test_dangerous_setting_is_detected_with_the_changing_package(self): settings = parse_bugreport_settings() settings.check_indicators() From 1e05f438d7c952300b646372d6f894da96358e8c Mon Sep 17 00:00:00 2001 From: besendorf Date: Fri, 9 Oct 2026 03:48:37 -0700 Subject: [PATCH 108/109] Include the eighth prior year when resolving settings leap days Adjust the year range for leap year calculation to account for century years. --- src/mvt/android/artifacts/settings.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/mvt/android/artifacts/settings.py b/src/mvt/android/artifacts/settings.py index 8237ee34..91b357d6 100644 --- a/src/mvt/android/artifacts/settings.py +++ b/src/mvt/android/artifacts/settings.py @@ -257,13 +257,14 @@ class Settings(AndroidArtifact): at or before that time. The year is parsed together with the value, because strptime() would - otherwise parse it in 1900, which has no 29 February. Going back eight - years always reaches a leap year. + otherwise parse it in 1900, which has no 29 February. The most recent + matching leap day can be eight years earlier when a century year is + not a leap year and the current year's leap day is still in the future. """ if section_end is None: return None - for year in range(section_end.year, section_end.year - 8, -1): + for year in range(section_end.year, section_end.year - 9, -1): try: timestamp = datetime.strptime(f"{year}-{value}", "%Y-%m-%d %H:%M:%S.%f") except ValueError: From aac41ec8c24b8e64571018d5bf6bfa0bf1a4c9d7 Mon Sep 17 00:00:00 2001 From: besendorf Date: Fri, 9 Oct 2026 03:49:54 -0700 Subject: [PATCH 109/109] Test settings leap-day resolution across a skipped century Ensure a February 29 history entry in a January 2104 dump resolves to 2096, since 2100 is not a leap year. --- tests/android/test_artifact_settings.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/android/test_artifact_settings.py b/tests/android/test_artifact_settings.py index f114cab7..239490a2 100644 --- a/tests/android/test_artifact_settings.py +++ b/tests/android/test_artifact_settings.py @@ -194,6 +194,14 @@ class TestSettingsArtifact: == "2020-02-29 22:41:07.980000" ) + def test_leap_day_history_before_a_leap_day_after_a_skipped_century(self): + # 2100 is not a leap year, so before 29 February 2104 the most + # recent matching date is eight years earlier, in 2096. + assert ( + resolve_history_time("02-29 22:41:07.980", "2104-01-10 10:00:00") + == "2096-02-29 22:41:07.980000" + ) + def test_dangerous_setting_is_detected_with_the_changing_package(self): settings = parse_bugreport_settings() settings.check_indicators()