Prepare documented WhatsApp injector with portable Python bundle
This commit is contained in:
commit
d2b621bce7
49 files changed
+5737
No files matched your search
@@ -0,0 +1,7 @@
|
||||
* text=auto
|
||||
*.py text eol=lf
|
||||
*.md text eol=lf
|
||||
*.cjs text eol=lf
|
||||
*.js text eol=lf
|
||||
*.cpp text eol=lf
|
||||
*.dll binary
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Python and local environments
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
# Runtime data and research output
|
||||
analysis/
|
||||
exports/
|
||||
.whatsapp-*/
|
||||
webview2-harmless-test/
|
||||
*.zip
|
||||
*.partial
|
||||
*.report.json
|
||||
*-report.json
|
||||
contacts*.json
|
||||
messages*.json*
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
# Editor and OS files
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,3 @@
|
||||
# Project instructions
|
||||
|
||||
Use `whatsapp_suite/` as canonical source; regenerate `whatsapp.py` after source changes. Run source and portable offline tests. Keep default tests free of live side effects. Do not commit exports, profiles, real message/contact fixtures, credentials or research captures. Preserve explicit self-message recipient guards and supported-build checks. Read `CONTRIBUTING.md` and `docs/VALIDATION.md` before changing native integration.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## Initial prepared project — 2026-10-11
|
||||
|
||||
- Consolidated toolkit with one CLI and a portable Python bundle.
|
||||
- Native WebView message/contact export, old-message reading and fixed self-send test.
|
||||
- Saved-export search plus optional CDP, RAM, SQLite and pairing research tools.
|
||||
- Thirty offline regression tests and documented live validation boundaries.
|
||||
- Clean Git layout excluding private exports, runtime captures and research artifacts.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Development
|
||||
|
||||
The canonical implementation is `whatsapp_suite/`. Edit it rather than the encoded payload in `whatsapp.py`. Synthetic fixtures belong in `tests/`; never use captured messages or contacts as fixtures.
|
||||
|
||||
Use Python 3.11+ and Node.js for the complete offline suite. Optional requirements are in `requirements-whatsapp-optional.txt`. Run source tests, rebuild with `python build_whatsapp_bundle.py`, and run the portable tests before committing. Check that unchanged inputs produce the same bundle hash.
|
||||
|
||||
Native sources are in `native/`; existing verified runtime DLLs are in `whatsapp_suite/assets/`. Changing native code requires rebuilding for Windows x64, updating asset hashes and conducting explicit build-specific live validation. The repository does not yet contain a reproducible native toolchain/build recipe. The Python builder packages existing DLLs and does not compile them.
|
||||
|
||||
Document compatibility changes and distinguish synthetic checks from live checks. Keep send operations explicit, fixed-recipient guards intact, and default tests free of live side effects. Inspect `git diff --cached` and the tracked file list before publication.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Licensing status
|
||||
|
||||
No open-source license has been selected for this project. This preparation does not grant a redistribution license or apply a new license to existing code or third-party assets. Before making the repository public, the owner should choose appropriate terms and review the provenance and redistribution status of the bundled DLLs and agent. WhatsApp and other product names belong to their respective owners.
|
||||
@@ -0,0 +1,70 @@
|
||||
# WhatsApp Desktop Suite
|
||||
|
||||
A Windows toolkit for reading, searching and exporting local WhatsApp Desktop data, with a portable single-file Python distribution and an editable source package.
|
||||
|
||||
The core live commands execute JavaScript inside the signed-in Desktop WebView through a bundled native bridge. They require neither CDP nor UI automation. Separate optional research commands use CDP, Frida or process-memory inspection.
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Command | Scope |
|
||||
| --- | --- | --- |
|
||||
| Contact export | `contacts-export` | Local contact rows, including distinct PN/LID records |
|
||||
| Message ZIP export | `export-zip` | Local message rows, decoded text/captions and manifest; no media downloads |
|
||||
| Old message reading | `live --suite history-read` | Five oldest local records, including records outside loaded chats |
|
||||
| Desktop search | `desktop-search` | Bounded app search through the native bridge |
|
||||
| Saved export search | `search` | Unicode-aware literal search, filtering and optional redaction |
|
||||
| Self-message test | `send-self` | One fixed message to the signed-in account, with stored-text verification |
|
||||
| Inspection and diagnostics | `inventory`, `stats`, `live` | Storage inventory, export summaries and fixed probes |
|
||||
| Optional research tools | See [CLI reference](docs/CLI.md) | CDP history export, RAM tools, SQLite probes and pairing tools |
|
||||
|
||||
## Quick start
|
||||
|
||||
Copy `whatsapp.py` into a writable folder. Run with Python 3.11 or newer:
|
||||
|
||||
```powershell
|
||||
python whatsapp.py --help
|
||||
python whatsapp.py test
|
||||
python whatsapp.py contacts-export --output contacts.json
|
||||
python whatsapp.py export-zip --output messages.zip
|
||||
python whatsapp.py live --suite history-read
|
||||
```
|
||||
|
||||
Live bridge commands require **Windows x64, 64-bit Python and WhatsApp Desktop 2.2639.100.0** with the verified WebView component. This is a version-specific integration, not a general promise of compatibility with future WhatsApp releases. The core commands need only Python's standard library. Optional tools have [additional dependencies](docs/CLI.md#optional-dependencies); Node.js enables the JavaScript fixture tests.
|
||||
|
||||
`send-self` actually sends a message. It is never part of the default tests and does not retry automatically. ZIP exports cover the local database, not necessarily every message ever received. Contacts are local table rows, not a deduplicated phone address book.
|
||||
|
||||
## Development
|
||||
|
||||
```powershell
|
||||
python -m whatsapp_suite --help
|
||||
python -m whatsapp_suite test
|
||||
python build_whatsapp_bundle.py
|
||||
python whatsapp.py test
|
||||
```
|
||||
|
||||
The builder embeds the maintained modules, DLLs and fixtures. Modules load from memory; a verified DLL is extracted only when needed. The distribution is approximately 158 KiB. Keep source and bundle changes together.
|
||||
|
||||
## Project layout
|
||||
|
||||
```text
|
||||
whatsapp.py Portable generated distribution
|
||||
whatsapp_suite/ Canonical Python implementations
|
||||
whatsapp_suite/assets/ Runtime DLLs and SQLite agent
|
||||
native/ Native helper C++ source
|
||||
tests/ Synthetic Python and JavaScript fixtures
|
||||
build_whatsapp_bundle.py Deterministic bundle builder
|
||||
docs/ Architecture, validation and publishing guide
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [CLI and export behavior](docs/CLI.md)
|
||||
- [Architecture and native bridge](docs/ARCHITECTURE.md)
|
||||
- [Validation and limitations](docs/VALIDATION.md)
|
||||
- [Development and contributions](CONTRIBUTING.md)
|
||||
- [Publishing to tea.karlcom.de](docs/PUBLISHING.md)
|
||||
- [Handling private data](SECURITY.md)
|
||||
|
||||
## Project status
|
||||
|
||||
Repository: [karl/whatsapp_injector](https://tea.karlcom.de/karl/whatsapp_injector). It is independent of WhatsApp/Meta. No open-source license has been selected; see [licensing status](LICENSE.md).
|
||||
@@ -0,0 +1,7 @@
|
||||
# Private data and security
|
||||
|
||||
Use the toolkit only with accounts and installations you are authorized to inspect. Live reports, contacts, messages, browser profiles, pairing QR payloads and process dumps can contain private data. Keep them outside Git. The ignore rules are a guardrail; review staged files before upload.
|
||||
|
||||
The native bridge executes inside the Desktop process and depends on a specific build. Use a trusted bundle. An embedded hash detects accidental modification but is not a digital signature. Do not make a CDP endpoint publicly reachable.
|
||||
|
||||
For a sensitive issue, contact the repository owner privately through an established channel. Do not attach live exports, session material or credentials to issues. For ordinary bug reports, include the command, Python/app versions and a redacted diagnostic summary.
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Build a deterministic single Python file; run this only for development."""
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import lzma
|
||||
from pathlib import Path
|
||||
import textwrap
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
BOOTSTRAP = '''#!/usr/bin/env python3
|
||||
"""Portable WhatsApp suite: python whatsapp.py --help
|
||||
|
||||
All Python modules, native helpers and regression fixtures are embedded.
|
||||
Only native DLLs are cached beside this file when a live command needs them.
|
||||
Core commands use the standard library. Legacy CDP/Frida/QR commands retain
|
||||
their optional dependencies. Live WebView probes require the supported
|
||||
WhatsApp Desktop build, Windows x64 and 64-bit Python 3.11+.
|
||||
Generated by build_whatsapp_bundle.py; edit the development sources to rebuild.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.abc
|
||||
import importlib.util
|
||||
import io
|
||||
import lzma
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
PAYLOAD_SHA256 = __DIGEST__
|
||||
PAYLOAD = (
|
||||
__PAYLOAD__
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
compressed = base64.b85decode(PAYLOAD)
|
||||
if hashlib.sha256(compressed).hexdigest() != PAYLOAD_SHA256:
|
||||
raise RuntimeError('Bundle integrity check failed')
|
||||
archive = zipfile.ZipFile(io.BytesIO(lzma.decompress(compressed)))
|
||||
members = frozenset(archive.namelist())
|
||||
launcher = Path(__file__).resolve()
|
||||
|
||||
class EmbeddedModules(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
relative = fullname.replace('.', '/')
|
||||
for name, package in ((relative + '/__init__.py', True), (relative + '.py', False)):
|
||||
if name in members:
|
||||
return importlib.util.spec_from_loader(fullname, self, is_package=package)
|
||||
return None
|
||||
|
||||
def create_module(self, spec):
|
||||
return None
|
||||
|
||||
def exec_module(self, module):
|
||||
relative = module.__name__.replace('.', '/')
|
||||
name = relative + ('/__init__.py' if module.__spec__.submodule_search_locations is not None else '.py')
|
||||
module.__file__ = str(launcher) + '!' + name
|
||||
exec(compile(archive.read(name), module.__file__, 'exec'), module.__dict__)
|
||||
|
||||
sys._whatsapp_bundle = {'base': launcher.parent, 'launcher': launcher, 'read': archive.read}
|
||||
sys.meta_path.insert(0, EmbeddedModules())
|
||||
from whatsapp_suite.cli import main as cli
|
||||
return cli()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
'''
|
||||
|
||||
|
||||
def main():
|
||||
files={p.relative_to(ROOT).as_posix():p.read_bytes() for p in sorted((ROOT/'whatsapp_suite').glob('*.py'))}
|
||||
for name in ('math_probe.dll','search_probe.dll','sqlite_inject.js'):
|
||||
files['whatsapp_suite/assets/'+name]=(ROOT/'whatsapp_suite/assets'/name).read_bytes()
|
||||
files['whatsapp_suite/_regression.py']=(ROOT/'tests/test_whatsapp_suite.py').read_bytes()
|
||||
for p in sorted((ROOT/'tests').glob('*.cjs')):
|
||||
files['tests/'+p.name]=p.read_bytes()
|
||||
# Whole-archive compression shares repeated source and native-helper bytes.
|
||||
memory=io.BytesIO()
|
||||
with zipfile.ZipFile(memory,'w',compression=zipfile.ZIP_STORED) as archive:
|
||||
for name,data in sorted(files.items()):
|
||||
entry=zipfile.ZipInfo(name,date_time=(1980,1,1,0,0,0))
|
||||
archive.writestr(entry,data)
|
||||
payload=lzma.compress(memory.getvalue(),preset=9)
|
||||
encoded=base64.b85encode(payload).decode('ascii')
|
||||
lines='\n'.join(' '+repr(line) for line in textwrap.wrap(encoded,100,break_on_hyphens=False))
|
||||
source=BOOTSTRAP.replace('__DIGEST__',repr(hashlib.sha256(payload).hexdigest())).replace('__PAYLOAD__',lines)
|
||||
destination=ROOT/'whatsapp.py'
|
||||
destination.write_text(source,encoding='utf-8',newline='\n')
|
||||
report={'bundle':destination.name,'bundle_bytes':destination.stat().st_size,'embedded_source_and_asset_bytes':sum(map(len,files.values())),'files_embedded':len(files),'sha256':hashlib.sha256(destination.read_bytes()).hexdigest()}
|
||||
(ROOT/'analysis/whatsapp-suite').mkdir(parents=True, exist_ok=True)
|
||||
(ROOT/'analysis/whatsapp-suite/bundle-build.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
print(json.dumps(report))
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,19 @@
|
||||
# Architecture
|
||||
|
||||
## Execution path
|
||||
|
||||
The portable launcher verifies and decompresses its embedded payload, then imports the Python package directly from the in-memory ZIP. It uses the same CLI and modules as `python -m whatsapp_suite`.
|
||||
|
||||
For a core live operation, Python uses Windows APIs through `ctypes`. It verifies the helper DLL and supported application build, installs a temporary `WH_GETMESSAGE` hook on the Desktop UI thread, and passes a bounded JavaScript request through shared memory. The native helper locates the existing WebView object using build-specific signatures and calls its `ExecuteScriptAsync` interface. A thread message wakes the hook. Results return through the shared mapping; the hook, mapping and process handles are then released. Reports record cleanup outcomes.
|
||||
|
||||
This still uses native C++ DLLs. Python packages their bytes; it does not replace their implementation with pure Python. The C++ source is for maintenance and is unnecessary on a machine running the portable file. SHA-256 checks verify embedded asset integrity, not publisher identity or authenticity of a downloaded file.
|
||||
|
||||
Message export uses the app's own JavaScript database getter and row decoder. It paginates a bounded range, streams decoded records into a ZIP and writes a manifest. The scan is not an atomic database snapshot. Failed decodes remain visible and cause failure status. Final publication refuses existing output and verifies ZIP CRCs.
|
||||
|
||||
Contact export reads selected public fields from the local contact table. Sending is restricted to a fixed self-message test with recipient checks and persisted plaintext readback. No general-purpose arbitrary-recipient send CLI is provided.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Live native operations support Desktop build `2.2639.100.0`. Updates may invalidate signatures and require new evidence and validation. Core bridge commands do not use CDP or UI automation; optional legacy commands have their own transports and dependencies. RAM readers only see loaded models. Exported message text does not include media file downloads. Password or passkey private-key extraction is not an implemented feature.
|
||||
|
||||
The repository includes only maintained source, synthetic fixtures and required runtime assets. Local profiles, extracted application code, credentials, messages, contacts and research dumps are excluded.
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
# WhatsApp test suite
|
||||
|
||||
## Portable single-file bundle
|
||||
|
||||
Copy **`whatsapp.py`** to another writable folder. That one Python file includes
|
||||
all tools, both native helpers and the regression fixtures. No adjacent source
|
||||
package, DLLs, C++ compiler or build script is needed to run it.
|
||||
|
||||
```powershell
|
||||
python whatsapp.py --help
|
||||
python whatsapp.py test
|
||||
python whatsapp.py live --suite history-read
|
||||
python whatsapp.py contacts-export --output contacts.json
|
||||
python whatsapp.py export-zip --output messages.zip
|
||||
python whatsapp.py send-self
|
||||
```
|
||||
|
||||
The bundle compresses the original modules and resources together with LZMA.
|
||||
It verifies its embedded payload and imports Python modules directly from memory.
|
||||
Only a native helper needed for a live command is materialized in a hash-verified
|
||||
cache beside the bundle. Keep the containing folder writable. Native helper
|
||||
bytes, build checks, batch sizes and message read/send paths are unchanged.
|
||||
|
||||
Core WebView commands use the standard library. Python 3.11+, Windows x64,
|
||||
64-bit Python and the supported WhatsApp build are still required for live
|
||||
Desktop operations. Optional legacy CDP, Frida and QR tools retain the packages
|
||||
listed below. Node.js remains optional for JavaScript fixture tests.
|
||||
|
||||
The development package below remains the editable source. Rebuild the generated
|
||||
single-file distribution after changing it:
|
||||
|
||||
```powershell
|
||||
python build_whatsapp_bundle.py
|
||||
```
|
||||
|
||||
The build is deterministic and records size and SHA-256 in
|
||||
`analysis/whatsapp-suite/bundle-build.json`. The build file is not needed on a
|
||||
machine running the bundle.
|
||||
|
||||
The WhatsApp scripts are consolidated in `whatsapp_suite/`, with one CLI and
|
||||
offline regression tests in `tests/`. Run commands from this workspace.
|
||||
|
||||
```powershell
|
||||
python -m whatsapp_suite
|
||||
python -m whatsapp_suite --help
|
||||
python -m whatsapp_suite test --output analysis/whatsapp-suite/results.json
|
||||
```
|
||||
|
||||
`python whatsapp_tests.py` is an equivalent entry point. The default run uses
|
||||
fixtures and temporary files. It verifies saved-export parsing, Unicode search,
|
||||
chat filtering, text redaction, history pagination/decryption contracts, fixed
|
||||
probe responses, command dispatch, and bundled DLL hashes. Node.js runs the
|
||||
JavaScript history fixtures; that test is reported as skipped if Node is absent.
|
||||
Python 3.11 or newer is required.
|
||||
|
||||
To test an already running supported WhatsApp Desktop installation:
|
||||
|
||||
```powershell
|
||||
python -m whatsapp_suite test --live
|
||||
python -m whatsapp_suite test --capabilities
|
||||
python -m whatsapp_suite live --suite math
|
||||
python -m whatsapp_suite live --suite capabilities
|
||||
python -m whatsapp_suite live --suite history-read --output analysis/whatsapp-suite/js-history-read.json
|
||||
python -m whatsapp_suite export-zip --output exports/whatsapp-messages.zip
|
||||
python -m whatsapp_suite contacts-export --output exports/contacts.json
|
||||
python -m whatsapp_suite send-self
|
||||
```
|
||||
|
||||
Live WebView probes require Windows x64, 64-bit Python, Desktop build
|
||||
`2.2639.100.0`, and the verified WebView2 Core DLL. They use bundled native
|
||||
helpers and check the build before installing a temporary hook. A missing or
|
||||
unsupported app produces a failed live result. The JSON report includes cleanup
|
||||
status. `--pid` selects a particular Desktop host.
|
||||
|
||||
## Tools
|
||||
|
||||
Every command supports `--help` for its options.
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `inventory` | List local Desktop storage files and sizes |
|
||||
| `stats EXPORT` | Summarize a saved JSON or JSONL export |
|
||||
| `search EXPORT QUERY` | Unicode-aware literal search of saved messages |
|
||||
| `desktop-search QUERY` | Bounded native app search through WebView2; metadata output |
|
||||
| `live --suite math\|capabilities\|all` | Fixed live WebView test suites |
|
||||
| `live --suite message-json\|contacts` | Explicit fixed message search/contact sample; saves private data |
|
||||
| `live --suite history-read` | Read the five oldest local message records through the JS database getter and decoder, without CDP |
|
||||
| `webview-test` | Arithmetic check in a separate WebView2 host; may download its SDK |
|
||||
| `analyze` | Storage analysis, optionally using an existing loopback CDP endpoint |
|
||||
| `history-export` | Export local history through Desktop's own database decryption |
|
||||
| `export-zip` | Export all local message rows to ZIP through JavaScript on WebView, without CDP or UI automation |
|
||||
| `contacts-export` | Export all local contact rows to JSON through the app's JS database getter |
|
||||
| `send-self` | Send one fixed test message to the signed-in account itself and verify stored plaintext |
|
||||
| `ram-read` | Read loaded V8 message objects from process memory |
|
||||
| `ram-search QUERY` | Search RAM models with verified collection membership |
|
||||
| `native-search [QUERY]` | Experimental injected SQLite search, `--oldest --limit 5`, or `--export-index` |
|
||||
| `qr-memory` | Recover candidate Desktop pairing QR payloads from RAM |
|
||||
| `qr-window` | Save an already displayed Desktop linking QR |
|
||||
| `pairing` | Start an isolated browser and request an official linking QR |
|
||||
|
||||
```powershell
|
||||
python -m whatsapp_suite search export.jsonl "keyword" --redact-text
|
||||
python -m whatsapp_suite desktop-search "keyword" --limit 10
|
||||
python -m whatsapp_suite history-export --help
|
||||
```
|
||||
|
||||
`history-read` uses the native WebView bridge to execute JavaScript in the running
|
||||
app. It scans at most 100,000 local record timestamps in a read-only transaction,
|
||||
then calls `WAWebSchemaMessage.getMessageTable().get` and
|
||||
`WAWebDBMessageSerialization.messageFromDbRow` for the five oldest records.
|
||||
It saves decoded body/caption text when present, including messages absent from
|
||||
the loaded conversation models. System events and media-only records can have
|
||||
no text. The live result includes page-state and native bridge cleanup status.
|
||||
This command requires neither CDP nor optional Python packages.
|
||||
|
||||
`export-zip` uses the same native bridge and the app's JavaScript database getter
|
||||
and decoder. It paginates all local rows up to the initial upper primary key,
|
||||
streams them into `messages.jsonl`, and includes a `manifest.json` with row counts,
|
||||
decoding errors, scope, and bridge cleanup status. Rows include message IDs,
|
||||
chat IDs, timestamps, types, body and caption text; media files are not downloaded.
|
||||
This is a live scan, not an atomic database snapshot. Row decoding failures are
|
||||
preserved as error records and make the command return a failure status.
|
||||
`--batch-size` controls bounded reads (default 100); `--redact-text` omits body and
|
||||
caption text. Existing outputs are refused. Interrupted exports retain a
|
||||
`.zip.partial` file and a `.zip.report.json` diagnostic report. A completed ZIP
|
||||
is checked for CRC errors before publication.
|
||||
|
||||
`contacts-export` reads the local contact table rather than the five-contact
|
||||
loaded-model sample. It exports selected identity, name, phone and business
|
||||
fields. Local records can include separate PN/LID entries and contacts outside
|
||||
the phone address book; this is not a deduplicated phone address-book export.
|
||||
|
||||
`send-self` resolves the signed-in account and checks both the selected chat and
|
||||
outgoing message recipient before invoking the app's text-send action. It sends
|
||||
one identifiable test message and reads it back through the message database
|
||||
decoder, recording ACK and exact plaintext equality in a receipt. An existing
|
||||
receipt is refused and sending is never retried automatically. Unlike the
|
||||
offline test suite, this command actually sends a WhatsApp message.
|
||||
|
||||
The separate bulk `history-export` command requires an existing Desktop CDP endpoint. RAM tools only see
|
||||
loaded objects and cannot retrieve all history. Pairing commands are separate
|
||||
from the automated test run. The `runtime` module preserves the explicit
|
||||
temporary CDP context manager for research scripts; entering that context
|
||||
restarts Desktop and is never part of the default tests.
|
||||
|
||||
## Optional dependencies
|
||||
|
||||
The default suite, saved-export tools, and native WebView probes need only the
|
||||
Python standard library (plus Node.js for the history fixture test).
|
||||
|
||||
| Tools | Additional Python packages |
|
||||
| --- | --- |
|
||||
| CDP analysis and history export | `websockets` |
|
||||
| Injected SQLite search | `frida` |
|
||||
| QR from RAM | `qrcode[pil]` |
|
||||
| QR from Desktop window | `Pillow`, `zxing-cpp` |
|
||||
| Isolated browser pairing | `websockets`, `qrcode[pil]`; installed Chrome |
|
||||
|
||||
Install all optional packages into a virtual environment with:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements-whatsapp-optional.txt
|
||||
```
|
||||
|
||||
## Layout and migration
|
||||
|
||||
- `whatsapp_suite/`: canonical tool implementations, common normalization,
|
||||
storage paths, Desktop discovery, CLI, and test runner.
|
||||
- `whatsapp_suite/assets/`: verified native DLLs and the native SQLite agent.
|
||||
- `tests/`: synthetic Python regressions and the JavaScript history fixture.
|
||||
- `analysis/whatsapp-suite/results.json`: latest automated test result.
|
||||
|
||||
The former `whatsapp_tool.py` commands now use `python -m whatsapp_suite`;
|
||||
`whatsapp-search` is named `desktop-search`, and `whatsapp-test` is
|
||||
`live --suite math`. The former `whatsapp_math.py` options are available under
|
||||
`live`. Archived scripts have become package modules; the identical Desktop QR
|
||||
RAM scripts have one implementation (`qr_memory`). Research helpers now import
|
||||
these modules. Old script archive and portable script copies were removed.
|
||||
The clean Git project excludes research reports, extracted application code and browser profiles. The surrounding original workspace retains those local files.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Publish to tea.karlcom.de
|
||||
|
||||
This directory is the clean repository root. The surrounding research workspace is not the repository and must not be uploaded.
|
||||
|
||||
1. The configured repository is `https://tea.karlcom.de/karl/whatsapp_injector.git`.
|
||||
2. Copy the SSH or HTTPS clone URL supplied by the server. Use your existing credential manager or SSH configuration; never embed tokens in the URL.
|
||||
3. From this directory, inspect the tracked file list and configure the remote:
|
||||
|
||||
```powershell
|
||||
git ls-files
|
||||
git remote add origin https://tea.karlcom.de/karl/whatsapp_injector.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
The owner and remote URL were supplied by Karl. Repository visibility is controlled on the server. Never push the surrounding research workspace.
|
||||
|
||||
For a release, rebuild and test the bundle, then attach only `whatsapp.py` and a SHA-256 checksum to a server release. State the supported Desktop build and optional dependencies. Do not attach test exports or research reports. No server-specific CI workflow is included because runner availability and configuration have not been verified.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Validation
|
||||
|
||||
Observed on 2026-10-11 in the original Windows development workspace. These observations describe that installation; they do not certify every command on every machine.
|
||||
|
||||
| Check | Result | Scope |
|
||||
| --- | --- | --- |
|
||||
| Portable offline regression suite | 30 tests passed, no failures/errors/skips | Isolated folder, Python `-I`, no adjacent source/assets |
|
||||
| Portable contact export | Passed | 313 local contact rows |
|
||||
| Portable old-message reading | Passed | 9,741 timestamp records scanned; five oldest records decoded |
|
||||
| Portable native cleanup | Passed | Hook removed, handles released, helper unloaded |
|
||||
| Deterministic build | Passed | Repeated build produced identical SHA-256 |
|
||||
| Message ZIP export before bundling | Passed | 9,740 records, zero decode errors; plaintext verified |
|
||||
| Self-message before bundling | Passed | One message sent; ACK 3 and exact persisted plaintext readback |
|
||||
|
||||
Every CLI help path is covered by regression tests. Synthetic fixtures cover parsing, normalization/search, export contracts, recipient guarding and helper cache integrity. Not every optional or live feature was rerun from the final bundle. Default tests do not send messages, link accounts or run live probes. JavaScript fixture checks can be skipped when Node.js is absent; inspect the report.
|
||||
|
||||
Run `python whatsapp.py test` for portable checks, or `python -m whatsapp_suite test` for source checks. Use `--help` before explicit live operations. Test reports and export artifacts are generated locally and ignored by Git. Do not commit live test output.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"note": "Runtime asset hashes; integrity only, not a publisher signature",
|
||||
"sha256": {
|
||||
"math_probe.dll": "136620d96f4745a4c41edfe1f4a9272ef25fc060ddace1c0eec1027bf252e02a",
|
||||
"search_probe.dll": "911beb9f6bb52390cde8a75562ca1d6c46f4629f641426a5250286903de60895",
|
||||
"sqlite_inject.js": "f31f31e170d30c0c845a3759db631d084b0bb096f1b654a9d9168a1078daed3f"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <inspectable.h>
|
||||
#include <winstring.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <wchar.h>
|
||||
struct Packet { volatile LONG state; LONG hr; DWORD thread; DWORD protocol;
|
||||
wchar_t script[8192]; wchar_t result[32768]; };
|
||||
static const UINT Wake = WM_APP + 0x498;
|
||||
static Packet* packet=nullptr;
|
||||
static HANDLE mapping=nullptr;
|
||||
static IUnknown* core=nullptr;
|
||||
static IUnknown* operation=nullptr;
|
||||
static IUnknown* info=nullptr;
|
||||
static const GUID CoreIID={0x3a3f559a,0xe5e9,0x5338,{0xbb,0x67,0x4e,0xb0,0x50,0x4a,0x4f,0x14}};
|
||||
static const GUID InfoIID={0x00000036,0,0,{0xc0,0,0,0,0,0,0,0x46}};
|
||||
static void** table(void* p) { return *(void***)p; }
|
||||
static HRESULT finishAsync() {
|
||||
HRESULT outcome=S_OK;
|
||||
if(info) {
|
||||
using Status=HRESULT(STDMETHODCALLTYPE*)(void*,INT32*);
|
||||
using Action=HRESULT(STDMETHODCALLTYPE*)(void*);
|
||||
INT32 status=0;
|
||||
HRESULT hr=((Status)table(info)[7])(info,&status);
|
||||
if(SUCCEEDED(hr) && status==0) {
|
||||
hr=((Action)table(info)[9])(info); // IAsyncInfo.Cancel
|
||||
if(FAILED(hr))outcome=hr;
|
||||
}
|
||||
hr=((Action)table(info)[10])(info); // IAsyncInfo.Close
|
||||
if(FAILED(hr) && SUCCEEDED(outcome))outcome=hr;
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
static void clearAsync() {
|
||||
if(info){info->Release();info=nullptr;}
|
||||
if(operation){operation->Release();operation=nullptr;}
|
||||
}
|
||||
static void fail(HRESULT hr) { clearAsync();packet->hr=hr;InterlockedExchange(&packet->state,-1); }
|
||||
static HRESULT findCore() {
|
||||
auto module=(uintptr_t)GetModuleHandleW(L"Microsoft.Web.WebView2.Core.dll");
|
||||
if(!module)return HRESULT_FROM_WIN32(ERROR_MOD_NOT_FOUND);
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t address=0,scanned=0;
|
||||
while(VirtualQuery((void*)address,&mbi,sizeof(mbi))) {
|
||||
uintptr_t end=(uintptr_t)mbi.BaseAddress+mbi.RegionSize;
|
||||
if(end<=address)break;
|
||||
address=end;
|
||||
if(mbi.State!=MEM_COMMIT||mbi.Type!=MEM_PRIVATE||mbi.Protect!=PAGE_READWRITE||
|
||||
mbi.RegionSize>64*1024*1024)continue;
|
||||
if(scanned+mbi.RegionSize>256*1024*1024)break;
|
||||
scanned+=mbi.RegionSize;
|
||||
for(uintptr_t p=(uintptr_t)mbi.BaseAddress;p+0x118<=end;p+=8) {
|
||||
if(*(uintptr_t*)p!=module+0xa6ef0 || *(uintptr_t*)(p+0x108)!=module+0xa6eb0)continue;
|
||||
if(table((void*)p)[0]!=(void*)(module+0x5ac50))continue;
|
||||
auto hr=((IUnknown*)p)->QueryInterface(CoreIID,(void**)&core);
|
||||
if(FAILED(hr))continue;
|
||||
if(table(core)[49]!=(void*)(module+0x59060)) { core->Release();core=nullptr;continue; }
|
||||
return S_OK;
|
||||
}
|
||||
}
|
||||
return HRESULT_FROM_WIN32(ERROR_NOT_FOUND);
|
||||
}
|
||||
static void runProbe() {
|
||||
if(!packet) {
|
||||
wchar_t name[96];swprintf_s(name,L"Local\\WhatsAppMathProbe-v2-%lu",GetCurrentProcessId());
|
||||
mapping=OpenFileMappingW(FILE_MAP_ALL_ACCESS,FALSE,name);
|
||||
if(!mapping)return;
|
||||
packet=(Packet*)MapViewOfFile(mapping,FILE_MAP_ALL_ACCESS,0,0,sizeof(Packet));
|
||||
if(!packet){CloseHandle(mapping);mapping=nullptr;return;}
|
||||
}
|
||||
if(packet->protocol!=0x4d415432){fail(E_INVALIDARG);return;}
|
||||
if(packet->thread!=GetCurrentThreadId()) { fail(RPC_E_WRONG_THREAD);return; }
|
||||
LONG state=packet->state;
|
||||
if(state==3) {InterlockedExchange(&packet->state,4);return;}
|
||||
if(state==6) {
|
||||
packet->hr=finishAsync();clearAsync();if(core){core->Release();core=nullptr;}
|
||||
InterlockedExchange(&packet->state,7);
|
||||
UnmapViewOfFile(packet);packet=nullptr;CloseHandle(mapping);mapping=nullptr;
|
||||
return;
|
||||
}
|
||||
if(state==0) {
|
||||
if(!core){auto hr=findCore();if(FAILED(hr)){fail(hr);return;}}
|
||||
HSTRING text=nullptr;
|
||||
auto hr=WindowsCreateString(packet->script,(UINT32)wcsnlen_s(packet->script,8192),&text);
|
||||
if(FAILED(hr)){fail(hr);return;}
|
||||
using Execute=HRESULT(STDMETHODCALLTYPE*)(void*,HSTRING,IUnknown**);
|
||||
hr=((Execute)table(core)[49])(core,text,&operation);
|
||||
WindowsDeleteString(text);
|
||||
if(FAILED(hr)){fail(hr);return;}
|
||||
hr=operation->QueryInterface(InfoIID,(void**)&info);
|
||||
if(FAILED(hr)){fail(hr);return;}
|
||||
InterlockedExchange(&packet->state,1);
|
||||
}
|
||||
if(packet->state==1) {
|
||||
using Status=HRESULT(STDMETHODCALLTYPE*)(void*,INT32*);
|
||||
INT32 status=0;auto hr=((Status)table(info)[7])(info,&status);
|
||||
if(FAILED(hr)){fail(hr);return;}
|
||||
if(status==0)return;
|
||||
if(status!=1){fail(E_ABORT);return;}
|
||||
using Results=HRESULT(STDMETHODCALLTYPE*)(void*,HSTRING*);
|
||||
HSTRING value=nullptr;hr=((Results)table(operation)[8])(operation,&value);
|
||||
if(FAILED(hr)){fail(hr);return;}
|
||||
UINT32 length=0;auto raw=WindowsGetStringRawBuffer(value,&length);
|
||||
if(length>=32768){WindowsDeleteString(value);fail(HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER));return;}
|
||||
wmemcpy(packet->result,raw,length);packet->result[length]=0;
|
||||
WindowsDeleteString(value);finishAsync();clearAsync();packet->hr=S_OK;
|
||||
InterlockedExchange(&packet->state,2);
|
||||
}
|
||||
}
|
||||
extern "C" __declspec(dllexport) LRESULT CALLBACK ProbeHook(int code,WPARAM wParam,LPARAM lParam) {
|
||||
if(code>=0 && wParam==PM_REMOVE && ((MSG*)lParam)->message==Wake) {
|
||||
((MSG*)lParam)->message=WM_NULL;
|
||||
__try {runProbe();}
|
||||
__except(EXCEPTION_EXECUTE_HANDLER) {
|
||||
if(packet){packet->hr=(LONG)GetExceptionCode();InterlockedExchange(&packet->state,-1);}
|
||||
}
|
||||
}
|
||||
return CallNextHookEx(nullptr,code,wParam,lParam);
|
||||
}
|
||||
BOOL WINAPI DllMain(HINSTANCE,DWORD,LPVOID) {return TRUE;}
|
||||
@@ -0,0 +1,104 @@
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <inspectable.h>
|
||||
#include <winstring.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <wchar.h>
|
||||
struct Packet { volatile LONG state; LONG hr; DWORD thread; DWORD reserved;
|
||||
wchar_t script[8192]; wchar_t result[32768]; };
|
||||
static const UINT Wake = WM_APP + 0x497;
|
||||
static Packet* packet=nullptr;
|
||||
static HANDLE mapping=nullptr;
|
||||
static IUnknown* core=nullptr;
|
||||
static IUnknown* operation=nullptr;
|
||||
static IUnknown* info=nullptr;
|
||||
static const GUID CoreIID={0x3a3f559a,0xe5e9,0x5338,{0xbb,0x67,0x4e,0xb0,0x50,0x4a,0x4f,0x14}};
|
||||
static const GUID InfoIID={0x00000036,0,0,{0xc0,0,0,0,0,0,0,0x46}};
|
||||
static void** table(void* p) { return *(void***)p; }
|
||||
static void clearAsync() {
|
||||
if(info){info->Release();info=nullptr;}
|
||||
if(operation){operation->Release();operation=nullptr;}
|
||||
}
|
||||
static void fail(HRESULT hr) { clearAsync();packet->hr=hr;InterlockedExchange(&packet->state,-1); }
|
||||
static HRESULT findCore() {
|
||||
auto module=(uintptr_t)GetModuleHandleW(L"Microsoft.Web.WebView2.Core.dll");
|
||||
if(!module)return HRESULT_FROM_WIN32(ERROR_MOD_NOT_FOUND);
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t address=0,scanned=0;
|
||||
while(VirtualQuery((void*)address,&mbi,sizeof(mbi))) {
|
||||
uintptr_t end=(uintptr_t)mbi.BaseAddress+mbi.RegionSize;
|
||||
if(end<=address)break;
|
||||
address=end;
|
||||
if(mbi.State!=MEM_COMMIT||mbi.Type!=MEM_PRIVATE||mbi.Protect!=PAGE_READWRITE||
|
||||
mbi.RegionSize>64*1024*1024)continue;
|
||||
if(scanned+mbi.RegionSize>256*1024*1024)break;
|
||||
scanned+=mbi.RegionSize;
|
||||
for(uintptr_t p=(uintptr_t)mbi.BaseAddress;p+0x118<=end;p+=8) {
|
||||
if(*(uintptr_t*)p!=module+0xa6ef0 || *(uintptr_t*)(p+0x108)!=module+0xa6eb0)continue;
|
||||
if(table((void*)p)[0]!=(void*)(module+0x5ac50))continue;
|
||||
auto hr=((IUnknown*)p)->QueryInterface(CoreIID,(void**)&core);
|
||||
if(FAILED(hr))continue;
|
||||
if(table(core)[49]!=(void*)(module+0x59060)) { core->Release();core=nullptr;continue; }
|
||||
return S_OK;
|
||||
}
|
||||
}
|
||||
return HRESULT_FROM_WIN32(ERROR_NOT_FOUND);
|
||||
}
|
||||
static void runProbe() {
|
||||
if(!packet) {
|
||||
wchar_t name[96];swprintf_s(name,L"Local\\WhatsAppLocalProbe-%lu",GetCurrentProcessId());
|
||||
mapping=OpenFileMappingW(FILE_MAP_ALL_ACCESS,FALSE,name);
|
||||
if(!mapping)return;
|
||||
packet=(Packet*)MapViewOfFile(mapping,FILE_MAP_ALL_ACCESS,0,0,sizeof(Packet));
|
||||
if(!packet){CloseHandle(mapping);mapping=nullptr;return;}
|
||||
}
|
||||
if(packet->thread!=GetCurrentThreadId()) { fail(RPC_E_WRONG_THREAD);return; }
|
||||
LONG state=packet->state;
|
||||
if(state==6) {
|
||||
clearAsync();if(core){core->Release();core=nullptr;}
|
||||
InterlockedExchange(&packet->state,7);
|
||||
UnmapViewOfFile(packet);packet=nullptr;CloseHandle(mapping);mapping=nullptr;
|
||||
return;
|
||||
}
|
||||
if(state==0) {
|
||||
if(!core){auto hr=findCore();if(FAILED(hr)){fail(hr);return;}}
|
||||
HSTRING text=nullptr;
|
||||
auto hr=WindowsCreateString(packet->script,(UINT32)wcsnlen_s(packet->script,8192),&text);
|
||||
if(FAILED(hr)){fail(hr);return;}
|
||||
using Execute=HRESULT(STDMETHODCALLTYPE*)(void*,HSTRING,IUnknown**);
|
||||
hr=((Execute)table(core)[49])(core,text,&operation);
|
||||
WindowsDeleteString(text);
|
||||
if(FAILED(hr)){fail(hr);return;}
|
||||
hr=operation->QueryInterface(InfoIID,(void**)&info);
|
||||
if(FAILED(hr)){fail(hr);return;}
|
||||
InterlockedExchange(&packet->state,1);
|
||||
}
|
||||
if(packet->state==1) {
|
||||
using Status=HRESULT(STDMETHODCALLTYPE*)(void*,INT32*);
|
||||
INT32 status=0;auto hr=((Status)table(info)[7])(info,&status);
|
||||
if(FAILED(hr)){fail(hr);return;}
|
||||
if(status==0)return;
|
||||
if(status!=1){fail(E_ABORT);return;}
|
||||
using Results=HRESULT(STDMETHODCALLTYPE*)(void*,HSTRING*);
|
||||
HSTRING value=nullptr;hr=((Results)table(operation)[8])(operation,&value);
|
||||
if(FAILED(hr)){fail(hr);return;}
|
||||
UINT32 length=0;auto raw=WindowsGetStringRawBuffer(value,&length);
|
||||
if(length>=32768){WindowsDeleteString(value);fail(HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER));return;}
|
||||
wmemcpy(packet->result,raw,length);packet->result[length]=0;
|
||||
WindowsDeleteString(value);clearAsync();packet->hr=S_OK;
|
||||
InterlockedExchange(&packet->state,2);
|
||||
}
|
||||
}
|
||||
extern "C" __declspec(dllexport) LRESULT CALLBACK ProbeHook(int code,WPARAM wParam,LPARAM lParam) {
|
||||
if(code>=0 && wParam==PM_REMOVE && ((MSG*)lParam)->message==Wake) {
|
||||
((MSG*)lParam)->message=WM_NULL;
|
||||
__try {runProbe();}
|
||||
__except(EXCEPTION_EXECUTE_HANDLER) {
|
||||
if(packet){packet->hr=(LONG)GetExceptionCode();InterlockedExchange(&packet->state,-1);}
|
||||
}
|
||||
}
|
||||
return CallNextHookEx(nullptr,code,wParam,lParam);
|
||||
}
|
||||
BOOL WINAPI DllMain(HINSTANCE,DWORD,LPVOID) {return TRUE;}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Optional live CDP, native SQLite, and pairing tools.
|
||||
websockets
|
||||
frida
|
||||
qrcode[pil]
|
||||
Pillow
|
||||
zxing-cpp
|
||||
@@ -0,0 +1,62 @@
|
||||
// Fixture tests for pagination, decryption errors, and plaintext redaction.
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const fs = require('node:fs');
|
||||
const probe = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
const rows = ['a','b','c','d','e'].map(id => ({id, body:'text-'+id, type:'chat', t:123, invisible:id==='b'}));
|
||||
const events = [];
|
||||
function req(result) {
|
||||
const r = {};
|
||||
queueMicrotask(() => {r.result=result; r.onsuccess();});
|
||||
return r;
|
||||
}
|
||||
const store = {
|
||||
count:()=>req(rows.length),
|
||||
openKeyCursor(range,direction) {
|
||||
const selected=rows.filter(r=>!range || ((range.lower===undefined || r.id>range.lower) && r.id<=range.upper));
|
||||
if(direction==='prev')selected.reverse();
|
||||
const request={};let index=0;
|
||||
function next(){queueMicrotask(()=>{
|
||||
request.result=index<selected.length?{primaryKey:selected[index++].id,continue:next}:null;
|
||||
request.onsuccess();
|
||||
});}
|
||||
next();return request;
|
||||
}
|
||||
};
|
||||
const db={objectStoreNames:{contains:n=>n==='message'},transaction:(name,mode)=>{
|
||||
assert.equal(name,'message');assert.equal(mode,'readonly');return {objectStore:()=>store};
|
||||
},close:()=>events.push('close')};
|
||||
const modules={
|
||||
WAWebSchemaMessage:{getMessageTable:()=>({get:async key=>{
|
||||
events.push('decrypt:'+key);
|
||||
if(key==='c')throw Error('simulated unavailable decryption key');
|
||||
return {...rows.find(r=>r.id===key)};
|
||||
}})},
|
||||
WAWebDBMessageSerialization:{messageFromDbRow:row=>{
|
||||
events.push('decode:'+row.id);
|
||||
return {...row,id:{id:row.id,remote:'test-chat',fromMe:false}};
|
||||
}}
|
||||
};
|
||||
async function run(options){return vm.runInNewContext(probe.replace('__OPTIONS__',JSON.stringify(options)),{
|
||||
location:{origin:'https://web.whatsapp.com'}, require:n=>modules[n],
|
||||
indexedDB:{databases:async()=>[{name:'model-storage'}],open:()=>req(db)},
|
||||
IDBKeyRange:{upperBound:upper=>({upper}),bound:(lower,upper,openLower,openUpper)=>{
|
||||
assert.equal(openLower,true);assert.equal(openUpper,false);return {lower,upper};
|
||||
}}
|
||||
});}
|
||||
(async()=>{
|
||||
const bounds=await run({mode:'bounds'});assert.equal(bounds.count,5);assert.equal(bounds.upper,'e');
|
||||
let after=null,all=[];
|
||||
while(after!=='e'){
|
||||
const page=await run({mode:'batch',upper:'e',after,batch:2,redact:false});
|
||||
assert.ok(page.keys.length<=2);all.push(...page.messages);after=page.last;
|
||||
}
|
||||
assert.deepEqual(all.map(m=>m.id),['a','b','c','d','e']); // UI-invisible b remains included.
|
||||
assert.equal(all[2].error,'row_decryption_or_decoding_failed');
|
||||
assert.equal(all[1].body,'text-b');
|
||||
const redacted=await run({mode:'batch',upper:'b',after:null,batch:2,redact:true});
|
||||
assert.ok(redacted.messages.every(m=>!('body' in m)&&!('caption' in m)));
|
||||
assert.equal(redacted.messages[0].body_length,6);
|
||||
assert.ok(events.indexOf('decrypt:a')<events.indexOf('decode:a'));
|
||||
console.log('PASS: bounded pagination, upper bound, invisible rows, row errors, decryption order, redaction');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
@@ -0,0 +1,41 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const fs = require('node:fs');
|
||||
const probe = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
const rows = [90, 20, 60, 10, 70, 30, 40, 0].map((t, i) => ({id: String(i), t}));
|
||||
const reads = [];
|
||||
let closed = false;
|
||||
const store = {openCursor() {
|
||||
const request = {}; let index = 0;
|
||||
function advance() {queueMicrotask(() => {
|
||||
const row = rows[index++];
|
||||
request.result = row ? {primaryKey: row.id, value: row, continue: advance} : null;
|
||||
request.onsuccess();
|
||||
});}
|
||||
advance(); return request;
|
||||
}};
|
||||
const db = {transaction(name, mode) {
|
||||
assert.equal(name, 'message'); assert.equal(mode, 'readonly');
|
||||
return {objectStore: () => store};
|
||||
}, close: () => {closed = true;}};
|
||||
const modules = {
|
||||
WAWebSchemaMessage: {getMessageTable: () => ({get: async key => {
|
||||
reads.push(key); return rows.find(r => r.id === key);
|
||||
}})},
|
||||
WAWebDBMessageSerialization: {messageFromDbRow: row => ({...row, type: 'chat', body: 'fixture-' + row.id})},
|
||||
};
|
||||
(async () => {
|
||||
const result = await vm.runInNewContext(probe, {
|
||||
location: {origin: 'https://web.whatsapp.com'}, require: n => modules[n],
|
||||
indexedDB: {databases: async () => [{name: 'model-storage'}], open: () => {
|
||||
const request = {}; queueMicrotask(() => {request.result = db; request.onsuccess();}); return request;
|
||||
}},
|
||||
});
|
||||
assert.deepEqual(Array.from(result.messages, m => m.timestamp), [10, 20, 30, 40, 60]);
|
||||
assert.equal(result.scanned, 8);
|
||||
assert.equal(reads.length, 5);
|
||||
assert.ok(result.messages.every(m => m.body.startsWith('fixture-')));
|
||||
assert.equal(result.cdp, false);
|
||||
assert.equal(closed, true);
|
||||
console.log('PASS: oldest selection, bounded app decryption, decoded text, readonly transaction, cleanup');
|
||||
})().catch(error => {console.error(error); process.exitCode = 1;});
|
||||
@@ -0,0 +1,36 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const vm=require('node:vm');
|
||||
const fs=require('node:fs');
|
||||
const probes=JSON.parse(fs.readFileSync(process.argv[2],'utf8'));
|
||||
const me={toString:()=> 'self@lid'};
|
||||
const other={toString:()=> 'other@lid'};
|
||||
let sends=0;
|
||||
const data={id:{remote:me,toString:()=> 'self-message'},body:'fixture'};
|
||||
const chat={id:me,msgs:{get:()=>({ack:3})}};
|
||||
const modules={
|
||||
WAWebUserPrefsMeUser:{getMaybeMeLidUser:()=>me,isMeAccount:v=>v===me},
|
||||
WAWebFindChatAction:{findOrCreateLatestChat:async()=>({chat})},
|
||||
WAWebSendTextMsgChatAction:{createTextMsgData:async()=>data,addAndSendTextMsg:async()=>{sends++;}},
|
||||
WAWebSchemaMessage:{getMessageTable:()=>({get:async()=>({body:'fixture',t:123})})},
|
||||
WAWebDBMessageSerialization:{messageFromDbRow:v=>v},
|
||||
WAWebSchemaContact_DO_NOT_USE_DIRECTLY:{getContactTable:()=>({all:async()=>[
|
||||
{id:'a@lid',name:'Grüße 😺',phoneNumber:'123',unexpectedSecret:{value:'never export'}},
|
||||
{id:'b@lid',name:null,isBusiness:true},
|
||||
]})},
|
||||
};
|
||||
const run=code=>vm.runInNewContext(code,{require:name=>modules[name]});
|
||||
(async()=>{
|
||||
const good=await run(probes.send);
|
||||
assert.equal(sends,1);assert.equal(good.persisted,true);assert.equal(good.plaintext_matches,true);
|
||||
chat.id=other;
|
||||
await assert.rejects(run(probes.send),/Refusing to send outside own account/);
|
||||
assert.equal(sends,1);
|
||||
chat.id=me;data.id.remote=other;
|
||||
await assert.rejects(run(probes.send),/not addressed to self/);
|
||||
assert.equal(sends,1);
|
||||
const contacts=await run(probes.contacts);
|
||||
assert.equal(contacts.count,2);assert.equal(contacts.contacts[0].name,'Grüße 😺');
|
||||
assert.equal('unexpectedSecret' in contacts.contacts[0],false);
|
||||
assert.equal(contacts.cdp,false);assert.equal(contacts.ui_automation,false);
|
||||
console.log('PASS: send once, own-chat guard, own-recipient guard, plaintext readback, contact fields');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
@@ -0,0 +1,291 @@
|
||||
import argparse
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from whatsapp_suite import exports, history, live, qr_memory, ram_search
|
||||
from whatsapp_suite.common import BASE, asset_bytes, resource_bytes, cli_command
|
||||
from whatsapp_suite.analyzer import endpoint
|
||||
|
||||
|
||||
class SavedExportTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.folder = Path(self.temp.name)
|
||||
self.rows = [
|
||||
{'id': '1', 'chat_id': 'a', 'body': 'Cafe\u0301 STRASSE', 'type': 'chat', 'nested': {'secret': 'private'}},
|
||||
{'id': '2', 'chatId': 'b', 'caption': 'CAFÉ Straße', 'type': 'image'},
|
||||
{'id': '3', 'chat_id': 'a', 'indexed_text': 'other', 'type': 'chat'},
|
||||
]
|
||||
self.path = self.folder / 'messages.json'
|
||||
self.path.write_text(json.dumps(self.rows), encoding='utf-8')
|
||||
|
||||
def invoke(self, *args):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
self.assertEqual(exports.main(list(args)), 0)
|
||||
return json.loads(out.getvalue())
|
||||
|
||||
def test_json_report_wrappers(self):
|
||||
for key in ('messages', 'models', 'results'):
|
||||
self.path.write_text(json.dumps({key: self.rows}), encoding='utf-8')
|
||||
self.assertEqual(list(exports.messages(self.path)), self.rows)
|
||||
|
||||
def test_jsonl_bom_and_blank_lines(self):
|
||||
path = self.folder / 'messages.jsonl'
|
||||
path.write_text('\n' + '\n\n'.join(json.dumps(r) for r in self.rows), encoding='utf-8-sig')
|
||||
self.assertEqual(list(exports.messages(path)), self.rows)
|
||||
|
||||
def test_malformed_jsonl_reports_line(self):
|
||||
path = self.folder / 'broken.jsonl'
|
||||
path.write_text('{}\ninvalid', encoding='utf-8')
|
||||
with self.assertRaisesRegex(ValueError, 'line 2'):
|
||||
list(exports.messages(path))
|
||||
|
||||
def test_nonobject_records_rejected(self):
|
||||
for data in ([None], {'messages': [42]}, 'wrong', {}):
|
||||
self.path.write_text(json.dumps(data), encoding='utf-8')
|
||||
with self.assertRaises(ValueError):
|
||||
list(exports.messages(self.path))
|
||||
|
||||
def test_unicode_search_and_total_beyond_limit(self):
|
||||
result = self.invoke('search', str(self.path), 'café straße', '--limit', '1')
|
||||
self.assertEqual((result['matches'], result['returned']), (2, 1))
|
||||
|
||||
def test_chat_filter_and_nested_redaction(self):
|
||||
result = self.invoke('search', str(self.path), 'café', '--chat-id', 'a', '--redact-text')
|
||||
self.assertEqual(result['matches'], 1)
|
||||
row = result['results'][0]
|
||||
self.assertEqual(row['id'], '1')
|
||||
self.assertNotIn('body', row)
|
||||
self.assertNotIn('nested', row)
|
||||
self.assertGreater(row['text_length'], 0)
|
||||
|
||||
def test_stats_understands_both_chat_id_fields(self):
|
||||
result = self.invoke('stats', str(self.path))
|
||||
self.assertEqual((result['messages'], result['messages_with_text'], result['chats']), (3, 3, 2))
|
||||
self.assertEqual(result['types'], {'chat': 2, 'image': 1})
|
||||
|
||||
def test_output_cannot_overwrite_input(self):
|
||||
before = self.path.read_bytes()
|
||||
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
|
||||
exports.main(['stats', str(self.path), '--output', str(self.path)])
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_empty_query_and_invalid_limit_rejected(self):
|
||||
for tail in ([' '], ['café', '--limit', '0']):
|
||||
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
|
||||
exports.main(['search', str(self.path)] + tail)
|
||||
|
||||
def test_inventory_empty_and_existing_profile(self):
|
||||
self.assertFalse(exports.inventory(self.folder / 'absent')['exists'])
|
||||
store = self.folder / 'Local Storage/leveldb'
|
||||
store.mkdir(parents=True)
|
||||
(store / 'sample.ldb').write_bytes(b'123')
|
||||
report = exports.inventory(self.folder)
|
||||
self.assertEqual(report['storage_inventory'][0]['files'], [{'name': 'sample.ldb', 'bytes': 3}])
|
||||
|
||||
|
||||
class ProbeContractTests(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node.js is needed for JavaScript fixtures')
|
||||
def test_self_message_guards_and_contact_export(self):
|
||||
from whatsapp_suite.self_message import PROBE as send
|
||||
from whatsapp_suite.contacts_export import PROBE as contacts
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
probe=Path(folder)/'probes.json'
|
||||
probe.write_text(json.dumps({'send':send.replace('__BODY__',json.dumps('fixture')),'contacts':contacts}),encoding='utf-8')
|
||||
result=subprocess.run(['node','-',str(probe)],input=resource_bytes('tests/self_and_contacts.cjs').decode('utf-8'),capture_output=True,text=True,encoding='utf-8',timeout=20)
|
||||
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
|
||||
|
||||
def test_self_send_refuses_existing_receipt_without_retry(self):
|
||||
from whatsapp_suite.self_message import send_self
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path=Path(folder)/'receipt.json'
|
||||
path.write_text('original',encoding='utf-8')
|
||||
with patch('whatsapp_suite.self_message.run_math') as bridge:
|
||||
with self.assertRaises(ValueError):send_self(path)
|
||||
bridge.assert_not_called()
|
||||
self.assertEqual(path.read_text(),'original')
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node.js is needed for JavaScript fixtures')
|
||||
def test_oldest_history_read_without_cdp(self):
|
||||
from whatsapp_suite.history_read import PROBE
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
probe = Path(folder) / 'probe.json'
|
||||
probe.write_text(json.dumps(PROBE), encoding='utf-8')
|
||||
result = subprocess.run(['node', '-', str(probe)], input=resource_bytes('tests/history_read.cjs').decode('utf-8'), capture_output=True, text=True, encoding='utf-8', timeout=20)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_ram_search_requires_verified_membership(self):
|
||||
rows = [{'body': 'café', 'chat_id': 'a', 'in_message_collection': valid} for valid in (True, False)]
|
||||
result = ram_search.search_models(rows, 'CAFE\u0301', chat_id='a', redact=True)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertNotIn('body', result[0])
|
||||
self.assertEqual(result[0]['body_length'], 4)
|
||||
self.assertEqual(ram_search.search_models(rows, 'café', chat_id='b'), [])
|
||||
with self.assertRaises(ValueError):
|
||||
ram_search.search_models(rows, ' ')
|
||||
|
||||
def test_loopback_endpoint_validation(self):
|
||||
self.assertEqual(endpoint('http://127.0.0.1:9223/'), 'http://127.0.0.1:9223')
|
||||
self.assertEqual(endpoint('http://[::1]:9223'), 'http://[::1]:9223')
|
||||
for url in ('https://example.com', 'file:///tmp/x', 'http://localhost:9223', 'http://user:[email protected]', 'http://127.0.0.1?x=1'):
|
||||
with self.assertRaises(argparse.ArgumentTypeError):
|
||||
endpoint(url)
|
||||
|
||||
def test_native_assets_match_verified_hashes(self):
|
||||
from whatsapp_suite.native_search import EMBEDDED_PROBE_SHA256
|
||||
for filename, expected in (('math_probe.dll', live.EMBEDDED_SHA256), ('search_probe.dll', EMBEDDED_PROBE_SHA256)):
|
||||
payload = asset_bytes(filename)
|
||||
self.assertEqual(payload[:2], b'MZ')
|
||||
self.assertEqual(hashlib.sha256(payload).hexdigest(), expected)
|
||||
|
||||
def test_math_response_binding_and_float_tolerance(self):
|
||||
data = {'probe_id': 'test', 'cases': [{'expression': expression, 'value': expected} for expression, expected in live.CASES]}
|
||||
self.assertTrue(all(row['passed'] for row in live.validate_result(json.dumps(data), 'test')))
|
||||
with self.assertRaises(ValueError):
|
||||
live.validate_result(json.dumps(data), 'other')
|
||||
data['cases'][0]['value'] = -1
|
||||
self.assertFalse(live.validate_result(json.dumps(data), 'test')[0]['passed'])
|
||||
|
||||
def test_capability_response_must_match_fixed_checks(self):
|
||||
data = {'probe_id': 'test', 'state': 'done', 'checks': [{'id': spec[0]} for spec in live.CAPABILITIES]}
|
||||
self.assertEqual(live.validate_capabilities(data, 'test'), data['checks'])
|
||||
data['checks'].reverse()
|
||||
with self.assertRaises(ValueError):
|
||||
live.validate_capabilities(data, 'test')
|
||||
|
||||
def test_pairing_payload_shape(self):
|
||||
key = base64.b64encode(bytes(range(32))).decode()
|
||||
value = ','.join(['reference123', key, key, key, '8'])
|
||||
self.assertTrue(qr_memory.valid_payload(value))
|
||||
self.assertTrue(qr_memory.valid_payload('https://wa.me/settings/linked_devices#' + value))
|
||||
self.assertFalse(qr_memory.valid_payload(value.replace(',8', ',7')))
|
||||
self.assertFalse(qr_memory.valid_payload('reference,invalid,invalid,invalid,8'))
|
||||
|
||||
def test_history_refuses_existing_export(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'history.jsonl'
|
||||
path.write_text('original', encoding='utf-8')
|
||||
with self.assertRaisesRegex(RuntimeError, 'already exists'):
|
||||
history.export(None, path)
|
||||
self.assertEqual(path.read_text(), 'original')
|
||||
|
||||
def test_desktop_search_escapes_query_and_enforces_bounds(self):
|
||||
from whatsapp_suite import native_search
|
||||
query = '"; window.unwanted = true; //'
|
||||
with patch.object(native_search, 'native_webview_test', return_value=0) as backend:
|
||||
self.assertEqual(native_search.run_live_probe(None, Path('unused.json'), query, 10), 0)
|
||||
config = backend.call_args.args[2]
|
||||
self.assertIn('query=' + json.dumps(query), config['start'])
|
||||
self.assertEqual(config['mode'], 'search')
|
||||
for query, limit in (('', 10), ('valid', 0), ('valid', 51), ('x' * 201, 10)):
|
||||
with self.assertRaises(ValueError):
|
||||
native_search.run_live_probe(None, Path('unused.json'), query, limit)
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node.js is needed for the JavaScript history fixtures')
|
||||
def test_history_javascript_pagination_decryption_redaction(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
probe = Path(folder) / 'probe.json'
|
||||
probe.write_text(json.dumps(history.PROBE), encoding='utf-8')
|
||||
result = subprocess.run(['node', '-', str(probe)], input=resource_bytes('tests/history_export.cjs').decode('utf-8'), capture_output=True, text=True, encoding='utf-8', timeout=20)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
class CommandTests(unittest.TestCase):
|
||||
def test_all_commands_have_help(self):
|
||||
from whatsapp_suite.cli import COMMANDS
|
||||
for command in ['test'] + list(COMMANDS):
|
||||
with self.subTest(command=command):
|
||||
result = subprocess.run(cli_command(command, '--help'), cwd=BASE, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('usage:', result.stdout)
|
||||
|
||||
def test_saved_search_dispatch(self):
|
||||
from whatsapp_suite.cli import main
|
||||
with patch('whatsapp_suite.exports.main', return_value=0) as target:
|
||||
self.assertEqual(main(['search', 'export.json', 'keyword']), 0)
|
||||
target.assert_called_once_with(['search', 'export.json', 'keyword'])
|
||||
|
||||
def test_live_probe_is_explicit(self):
|
||||
from whatsapp_suite.cli import main
|
||||
with patch('whatsapp_suite.runner.main', return_value=0) as target:
|
||||
self.assertEqual(main([]), 0)
|
||||
target.assert_called_once_with([])
|
||||
|
||||
|
||||
class ZipExportTests(unittest.TestCase):
|
||||
def test_pagination_zip_unicode_and_row_errors(self):
|
||||
from whatsapp_suite.zip_export import write_messages
|
||||
pages = iter([
|
||||
{'count': 3, 'upper': 'c'},
|
||||
{'keys': ['a', 'b'], 'last': 'b', 'messages': [{'id': 'a', 'body': 'Grüße 😺'}, {'id': 'b', 'error': 'decode_failed'}]},
|
||||
{'keys': ['c'], 'last': 'c', 'messages': [{'id': 'c', 'body': 'old message'}]},
|
||||
{'count': 3, 'upper': 'c'},
|
||||
])
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'test.zip'
|
||||
with zipfile.ZipFile(path, 'w') as archive, contextlib.redirect_stdout(io.StringIO()):
|
||||
result = write_messages(lambda _: next(pages), archive, 2, False)
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
self.assertIsNone(archive.testzip())
|
||||
rows = [json.loads(line) for line in archive.read('messages.jsonl').decode('utf-8').splitlines()]
|
||||
self.assertEqual(rows[0]['body'], 'Grüße 😺')
|
||||
self.assertEqual((result['rows'], result['row_errors']), (3, 1))
|
||||
self.assertTrue(result['reached_initial_upper'])
|
||||
|
||||
def test_duplicate_rows_fail(self):
|
||||
from whatsapp_suite.zip_export import write_messages
|
||||
pages = iter([{'count': 2, 'upper': 'b'}, {'keys': ['a', 'a'], 'last': 'a', 'messages': [{'id': 'a'}, {'id': 'a'}]}])
|
||||
with tempfile.TemporaryDirectory() as folder, zipfile.ZipFile(Path(folder) / 'test.zip', 'w') as archive:
|
||||
with self.assertRaisesRegex(RuntimeError, 'Duplicate'):
|
||||
write_messages(lambda _: next(pages), archive, 2, False)
|
||||
|
||||
def test_existing_output_preserved(self):
|
||||
from whatsapp_suite.zip_export import export_zip
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'test.zip'
|
||||
path.write_bytes(b'original')
|
||||
with self.assertRaises(ValueError):
|
||||
export_zip(path)
|
||||
self.assertEqual(path.read_bytes(), b'original')
|
||||
|
||||
def test_empty_database(self):
|
||||
from whatsapp_suite.zip_export import write_messages
|
||||
with tempfile.TemporaryDirectory() as folder, zipfile.ZipFile(Path(folder) / 'test.zip', 'w') as archive:
|
||||
result = write_messages(lambda _: {'count': 0, 'upper': None}, archive, 100, False)
|
||||
self.assertEqual(result['rows'], 0)
|
||||
self.assertTrue(result['reached_initial_upper'])
|
||||
|
||||
|
||||
class NativeCacheTests(unittest.TestCase):
|
||||
@unittest.skipUnless(sys.platform == 'win32', 'Native helper cache requires Windows')
|
||||
def test_verified_helper_reuses_and_repairs_cache(self):
|
||||
from whatsapp_suite import common
|
||||
payload=b'MZ synthetic fixture, never loaded'
|
||||
digest=hashlib.sha256(payload).hexdigest()
|
||||
with tempfile.TemporaryDirectory() as folder, patch.object(common,'BASE',Path(folder)), patch.object(common,'asset_bytes',return_value=payload):
|
||||
path,status=common.verified_helper('fixture.dll',digest,'cache','helper.dll')
|
||||
self.assertEqual(status,'extracted')
|
||||
self.assertEqual(common.verified_helper('fixture.dll',digest,'cache','helper.dll')[1],'reused')
|
||||
path.write_bytes(b'tampered')
|
||||
self.assertEqual(common.verified_helper('fixture.dll',digest,'cache','helper.dll')[1],'repaired')
|
||||
self.assertEqual(path.read_bytes(),payload)
|
||||
with self.assertRaisesRegex(ValueError,'SHA-256'):
|
||||
common.verified_helper('fixture.dll','0'*64,'cache','helper.dll')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+1547
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
"""Consolidated WhatsApp Desktop tools and regression suite."""
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Read-only WhatsApp storage analyzer; optional reads through WhatsApp's own decryption path."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import tomllib
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from .common import PROFILE
|
||||
from .exports import inventory
|
||||
|
||||
|
||||
def endpoint(value: str) -> str:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.scheme not in ("http", "https") or parsed.hostname not in ("127.0.0.1", "::1") or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise argparse.ArgumentTypeError("CDP must be an HTTP(S) literal loopback endpoint")
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
PROBE = r"""(async () => {
|
||||
const limit = __LIMIT__, includeText = __TEXT__, readDb = __READ_DB__;
|
||||
const hash = async s => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)))).map(x=>x.toString(16).padStart(2,'0')).join('');
|
||||
const len = v => v == null ? null : typeof v==='string' ? v.length : v.byteLength ?? v.length ?? null;
|
||||
const encrypted = v => v == null ? {present:false} : {present:true, envelope: typeof v==='object' && '_data' in v && 'iv' in v, ciphertext_bytes:len(v._data), iv_bytes:len(v.iv), scheme:v._scheme??null, key_id_present:v._keyId!=null};
|
||||
let models=[], modelError=null;
|
||||
try { models=require('WAWebMsgCollection').MsgCollection.getModelsArray(); }
|
||||
catch(e) { modelError=String(e); }
|
||||
const selected=models.slice(0,limit), ids=selected.map(m=>m.id?.toString()).filter(Boolean);
|
||||
const summaries=await Promise.all(selected.map(async m=>{
|
||||
const s={id_sha256:m.id ? await hash(m.id.toString()):null, type:m.type??null, body_length:len(m.body), caption_length:len(m.caption), plaintext_present:typeof m.body==='string'||typeof m.caption==='string', model_encrypted_data_present:m.encryptedData!=null};
|
||||
if(includeText){s.body=typeof m.body==='string'?m.body:null;s.caption=typeof m.caption==='string'?m.caption:null;}
|
||||
return s;
|
||||
}));
|
||||
let appReads=[];
|
||||
if(readDb){
|
||||
for(const m of selected){
|
||||
if(!m.id)continue;
|
||||
try {
|
||||
const row=await require('WAWebModelStorageUtils').getStorage().table('message').get(m.id.toString());
|
||||
const restored=row?require('WAWebDBMessageSerialization').messageFromDbRow(row):null;
|
||||
const result={id_sha256:await hash(m.id.toString()),row_found:!!row,decoded_opaque_bytes:len(row?.msgRowOpaqueData),restored_body_length:len(restored?.body),restored_caption_length:len(restored?.caption),body_matches_loaded_model:restored?restored.body===m.body:null,caption_matches_loaded_model:restored?restored.caption===m.caption:null};
|
||||
if(includeText){result.body=restored?.body??null;result.caption=restored?.caption??null;}
|
||||
appReads.push(result);
|
||||
}catch(e){appReads.push({id_sha256:await hash(m.id.toString()),error:String(e)});}
|
||||
}
|
||||
}
|
||||
// Refuse to create a database: enumerate names first, then abort any upgrade.
|
||||
const dbs=typeof indexedDB.databases==='function'?await indexedDB.databases():null;
|
||||
if(!dbs) return {model_count:models.length,models:summaries,model_error:modelError,database_error:'indexedDB.databases unavailable; refusing open to avoid creating a DB'};
|
||||
if(!dbs.some(d=>d.name==='model-storage')) return {model_count:models.length,models:summaries,model_error:modelError,database_names:dbs.map(d=>d.name),database_error:'model-storage absent; no database opened'};
|
||||
const db=await new Promise((resolve,reject)=>{ const r=indexedDB.open('model-storage');r.onupgradeneeded=()=>{r.transaction.abort();reject(new Error('Upgrade aborted'));};r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(new Error(r.error?.name??'Open failed'));r.onblocked=()=>reject(new Error('Database open blocked'));});
|
||||
let rows=[],matching=[];
|
||||
try {
|
||||
if(!db.objectStoreNames.contains('message'))return {model_count:models.length,models:summaries,model_error:modelError,database_error:'message store absent',stores:Array.from(db.objectStoreNames)};
|
||||
const request = r => new Promise((resolve,reject)=>{r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(new Error(r.error?.name??'Read failed'));});
|
||||
const tx=db.transaction(['message'],'readonly'), store=tx.objectStore('message');
|
||||
const reads=[request(store.getAll(undefined,limit)),...ids.map(id=>request(store.get(id)))];
|
||||
const values=await Promise.all(reads);rows=values[0];matching=values.slice(1).filter(Boolean);
|
||||
const summarize=async r=>({id_sha256:await hash(String(r.id)),type:r.type??null,field_names:Object.keys(r).sort(),payload:encrypted(r.msgRowOpaqueData),body_field_present:Object.prototype.hasOwnProperty.call(r,'body'),caption_field_present:Object.prototype.hasOwnProperty.call(r,'caption')});
|
||||
const rowSummary=await Promise.all(rows.map(summarize)), matchingSummary=await Promise.all(matching.map(summarize));
|
||||
const byId=new Map(matchingSummary.map(r=>[r.id_sha256,r]));
|
||||
return {model_count:models.length,models:summaries,model_error:modelError,application_read_path:appReads,database_names:dbs.map(d=>d.name),stores:Array.from(db.objectStoreNames),bounded_row_sample:rowSummary,matching_rows:matchingSummary,comparisons:summaries.filter(m=>byId.has(m.id_sha256)).map(m=>({id_sha256:m.id_sha256,ram_plaintext_present:m.plaintext_present,ram_body_length:m.body_length,ram_caption_length:m.caption_length,disk_payload:byId.get(m.id_sha256).payload}))};
|
||||
} finally { db.close(); }
|
||||
})()"""
|
||||
|
||||
|
||||
def cdp_probe(url: str, limit: int, include_text: bool, read_db: bool = False) -> tuple[dict, str]:
|
||||
from websockets.sync.client import connect
|
||||
with urllib.request.urlopen(url + "/json/list", timeout=10) as response:
|
||||
targets = json.load(response)
|
||||
target = next((t for t in targets if t.get("type") == "page" and urllib.parse.urlsplit(t.get("url", "")).hostname == "web.whatsapp.com"), None)
|
||||
if not target:
|
||||
raise RuntimeError("No WhatsApp page target is exposed; start WhatsApp with CDP enabled.")
|
||||
websocket_url = target["webSocketDebuggerUrl"]
|
||||
if urllib.parse.urlsplit(websocket_url).hostname not in ("127.0.0.1", "::1"):
|
||||
raise RuntimeError("Refusing a non-loopback WebSocket target")
|
||||
expression = PROBE.replace("__LIMIT__", str(limit)).replace("__TEXT__", "true" if include_text else "false").replace("__READ_DB__", "true" if read_db else "false")
|
||||
with connect(websocket_url, max_size=20_000_000, open_timeout=10) as ws:
|
||||
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": expression, "returnByValue": True, "awaitPromise": True}}))
|
||||
while True:
|
||||
result = json.loads(ws.recv(timeout=30))
|
||||
if result.get("id") == 1:
|
||||
break
|
||||
if "error" in result or result.get("result", {}).get("exceptionDetails"):
|
||||
raise RuntimeError("CDP evaluation failed: " + json.dumps(result.get("error") or result["result"]["exceptionDetails"]))
|
||||
return result["result"]["result"]["value"], target["id"]
|
||||
|
||||
|
||||
class MCP:
|
||||
def __init__(self, config: Path):
|
||||
with config.open("rb") as stream:
|
||||
entry = tomllib.load(stream)["mcp_servers"]["rea"]
|
||||
env = dict(os.environ, **entry.get("env", {}))
|
||||
self.proc = subprocess.Popen([entry["command"], *entry.get("args", [])], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", env=env)
|
||||
self.messages = queue.Queue()
|
||||
self.counter = 0
|
||||
threading.Thread(target=self._read, daemon=True).start()
|
||||
self.call("initialize", {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "whatsapp-readonly-analyzer", "version": "1.0"}})
|
||||
self.send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
|
||||
def _read(self):
|
||||
for line in self.proc.stdout:
|
||||
try:
|
||||
self.messages.put(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
self.messages.put({"error": {"message": "REA server exited"}})
|
||||
|
||||
def send(self, payload):
|
||||
self.proc.stdin.write(json.dumps(payload) + "\n")
|
||||
self.proc.stdin.flush()
|
||||
|
||||
def call(self, method, params):
|
||||
self.counter += 1
|
||||
ident = self.counter
|
||||
self.send({"jsonrpc": "2.0", "id": ident, "method": method, "params": params})
|
||||
while True:
|
||||
message = self.messages.get(timeout=60)
|
||||
if message.get("id") == ident or ("error" in message and "id" not in message):
|
||||
if "error" in message:
|
||||
raise RuntimeError(str(message["error"]))
|
||||
return message["result"]
|
||||
|
||||
def close(self):
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
|
||||
|
||||
def rea_inspect(url: str, target_id: str, config: Path) -> dict:
|
||||
client = MCP(config)
|
||||
try:
|
||||
results = []
|
||||
for name, arguments in (("list_browser_targets", {"cdp_endpoint": url, "allowed_origins": ["https://web.whatsapp.com"]}), ("inspect_web_page", {"cdp_endpoint": url, "target_id": target_id, "allowed_origins": ["https://web.whatsapp.com"], "observation_ms": 100, "include_script_sources": False, "include_accessibility_text": False, "include_console_text": False, "include_json_body_shapes": False, "include_storage_fingerprints": False, "include_storage_keys": False, "include_websocket_shapes": False})):
|
||||
result = client.call("tools/call", {"name": name, "arguments": arguments})
|
||||
# Keep provenance only: never save raw DOM, console, source, or storage values.
|
||||
evidence = result.get("structuredContent")
|
||||
if evidence is None:
|
||||
for block in result.get("content", []):
|
||||
if block.get("type") == "text":
|
||||
try:
|
||||
evidence = json.loads(block["text"])
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
evidence = evidence if isinstance(evidence, dict) else {}
|
||||
results.append({"tool": name, "is_error": result.get("isError", False), "evidence_id": evidence.get("evidence_id"), "operation": evidence.get("operation"), "provider": evidence.get("provider"), "confidence": evidence.get("confidence"), "limitations": evidence.get("limitations", []), "response_sha256": hashlib.sha256(json.dumps(result, sort_keys=True).encode()).hexdigest()})
|
||||
return {"source": "REA stdio MCP", "evidence": results, "retention": "Provenance and response digests only; full payload deliberately omitted."}
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--profile", type=Path, default=PROFILE)
|
||||
parser.add_argument("--cdp", type=endpoint, help="Existing loopback CDP endpoint, e.g. http://127.0.0.1:9223")
|
||||
parser.add_argument("--limit", type=int, default=20, help="Maximum models and DB sample rows (1..100)")
|
||||
parser.add_argument("--include-message-text", action="store_true", help="Explicitly save loaded own-account message body/caption in output")
|
||||
parser.add_argument("--read-db", action="store_true", help="Use WhatsApp's own message-table getter to decrypt bounded matching rows and compare text in memory")
|
||||
parser.add_argument("--rea", action="store_true", help="Also request REA passive page evidence over configured stdio MCP")
|
||||
parser.add_argument("--config", type=Path, default=Path.home() / ".codex/config.toml")
|
||||
parser.add_argument("--output", type=Path, default=Path("whatsapp-analyzer-report.json"))
|
||||
args = parser.parse_args(argv)
|
||||
if not 1 <= args.limit <= 100:
|
||||
parser.error("--limit must be between 1 and 100")
|
||||
if (args.rea or args.include_message_text or args.read_db) and not args.cdp:
|
||||
parser.error("--rea, --read-db and --include-message-text require --cdp")
|
||||
report = {"timestamp_utc": dt.datetime.now(dt.timezone.utc).isoformat(), "offline": inventory(args.profile), "message_text_included": args.include_message_text,
|
||||
"application_db_read_enabled": args.read_db,
|
||||
"limitations": ["Static REA findings guide the probe; Runtime.evaluate results are independent CDP observations, not REA attestations.", "Reads only existing loaded models and bounded read-only IndexedDB rows. No send, navigation, record writes or key extraction. --read-db calls WhatsApp's own getter, which decrypts payloads in memory.", "RAM strings are already decrypted by WhatsApp. Raw-envelope comparison alone proves storage shape; --read-db additionally checks restored content equality against loaded models without exporting text by default.", "Not all history is loaded; database getAll sample is bounded and has no recency guarantee. Metadata such as field names and message type remains visible.", "WebView2 and WhatsApp caches are not a forensic snapshot. OS swap, media caches, notifications and native SQLite are outside this probe."]}
|
||||
failed = False
|
||||
if args.cdp:
|
||||
try:
|
||||
result, target = cdp_probe(args.cdp, args.limit, args.include_message_text, args.read_db)
|
||||
report["runtime"] = {"source": "Independent read-only CDP Runtime.evaluate", "target_id": target, "result": result}
|
||||
if args.rea:
|
||||
report["rea"] = rea_inspect(args.cdp, target, args.config)
|
||||
except Exception as error:
|
||||
report["error"] = f"{type(error).__name__}: {error}"
|
||||
failed = True
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"Report saved: {args.output.resolve()}")
|
||||
if failed:
|
||||
print(report["error"])
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
// REA-backed WinRT ABI for WhatsAppNativeProjection.dll build 2.2639.100.0.
|
||||
// Native calls only; no CDP, UI automation or restart.
|
||||
const native=Process.getModuleByName('WhatsAppNative.dll');
|
||||
if(!native.path.includes('WhatsAppDesktop_2.2639.100.0_'))throw Error('Unsupported Desktop build; ABI must be revalidated with REA');
|
||||
const combase=Process.getModuleByName('combase.dll');
|
||||
function api(name,ret,args){return new NativeFunction(combase.getExportByName(name),ret,args);}
|
||||
const createString=api('WindowsCreateString','int',['pointer','uint','pointer']);
|
||||
const deleteString=api('WindowsDeleteString','int',['pointer']);
|
||||
const rawString=api('WindowsGetStringRawBuffer','pointer',['pointer','pointer']);
|
||||
function check(hr){if(hr<0)throw Error('HRESULT 0x'+(hr>>>0).toString(16));}
|
||||
function hstring(value){const out=Memory.alloc(8);check(createString(Memory.allocUtf16String(value),value.length,out));return out.readPointer();}
|
||||
function readHString(handle){if(handle.isNull())return '';const len=Memory.alloc(4);const p=rawString(handle,len);return p.readUtf16String(len.readU32());}
|
||||
function guid(hex){const p=Memory.alloc(16);p.writeByteArray(hex.match(/../g).map(x=>parseInt(x,16)));return p;}
|
||||
const dbIid=guid('eeb0ca996bf8523780d9cf1b934d62b5');
|
||||
const stmtIid=guid('94f8c705a4cb4b3baa1b9b2ce2ff2734');
|
||||
const propertyIid=guid('dd82d64b5475e9409a9b82654ede7e62');
|
||||
function method(obj,slot,ret,args){return new NativeFunction(obj.readPointer().add(slot*8).readPointer(),ret,['pointer',...args]);}
|
||||
function release(obj){if(!obj.isNull())method(obj,2,'uint',[])(obj);}
|
||||
function query(obj,iid){const out=Memory.alloc(8);check(method(obj,0,'int',['pointer','pointer'])(obj,iid,out));return out.readPointer();}
|
||||
let references=[];
|
||||
function property(value){if(value.isNull())return null;let p=ptr(0);try{
|
||||
p=query(value,propertyIid);const out=Memory.alloc(8);check(method(p,19,'int',['pointer'])(p,out));
|
||||
const h=out.readPointer();try{return readHString(h);}finally{deleteString(h);}
|
||||
}finally{release(p);release(value);}}
|
||||
function statement(db,sql){const h=hstring(sql),error=Memory.alloc(4),partial=Memory.alloc(8),out=Memory.alloc(8);
|
||||
try{check(method(db,9,'int',['pointer','pointer','pointer','pointer'])(db,h,error,partial,out));
|
||||
// The integer out-parameter is the consumed SQL offset, not an error code.
|
||||
const original=out.readPointer();if(original.isNull())throw Error('No prepared statement');
|
||||
try{return query(original,stmtIid);}finally{release(original);}
|
||||
}finally{deleteString(h);}
|
||||
}
|
||||
function select(db,sql,limit){let st=ptr(0);try{
|
||||
st=statement(db,sql);const count=Memory.alloc(4);check(method(st,10,'int',['pointer'])(st,count));
|
||||
const rows=[];while(rows.length<limit){const has=Memory.alloc(1);check(method(st,7,'int',['pointer'])(st,has));if(has.readU8()===0)break;
|
||||
const row=[];for(let i=0;i<count.readS32();i++){const out=Memory.alloc(8);check(method(st,11,'int',['int','pointer'])(st,i,out));row.push(property(out.readPointer()));}rows.push(row);}
|
||||
return rows;
|
||||
}finally{if(!st.isNull()){try{check(method(st,6,'int',[])(st));}finally{release(st);}}}}
|
||||
rpc.exports={
|
||||
discover(maxMb){
|
||||
// Obtain the real interface vtable from a fresh closed Sqlite object. No DB
|
||||
// is opened and no credentials are read from the existing session.
|
||||
const init=api('RoInitialize','int',['uint'])(1);if(init<0&&init!==-2147417850)check(init);
|
||||
const name=hstring('WhatsAppNative.Sqlite'),factoryOut=Memory.alloc(8);
|
||||
const getFactory=new NativeFunction(native.getExportByName('DllGetActivationFactory'),'int',['pointer','pointer']);
|
||||
let factory=ptr(0),sample=ptr(0),iface=ptr(0);
|
||||
try{check(getFactory(name,factoryOut));factory=factoryOut.readPointer();const out=Memory.alloc(8);
|
||||
check(method(factory,6,'int',['pointer'])(factory,out));sample=out.readPointer();iface=query(sample,dbIid);
|
||||
const vtable=iface.readPointer();const bytes=Memory.alloc(8);bytes.writePointer(vtable);
|
||||
const pattern=Array.from(new Uint8Array(bytes.readByteArray(8))).map(v=>v.toString(16).padStart(2,'0')).join(' ');
|
||||
let scanned=0,hitCount=0;const found=[],addresses=new Set(),failures=[];
|
||||
for(const range of Process.enumerateRanges({protection:'rw-',coalesce:true})){
|
||||
if(scanned>=maxMb*1024*1024)break;
|
||||
const size=Math.min(range.size,maxMb*1024*1024-scanned);scanned+=size;
|
||||
let hits=[];try{hits=Memory.scanSync(range.base,size,pattern);}catch{continue;}
|
||||
hitCount+=hits.length;
|
||||
for(const hit of hits){if(hit.address.equals(iface)||!hit.address.and(7).isNull())continue;
|
||||
let object=ptr(0);try{object=query(hit.address,dbIid);if(addresses.has(object.toString())){release(object);continue;}
|
||||
const tables=select(object,"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",100);
|
||||
const id=references.length;references.push(object);addresses.add(object.toString());
|
||||
found.push({id,address:object.toString(),tables:tables.map(r=>r[0])});object=ptr(0);
|
||||
}catch(error){if(failures.length<20)failures.push({address:hit.address.toString(),error:String(error)});if(!object.isNull())release(object);}
|
||||
}
|
||||
}return {databases:found,bytesScanned:scanned,vtable:vtable.toString(),vtableHits:hitCount,failures};
|
||||
}finally{release(iface);release(sample);release(factory);deleteString(name);}
|
||||
},
|
||||
search(id,term,limit){if(!Number.isInteger(id)||id<0||id>=references.length)throw Error('Unknown database');
|
||||
if(!Number.isInteger(limit)||limit<1||limit>1000)throw Error('Invalid row limit');
|
||||
const tokens=term.trim().split(/\s+/).filter(Boolean);if(!tokens.length)throw Error('Empty query');
|
||||
const match=tokens.map(t=>'"'+t.replace(/"/g,'""')+'"*').join(' AND ').replace(/'/g,"''");
|
||||
const sql="SELECT CAST(t.id AS TEXT),CAST(t.chatId AS TEXT),CAST(t.timestamp AS TEXT),CAST(t.text AS TEXT) FROM message t JOIN message_fts f ON t.rowid=f.rowid WHERE message_fts MATCH '"+match+"' ORDER BY rank LIMIT "+limit;
|
||||
return select(references[id],sql,limit);
|
||||
},
|
||||
oldest(id,limit){
|
||||
if(!Number.isInteger(id)||id<0||id>=references.length)throw Error('Unknown database');
|
||||
if(!Number.isInteger(limit)||limit<1||limit>1000)throw Error('Invalid row limit');
|
||||
return select(references[id],"SELECT CAST(id AS TEXT),CAST(chatId AS TEXT),CAST(timestamp AS TEXT),CAST(text AS TEXT) FROM message WHERE timestamp > 0 AND text IS NOT NULL AND length(text) > 0 ORDER BY timestamp,id LIMIT "+limit,limit);
|
||||
},
|
||||
stats(id){return select(references[id],"SELECT CAST(COUNT(*) AS TEXT),CAST(MIN(timestamp) AS TEXT),CAST(MAX(timestamp) AS TEXT) FROM message",1)[0];},
|
||||
indexbounds(id){return select(references[id],"SELECT CAST(COUNT(*) AS TEXT),CAST(COALESCE(MAX(rowid),0) AS TEXT) FROM message",1)[0];},
|
||||
exportindex(id,after,upper,limit){
|
||||
if(!Number.isInteger(id)||id<0||id>=references.length)throw Error('Unknown database');
|
||||
if(!/^-?\d+$/.test(String(after))||!/^\d+$/.test(String(upper)))throw Error('Invalid row boundary');
|
||||
if(!Number.isInteger(limit)||limit<1||limit>500)throw Error('Invalid batch size');
|
||||
return select(references[id],"SELECT CAST(rowid AS TEXT),CAST(id AS TEXT),CAST(chatId AS TEXT),CAST(timestamp AS TEXT),CAST(text AS TEXT) FROM message WHERE rowid > "+after+" AND rowid <= "+upper+" ORDER BY rowid LIMIT "+limit,limit);
|
||||
},
|
||||
close(){for(const object of references)release(object);references=[];}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Single entry point for regression tests and explicit app probes."""
|
||||
import argparse
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
COMMANDS = {
|
||||
'inventory': ('exports', 'List local Desktop storage files'),
|
||||
'stats': ('exports', 'Summarize a saved JSON/JSONL export'),
|
||||
'search': ('exports', 'Search a saved export'),
|
||||
'desktop-search': ('exports', 'Search the running Desktop app'),
|
||||
'live': ('live', 'Run fixed Desktop math/capability/data probes'),
|
||||
'webview-test': ('webview', 'Run an isolated WebView2 host test'),
|
||||
'analyze': ('analyzer', 'Inspect storage and optionally an existing CDP session'),
|
||||
'history-export': ('history', 'Export history through an existing Desktop CDP endpoint'),
|
||||
'export-zip': ('zip_export', 'Export all local messages via JavaScript in WebView; no CDP or UI automation'),
|
||||
'send-self': ('self_message', 'Send one test text to your own WhatsApp account via JavaScript'),
|
||||
'contacts-export': ('contacts_export', 'Export all locally stored contacts via JavaScript without CDP'),
|
||||
'ram-read': ('ram', 'Recover loaded message objects from process RAM'),
|
||||
'ram-search': ('ram_search', 'Search verified loaded message objects'),
|
||||
'native-search': ('injected_search', 'Experimental native SQLite search/index export'),
|
||||
'qr-memory': ('qr_memory', 'Find Desktop pairing QR candidates in RAM'),
|
||||
'qr-window': ('qr_window', 'Capture an already displayed Desktop pairing QR'),
|
||||
'pairing': ('pairing', 'Request a linking QR in an isolated browser'),
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv:
|
||||
argv = ['test']
|
||||
parser = argparse.ArgumentParser(description='WhatsApp tools and test suite. Default: offline regression tests.')
|
||||
commands = parser.add_subparsers(dest='command', required=True)
|
||||
commands.add_parser('test', help='Run offline regression tests; optionally add live checks', add_help=False)
|
||||
for name, (_, description) in COMMANDS.items():
|
||||
commands.add_parser(name, help=description, add_help=False)
|
||||
args, remaining = parser.parse_known_args(argv)
|
||||
try:
|
||||
if args.command == 'test':
|
||||
from .runner import main as run_tests
|
||||
return run_tests(remaining)
|
||||
module = importlib.import_module('.' + COMMANDS[args.command][0], __package__)
|
||||
if args.command in ('inventory', 'stats', 'search', 'desktop-search'):
|
||||
command = 'whatsapp-search' if args.command == 'desktop-search' else args.command
|
||||
return module.main([command] + remaining)
|
||||
if args.command == 'webview-test':
|
||||
help_parser = argparse.ArgumentParser(description=COMMANDS[args.command][1])
|
||||
help_parser.parse_args(remaining)
|
||||
return module.run_webview_test()
|
||||
return module.main(remaining)
|
||||
except (OSError, ValueError, RuntimeError, ImportError) as error:
|
||||
print(f'{args.command}: {error}', file=sys.stderr)
|
||||
return 1
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Shared workspace paths and text normalization."""
|
||||
import os
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
_BUNDLE = getattr(sys, '_whatsapp_bundle', None)
|
||||
BASE = Path(_BUNDLE['base']) if _BUNDLE else Path(__file__).resolve().parent.parent
|
||||
ASSETS = Path(__file__).resolve().parent / "assets"
|
||||
PROFILE = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData/Local"))) / "Packages/5319275A.WhatsAppDesktop_cv1g1gvanyjgm/LocalCache/EBWebView/Default"
|
||||
|
||||
def normalized(value):
|
||||
return unicodedata.normalize('NFC', value).casefold()
|
||||
|
||||
|
||||
def resource_bytes(name):
|
||||
"""Read an embedded bundle resource or its development-tree equivalent."""
|
||||
if _BUNDLE:
|
||||
return _BUNDLE['read'](name)
|
||||
return (BASE / name).read_bytes()
|
||||
|
||||
|
||||
def asset_bytes(name):
|
||||
return resource_bytes('whatsapp_suite/assets/' + name)
|
||||
|
||||
|
||||
def cli_command(*arguments):
|
||||
if _BUNDLE:
|
||||
return [sys.executable, str(_BUNDLE['launcher']), *arguments]
|
||||
return [sys.executable, '-m', 'whatsapp_suite', *arguments]
|
||||
|
||||
|
||||
def verified_helper(asset, digest, cache_dir, filename):
|
||||
"""Materialize a hash-verified DLL only when a live probe needs it."""
|
||||
import ctypes
|
||||
import hashlib
|
||||
import tempfile
|
||||
if os.name != 'nt' or ctypes.sizeof(ctypes.c_void_p) != 8:
|
||||
raise ValueError('Native helpers require Windows and 64-bit Python')
|
||||
payload = asset_bytes(asset)
|
||||
if hashlib.sha256(payload).hexdigest() != digest:
|
||||
raise ValueError('Bundled helper failed SHA-256 verification')
|
||||
folder = BASE / cache_dir / digest
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
dll = folder / filename
|
||||
existed = dll.exists()
|
||||
if existed and hashlib.sha256(dll.read_bytes()).hexdigest() == digest:
|
||||
return dll, 'reused'
|
||||
descriptor, temporary = tempfile.mkstemp(prefix='helper-', suffix='.tmp', dir=folder)
|
||||
try:
|
||||
with os.fdopen(descriptor, 'wb') as stream:
|
||||
stream.write(payload)
|
||||
os.replace(temporary, dll)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
return dll, 'repaired' if existed else 'extracted'
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Export all locally stored contact records through WhatsApp's JavaScript API."""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .live import run_math
|
||||
from .zip_export import evaluate
|
||||
|
||||
PROBE = r'''(async () => {
|
||||
const table=require('WAWebSchemaContact_DO_NOT_USE_DIRECTLY').getContactTable();
|
||||
const rows=await table.all();
|
||||
if(!Array.isArray(rows))throw Error('Unexpected contact table result');
|
||||
const fields=['id','phoneNumber','name','shortName','pushname','notifyName','verifiedName',
|
||||
'addressBookName','isAddressBookContact','isMyContact','isBusiness','isEnterprise'];
|
||||
const contacts=rows.map(row=>{
|
||||
const out={};for(const field of fields){const value=row[field];
|
||||
if(value==null||['string','number','boolean'].includes(typeof value))out[field]=value??null;
|
||||
else if(field==='id')out.id=value.toString();
|
||||
}
|
||||
if(typeof out.id!=='string'||!out.id)throw Error('Contact ID unavailable');
|
||||
return out;
|
||||
});
|
||||
return {source:'WAWebSchemaContact_DO_NOT_USE_DIRECTLY.getContactTable().all',
|
||||
scope:'All local contact rows, not only loaded models or phone address-book entries',
|
||||
count:contacts.length,contacts,cdp:false,ui_automation:false};
|
||||
})()'''
|
||||
|
||||
|
||||
def export_contacts(output,pid=None):
|
||||
output=Path(output)
|
||||
if output.exists():raise ValueError('Output exists; choose a new --output')
|
||||
def operation(execute,session,report):
|
||||
data=evaluate(execute,PROBE,session)
|
||||
if not isinstance(data.get('contacts'),list) or data.get('count')!=len(data['contacts']):
|
||||
raise RuntimeError('Incomplete contact export response')
|
||||
report['data']=data
|
||||
report=run_math(pid,'contacts-export',_operation=operation)
|
||||
output.parent.mkdir(parents=True,exist_ok=True)
|
||||
with output.open('x',encoding='utf-8') as stream:
|
||||
json.dump(report,stream,ensure_ascii=True,indent=2)
|
||||
return report
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--pid',type=int)
|
||||
parser.add_argument('--output',type=Path,default=Path('exports')/('contacts-'+dt.datetime.now().strftime('%Y%m%d-%H%M%S')+'.json'))
|
||||
args=parser.parse_args(argv)
|
||||
report=export_contacts(args.output,args.pid)
|
||||
print(json.dumps({'passed':report['passed'],'error':report.get('error'),'contacts':report.get('data',{}).get('count'),'output':str(args.output)}))
|
||||
return 0 if report['passed'] else 1
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Shared verified Desktop process and window discovery."""
|
||||
import ctypes
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
SUPPORTED_BUILD = "2.2639.100.0"
|
||||
CORE_SHA256 = "c6da3c67eda66a8d48ff2ae11f68085f1f8250111138a6ae384d38eefb4da536"
|
||||
|
||||
def check_core_build(path):
|
||||
if 'WhatsAppDesktop_' + SUPPORTED_BUILD + '_' not in str(path):
|
||||
raise ValueError('Unsupported WhatsApp build: ' + str(path) +
|
||||
'; supported build is ' + SUPPORTED_BUILD + '. No hook installed.')
|
||||
if hashlib.sha256(Path(path).read_bytes()).hexdigest() != CORE_SHA256:
|
||||
raise ValueError('Unknown WebView2 Core DLL hash. No hook installed.')
|
||||
|
||||
def visible_ui_threads(pid):
|
||||
"""Inspect window ownership; no clicks, navigation or window messages."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
user32 = ctypes.WinDLL('user32', use_last_error=True)
|
||||
user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
|
||||
user32.GetWindowThreadProcessId.restype = wintypes.DWORD
|
||||
user32.IsWindowVisible.argtypes = [wintypes.HWND]
|
||||
user32.IsWindowVisible.restype = wintypes.BOOL
|
||||
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
threads = set()
|
||||
def visit(hwnd, parameter):
|
||||
process = wintypes.DWORD()
|
||||
thread = user32.GetWindowThreadProcessId(hwnd, ctypes.byref(process))
|
||||
if process.value == pid and user32.IsWindowVisible(hwnd):
|
||||
threads.add(thread)
|
||||
return True
|
||||
callback = callback_type(visit)
|
||||
user32.EnumWindows.argtypes = [callback_type, wintypes.LPARAM]
|
||||
user32.EnumWindows.restype = wintypes.BOOL
|
||||
if not user32.EnumWindows(callback, 0):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
return threads
|
||||
|
||||
def detect_desktop_pid(requested_pid=None):
|
||||
"""Find the running supported Desktop host using Python and Windows APIs."""
|
||||
import ctypes
|
||||
from ctypes import wintypes as w
|
||||
import hashlib
|
||||
class ProcessEntry(ctypes.Structure):
|
||||
_fields_ = [('dwSize',w.DWORD),('cntUsage',w.DWORD),('th32ProcessID',w.DWORD),
|
||||
('th32DefaultHeapID',ctypes.c_size_t),('th32ModuleID',w.DWORD),
|
||||
('cntThreads',w.DWORD),('th32ParentProcessID',w.DWORD),
|
||||
('pcPriClassBase',w.LONG),('dwFlags',w.DWORD),('szExeFile',w.WCHAR*260)]
|
||||
class ModuleEntry(ctypes.Structure):
|
||||
_fields_ = [('dwSize',w.DWORD),('th32ModuleID',w.DWORD),('th32ProcessID',w.DWORD),
|
||||
('GlblcntUsage',w.DWORD),('ProccntUsage',w.DWORD),
|
||||
('modBaseAddr',ctypes.POINTER(w.BYTE)),('modBaseSize',w.DWORD),
|
||||
('hModule',w.HMODULE),('szModule',w.WCHAR*256),('szExePath',w.WCHAR*260)]
|
||||
kernel = ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
kernel.CreateToolhelp32Snapshot.argtypes = [w.DWORD,w.DWORD]
|
||||
kernel.CreateToolhelp32Snapshot.restype = w.HANDLE
|
||||
kernel.CloseHandle.argtypes = [w.HANDLE]
|
||||
kernel.CloseHandle.restype = w.BOOL
|
||||
for name, entry in [('Process32FirstW',ProcessEntry),('Process32NextW',ProcessEntry),
|
||||
('Module32FirstW',ModuleEntry),('Module32NextW',ModuleEntry)]:
|
||||
fn = getattr(kernel,name)
|
||||
fn.argtypes = [w.HANDLE,ctypes.POINTER(entry)]
|
||||
fn.restype = w.BOOL
|
||||
invalid = ctypes.c_void_p(-1).value
|
||||
snapshot = kernel.CreateToolhelp32Snapshot(2,0)
|
||||
if snapshot == invalid:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
roots = []
|
||||
try:
|
||||
entry = ProcessEntry(dwSize=ctypes.sizeof(ProcessEntry))
|
||||
exists = kernel.Process32FirstW(snapshot,ctypes.byref(entry))
|
||||
while exists:
|
||||
if entry.szExeFile.casefold() == 'whatsapp.root.exe':
|
||||
roots.append(entry.th32ProcessID)
|
||||
exists = kernel.Process32NextW(snapshot,ctypes.byref(entry))
|
||||
finally:
|
||||
kernel.CloseHandle(snapshot)
|
||||
candidates = []
|
||||
rejected = []
|
||||
for pid in roots:
|
||||
if requested_pid is not None and pid != requested_pid:
|
||||
continue
|
||||
snapshot = kernel.CreateToolhelp32Snapshot(8|16,pid)
|
||||
if snapshot == invalid:
|
||||
continue
|
||||
try:
|
||||
entry = ModuleEntry(dwSize=ctypes.sizeof(ModuleEntry))
|
||||
exists = kernel.Module32FirstW(snapshot,ctypes.byref(entry))
|
||||
while exists:
|
||||
if entry.szModule.casefold() == 'microsoft.web.webview2.core.dll':
|
||||
path = Path(entry.szExePath)
|
||||
if len(visible_ui_threads(pid)) == 1:
|
||||
try:
|
||||
check_core_build(path)
|
||||
candidates.append(pid)
|
||||
except (ValueError, OSError) as error:
|
||||
rejected.append(str(error))
|
||||
break
|
||||
exists = kernel.Module32NextW(snapshot,ctypes.byref(entry))
|
||||
except OSError:
|
||||
continue
|
||||
finally:
|
||||
kernel.CloseHandle(snapshot)
|
||||
if not candidates:
|
||||
raise ValueError('; '.join(rejected) if rejected else 'No supported running WhatsApp Desktop host with one identifiable UI thread found; start WhatsApp normally')
|
||||
if len(candidates) != 1:
|
||||
raise ValueError('Multiple active Desktop hosts found; specify --pid: '+', '.join(map(str,candidates)))
|
||||
return candidates[0]
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Storage inventory and saved message export analysis."""
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
from .common import BASE, PROFILE, normalized
|
||||
|
||||
def messages(path):
|
||||
with path.open(encoding='utf-8-sig') as stream:
|
||||
if path.suffix.lower() == '.jsonl':
|
||||
for number, line in enumerate(stream, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError as ex:
|
||||
raise ValueError(f'Invalid JSON at line {number}: {ex}') from ex
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError(f'Line {number} is not a message object')
|
||||
yield row
|
||||
else:
|
||||
data = json.load(stream)
|
||||
if isinstance(data, list):
|
||||
rows = data
|
||||
elif isinstance(data, dict):
|
||||
rows = next((data[key] for key in ('messages', 'models', 'results')
|
||||
if isinstance(data.get(key), list)), None)
|
||||
if rows is None:
|
||||
raise ValueError('Expected messages, models or results array')
|
||||
else:
|
||||
raise ValueError('Expected an array or a message report')
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError('Message array contains a non-object value')
|
||||
yield row
|
||||
|
||||
def content(row):
|
||||
return '\n'.join(row[key] for key in ('indexed_text', 'body', 'caption', 'text')
|
||||
if isinstance(row.get(key), str))
|
||||
|
||||
def inventory(profile):
|
||||
entries = []
|
||||
for pattern in ('IndexedDB/*whatsapp*', 'Local Storage/leveldb', 'Session Storage'):
|
||||
for folder in profile.glob(pattern):
|
||||
files = []
|
||||
for file in folder.iterdir() if folder.is_dir() else [folder]:
|
||||
try:
|
||||
if file.is_file():
|
||||
files.append({'name': file.name, 'bytes': file.stat().st_size})
|
||||
except OSError:
|
||||
continue
|
||||
entries.append({'path': str(folder), 'files': files})
|
||||
return {'profile': str(profile), 'exists': profile.exists(), 'storage_inventory': entries,
|
||||
'coverage': 'File names and sizes only; no decrypted messages.'}
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
inv = sub.add_parser('inventory', help='List local Desktop storage files and sizes')
|
||||
inv.add_argument('--profile', type=Path, default=PROFILE)
|
||||
for name in ('stats', 'search'):
|
||||
cmd = sub.add_parser(name, help='Analyze an existing JSON/JSONL message export')
|
||||
cmd.add_argument('input', type=Path)
|
||||
cmd.add_argument('--output', type=Path)
|
||||
if name == 'search':
|
||||
cmd.add_argument('query')
|
||||
cmd.add_argument('--chat-id')
|
||||
cmd.add_argument('--limit', type=int, default=100)
|
||||
cmd.add_argument('--redact-text', action='store_true')
|
||||
sub.add_parser('webview-test', help='Harmless JavaScript test in a separate local host')
|
||||
live = sub.add_parser('whatsapp-test', help='Run eight fixed arithmetic checks in the running Desktop instance')
|
||||
live.add_argument('--pid', type=int, help='Optional override; running Desktop host is detected automatically')
|
||||
live.add_argument('--output', type=Path, default=BASE / 'analysis/webview2/live-arithmetic-result.json')
|
||||
search = sub.add_parser('whatsapp-search', help='Use the running app native message search through WebView2')
|
||||
search.add_argument('query')
|
||||
search.add_argument('--pid', type=int, help='Optional override; running Desktop host is detected automatically')
|
||||
search.add_argument('--limit', type=int, default=50)
|
||||
search.add_argument('--output', type=Path, default=BASE / 'analysis/webview2/live-message-search.json')
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == 'whatsapp-test':
|
||||
from .live import main as run_live
|
||||
return run_live(['--suite', 'math'] + (['--pid', str(args.pid)] if args.pid else []) + ['--output', str(args.output)])
|
||||
if args.command == 'whatsapp-search':
|
||||
if not args.query.strip() or len(args.query)>200 or not 1<=args.limit<=50:
|
||||
parser.error('Search requires 1..200 characters and --limit 1..50')
|
||||
from .native_search import run_live_probe
|
||||
return run_live_probe(args.pid, args.output, args.query, args.limit)
|
||||
if args.command == 'webview-test':
|
||||
from .webview import run_webview_test
|
||||
return run_webview_test()
|
||||
if args.command == 'inventory':
|
||||
result = inventory(args.profile)
|
||||
elif args.command == 'stats':
|
||||
count = with_text = 0
|
||||
kinds = collections.Counter()
|
||||
chats = set()
|
||||
for row in messages(args.input):
|
||||
count += 1
|
||||
with_text += bool(content(row))
|
||||
kinds[str(row.get('type', 'unknown'))] += 1
|
||||
chat = row.get('chatId', row.get('chat_id'))
|
||||
if chat is not None:
|
||||
chats.add(str(chat))
|
||||
result = {'source': str(args.input), 'messages': count, 'messages_with_text': with_text,
|
||||
'chats': len(chats), 'types': dict(kinds), 'scope': 'Saved export only'}
|
||||
else:
|
||||
if not args.query.strip():
|
||||
parser.error('query must not be empty')
|
||||
if args.limit < 1:
|
||||
parser.error('--limit must be positive')
|
||||
found = []
|
||||
total = 0
|
||||
for row in messages(args.input):
|
||||
chat = row.get('chatId', row.get('chat_id'))
|
||||
if args.chat_id is not None and str(chat) != args.chat_id:
|
||||
continue
|
||||
text = content(row)
|
||||
if normalized(args.query) not in normalized(text):
|
||||
continue
|
||||
total += 1
|
||||
if len(found) >= args.limit:
|
||||
continue
|
||||
item = dict(row)
|
||||
if args.redact_text:
|
||||
# Allowlist metadata so nested fields cannot leak message content.
|
||||
item = {key: value for key, value in row.items()
|
||||
if key in ('id', 'chatId', 'chat_id', 'timestamp', 'type', 'nativeRowID', 'native_rowid')
|
||||
and isinstance(value, (str, int, float, bool, type(None)))}
|
||||
item['text_length'] = len(text)
|
||||
found.append(item)
|
||||
result = {'source': str(args.input), 'matches': total, 'returned': len(found),
|
||||
'results': found, 'scope': 'Literal search of saved export only'}
|
||||
serialized = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
output = getattr(args, 'output', None)
|
||||
if output:
|
||||
if output.resolve() == args.input.resolve():
|
||||
parser.error('output must differ from input')
|
||||
output.write_text(serialized + '\n', encoding='utf-8')
|
||||
print(json.dumps({'output': str(output), 'command': args.command}))
|
||||
else:
|
||||
print(serialized)
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Export local WhatsApp Desktop history through its own database decryption.
|
||||
|
||||
Requires an already-exposed Desktop WebView2 CDP endpoint. Does not enable CDP,
|
||||
restart Desktop, use a separate browser, change messages, or request history sync.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from .analyzer import endpoint
|
||||
|
||||
PROBE = r'''(async () => {
|
||||
const options = __OPTIONS__;
|
||||
if (location.origin !== 'https://web.whatsapp.com') throw Error('Unexpected origin');
|
||||
const table = require('WAWebSchemaMessage').getMessageTable();
|
||||
const serializer = require('WAWebDBMessageSerialization');
|
||||
const names = await indexedDB.databases();
|
||||
if (!names.some(d => d.name === 'model-storage')) throw Error('Database absent');
|
||||
const db = await new Promise((resolve,reject) => {
|
||||
const r=indexedDB.open('model-storage');
|
||||
r.onupgradeneeded=()=>{r.transaction.abort();reject(Error('Refusing database creation or upgrade'));};
|
||||
r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database open failed'));
|
||||
r.onblocked=()=>reject(Error('Database blocked'));
|
||||
});
|
||||
const request=r=>new Promise((resolve,reject)=>{r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database read failed'));});
|
||||
try {
|
||||
if (!db.objectStoreNames.contains('message')) throw Error('Message store absent');
|
||||
if (options.mode==='bounds') {
|
||||
const store=db.transaction('message','readonly').objectStore('message');
|
||||
const [count,last]=await Promise.all([request(store.count()),request(store.openKeyCursor(null,'prev'))]);
|
||||
const upper=last?.primaryKey??null;
|
||||
if(upper!==null&&typeof upper!=='string') throw Error('Unsupported primary key type');
|
||||
return {count,upper};
|
||||
}
|
||||
const keys=await new Promise((resolve,reject)=>{
|
||||
const tx=db.transaction('message','readonly'),store=tx.objectStore('message'),values=[];
|
||||
const range=options.after===null?IDBKeyRange.upperBound(options.upper):IDBKeyRange.bound(options.after,options.upper,true,false);
|
||||
const r=store.openKeyCursor(range);
|
||||
r.onerror=()=>reject(Error('Cursor read failed'));tx.onabort=()=>reject(Error('Cursor transaction aborted'));
|
||||
r.onsuccess=()=>{const c=r.result;if(!c||values.length===options.batch){resolve(values);return;}
|
||||
if(typeof c.primaryKey!=='string'){reject(Error('Unsupported primary key type'));return;}
|
||||
values.push(c.primaryKey);c.continue();};
|
||||
});
|
||||
const messages=[];
|
||||
const asString=v=>v==null?null:typeof v==='string'?v:v.toString();
|
||||
for(const key of keys){
|
||||
try {
|
||||
// Default get(..., true) follows WAWebIdb.asyncView -> decryptDbMaterial.
|
||||
const row=await table.get(key);
|
||||
if(!row){messages.push({id:key,error:'row_missing_during_live_scan'});continue;}
|
||||
if(row.msgRowOpaqueData&&typeof row.msgRowOpaqueData==='object'&&'_data' in row.msgRowOpaqueData)
|
||||
throw Error('Opaque payload still encrypted');
|
||||
const m=serializer.messageFromDbRow(row),id=m.id;
|
||||
const out={id:key,message_id:id?.id??null,chat_id:asString(id?.remote),from_me:id?.fromMe??null,
|
||||
type:m.type??null,timestamp:m.t??null,body_length:typeof m.body==='string'?m.body.length:null,
|
||||
caption_length:typeof m.caption==='string'?m.caption.length:null};
|
||||
if(!options.redact){out.body=typeof m.body==='string'?m.body:null;out.caption=typeof m.caption==='string'?m.caption:null;}
|
||||
messages.push(out);
|
||||
}catch(e){messages.push({id:key,error:'row_decryption_or_decoding_failed',error_type:e?.name??'Error'});}
|
||||
}
|
||||
return {keys,messages,last:keys.length?keys[keys.length-1]:options.after};
|
||||
}finally{db.close();}
|
||||
})()'''
|
||||
|
||||
def desktop_ports():
|
||||
# Read process metadata only; never start a process with debugging flags.
|
||||
script = r'''$p=@(Get-CimInstance Win32_Process -Filter "Name='WhatsApp.Root.exe' OR Name='msedgewebview2.exe'");$ids=@($p | Where-Object Name -eq 'WhatsApp.Root.exe' | ForEach-Object ProcessId);for($i=0;$i -lt 8;$i++){$ids+=@($p | Where-Object {$_.ParentProcessId -in $ids} | ForEach-Object ProcessId);$ids=@($ids | Select-Object -Unique)};$r=@($p | Where-Object {$_.ProcessId -in $ids -and $_.CommandLine -match '--remote-debugging-port=(\d+)'} | ForEach-Object {[int]$Matches[1]});ConvertTo-Json -InputObject $r -Compress'''
|
||||
result=subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-Command',script],capture_output=True,text=True,check=True)
|
||||
return set(json.loads(result.stdout or '[]'))
|
||||
|
||||
def evaluate(ws, expression, ident):
|
||||
ws.send(json.dumps({'id':ident,'method':'Runtime.evaluate','params':{'expression':expression,'returnByValue':True,'awaitPromise':True}}))
|
||||
while True:
|
||||
response=json.loads(ws.recv(timeout=120))
|
||||
if response.get('id')!=ident:continue
|
||||
if response.get('error') or response.get('result',{}).get('exceptionDetails'):
|
||||
raise RuntimeError('WhatsApp database query failed; session may be locked or modules unavailable')
|
||||
return response['result']['result']['value']
|
||||
|
||||
def expression(options):
|
||||
return PROBE.replace('__OPTIONS__',json.dumps(options))
|
||||
|
||||
def export(cdp, output, batch=100, redact=False):
|
||||
output=Path(output)
|
||||
if output.exists() or output.with_suffix(output.suffix+'.partial').exists():
|
||||
raise RuntimeError('Output already exists; choose a new --output path')
|
||||
return _export(cdp,output,batch,redact)
|
||||
|
||||
def _export(cdp, output, batch=100, redact=False):
|
||||
from websockets.sync.client import connect
|
||||
ports=desktop_ports()
|
||||
if not ports:
|
||||
raise RuntimeError('Running WhatsApp Desktop exposes no CDP interface. ReadProcessMemory cannot execute its database decryption methods; Desktop was not restarted or modified.')
|
||||
if cdp is None:
|
||||
if len(ports)!=1:raise RuntimeError('Multiple Desktop CDP ports; supply --cdp')
|
||||
cdp='http://127.0.0.1:'+str(next(iter(ports)))
|
||||
parsed=urllib.parse.urlsplit(cdp)
|
||||
if (parsed.port or (443 if parsed.scheme=='https' else 80)) not in ports:
|
||||
raise RuntimeError('Endpoint does not belong to a detected WhatsApp Desktop debugging port')
|
||||
with urllib.request.urlopen(cdp+'/json/list',timeout=10) as response:targets=json.load(response)
|
||||
pages=[t for t in targets if t.get('type')=='page' and urllib.parse.urlsplit(t.get('url','')).hostname=='web.whatsapp.com']
|
||||
if len(pages)!=1:raise RuntimeError('Expected one WhatsApp Desktop page target')
|
||||
url=pages[0]['webSocketDebuggerUrl']
|
||||
if urllib.parse.urlsplit(url).hostname not in ('127.0.0.1','::1'):raise RuntimeError('Non-loopback WebSocket refused')
|
||||
output=Path(output)
|
||||
partial=output.with_suffix(output.suffix+'.partial')
|
||||
report_path=output.with_suffix(output.suffix+'.report.json')
|
||||
if output.exists() or partial.exists():raise RuntimeError('Output already exists; choose a new --output path')
|
||||
output.parent.mkdir(parents=True,exist_ok=True)
|
||||
report={'status':'running','rows':0,'row_errors':0,'snapshot_atomic':False,
|
||||
'scope':'All encountered local message rows up to initial upper primary key; no phone sync',
|
||||
'rea_evidence':'ev_8be2c4d68d5e268d7782a8098715202eaeda83625aa5b7f25277b09929f6995e'}
|
||||
try:
|
||||
with connect(url,max_size=16_000_000,open_timeout=10) as ws:
|
||||
bounds=evaluate(ws,expression({'mode':'bounds'}),1)
|
||||
report['initial_row_count']=bounds['count']
|
||||
upper,after,ident=bounds['upper'],None,2
|
||||
with partial.open('x',encoding='utf-8') as stream:
|
||||
while upper is not None and after!=upper:
|
||||
result=evaluate(ws,expression({'mode':'batch','upper':upper,'after':after,'batch':batch,'redact':redact}),ident)
|
||||
ident+=1
|
||||
if not result['keys']:break
|
||||
if result['last']==after:raise RuntimeError('Cursor made no progress')
|
||||
if len(result['messages'])!=len(result['keys']):raise RuntimeError('Incomplete batch response')
|
||||
for message in result['messages']:
|
||||
stream.write(json.dumps(message,ensure_ascii=False)+'\n')
|
||||
report['rows']+=1
|
||||
report['row_errors']+=int('error' in message)
|
||||
stream.flush();after=result['last']
|
||||
print(json.dumps({'exported_rows':report['rows'],'row_errors':report['row_errors']}),flush=True)
|
||||
report['final_row_count']=evaluate(ws,expression({'mode':'bounds'}),ident)['count']
|
||||
report['status']='completed_with_row_errors' if report['row_errors'] else 'completed_live_local_scan'
|
||||
partial.rename(output)
|
||||
report['output']=str(output.resolve())
|
||||
except Exception:
|
||||
report['status']='failed_partial_export'
|
||||
raise
|
||||
finally:
|
||||
report_path.write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
return report
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--cdp',type=endpoint)
|
||||
p.add_argument('--batch-size',type=int,default=100)
|
||||
p.add_argument('--redact-text',action='store_true')
|
||||
p.add_argument('--output',type=Path,default=Path('whatsapp-local-history.jsonl'))
|
||||
args=p.parse_args(argv)
|
||||
if not 1<=args.batch_size<=500:p.error('--batch-size must be 1..500')
|
||||
try:report=export(args.cdp,args.output,args.batch_size,args.redact_text)
|
||||
except (RuntimeError,OSError) as error:print(str(error));return 2
|
||||
print(json.dumps(report));return 0 if not report['row_errors'] else 1
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Bounded oldest-message reading through the app's JavaScript database API."""
|
||||
|
||||
PROBE = r'''(async () => {
|
||||
if(location.origin!=='https://web.whatsapp.com')throw Error('Unexpected origin');
|
||||
const table=require('WAWebSchemaMessage').getMessageTable();
|
||||
const serializer=require('WAWebDBMessageSerialization');
|
||||
if(!(await indexedDB.databases()).some(d=>d.name==='model-storage'))throw Error('Database absent');
|
||||
const db=await new Promise((resolve,reject)=>{
|
||||
const r=indexedDB.open('model-storage');
|
||||
r.onupgradeneeded=()=>{r.transaction.abort();reject(Error('Refusing database upgrade'));};
|
||||
r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(Error('Database open failed'));
|
||||
});
|
||||
let scanned=0;
|
||||
try {
|
||||
const oldest=await new Promise((resolve,reject)=>{
|
||||
const tx=db.transaction('message','readonly'),store=tx.objectStore('message'),rows=[];
|
||||
const r=store.openCursor();
|
||||
r.onerror=()=>reject(Error('Cursor failed'));tx.onabort=()=>reject(Error('Transaction aborted'));
|
||||
r.onsuccess=()=>{
|
||||
const c=r.result;if(!c){resolve(rows);return;}
|
||||
if(++scanned>100000) {reject(Error('History scan exceeds 100000 rows'));return;}
|
||||
const t=c.value.t;
|
||||
if(typeof t==='number'&&Number.isFinite(t)&&t>0){
|
||||
rows.push({key:c.primaryKey,t});rows.sort((a,b)=>a.t-b.t);if(rows.length>5)rows.pop();
|
||||
}
|
||||
c.continue();
|
||||
};
|
||||
});
|
||||
const messages=[];
|
||||
for(const candidate of oldest){
|
||||
const row=await table.get(candidate.key);
|
||||
if(!row)throw Error('Selected row disappeared');
|
||||
if(row.msgRowOpaqueData&&typeof row.msgRowOpaqueData==='object'&&'_data' in row.msgRowOpaqueData)
|
||||
throw Error('Payload remains encrypted');
|
||||
const m=serializer.messageFromDbRow(row);
|
||||
messages.push({id:candidate.key,timestamp:m.t,type:m.type,
|
||||
body:typeof m.body==='string'?m.body:null,caption:typeof m.caption==='string'?m.caption:null,
|
||||
selected_timestamp:candidate.t});
|
||||
}
|
||||
return {scanned,sample_limit:5,source:'WAWebSchemaMessage.getMessageTable().get + WAWebDBMessageSerialization.messageFromDbRow',
|
||||
cdp:false,messages};
|
||||
}finally{db.close();}
|
||||
})()'''
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Inject into normal WhatsApp Desktop and query its existing native FTS index.
|
||||
|
||||
Experimental WinRT ABI is tied to REA-analyzed build 2.2639.100.0. No CDP or UI.
|
||||
"""
|
||||
import argparse, json, subprocess
|
||||
from pathlib import Path
|
||||
from .common import asset_bytes
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('query',nargs='?')
|
||||
p.add_argument('--pid',type=int)
|
||||
p.add_argument('--limit',type=int,default=100)
|
||||
p.add_argument('--max-mb',type=int,default=512)
|
||||
p.add_argument('--output',type=Path,default=Path('whatsapp-injected-search.json'))
|
||||
p.add_argument('--redact-text',action='store_true')
|
||||
p.add_argument('--export-index',action='store_true',help='Export all rows of the native search index as JSONL')
|
||||
p.add_argument('--oldest',action='store_true',help='Read the oldest indexed messages with nonempty text (bounded by --limit)')
|
||||
p.add_argument('--batch-size',type=int,default=200)
|
||||
args=p.parse_args(argv)
|
||||
import frida
|
||||
if not 1<=args.limit<=1000 or not 16<=args.max_mb<=4096:p.error('Invalid limit or scan budget')
|
||||
if not 1<=args.batch_size<=500:p.error('--batch-size must be 1..500')
|
||||
if args.output.exists():p.error('Output exists; select a new --output')
|
||||
command="@(Get-CimInstance Win32_Process -Filter \"Name='WhatsApp.Root.exe'\" | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress"
|
||||
data=json.loads(subprocess.check_output(['powershell.exe','-NoProfile','-Command',command],text=True) or '[]')
|
||||
pids=data if isinstance(data,list) else [data]
|
||||
if args.pid:
|
||||
if args.pid not in pids:p.error('PID must be a running WhatsApp.Root.exe')
|
||||
pids=[args.pid]
|
||||
reports=[]
|
||||
exported_path=args.output.with_suffix('.messages.jsonl')
|
||||
partial_path=exported_path.with_suffix(exported_path.suffix+'.partial')
|
||||
if args.export_index and (exported_path.exists() or partial_path.exists()):p.error('Export output exists; choose a new --output')
|
||||
exported_stream=partial_path.open('x',encoding='utf-8') if args.export_index else None
|
||||
source=asset_bytes('sqlite_inject.js').decode('utf-8')
|
||||
for pid in pids:
|
||||
session=None;script=None
|
||||
try:
|
||||
session=frida.attach(pid);script=session.create_script(source);script.load()
|
||||
discovered=script.exports_sync.discover(args.max_mb)
|
||||
result={'pid':pid,'native_sqlite':discovered,'matches':[]}
|
||||
if args.export_index:
|
||||
result['index_exports']=[]
|
||||
for db in discovered['databases']:
|
||||
if not {'message','message_fts'}.issubset(db['tables']):continue
|
||||
expected,upper=script.exports_sync.indexbounds(db['id'])
|
||||
after='0';rows_written=0;text_count=0;seen=set()
|
||||
while True:
|
||||
rows=script.exports_sync.exportindex(db['id'],after,upper,args.batch_size)
|
||||
if not rows:break
|
||||
for row in rows:
|
||||
native_rowid=int(row[0])
|
||||
if native_rowid in seen or native_rowid<=int(after):raise RuntimeError('Duplicate/nonmonotonic row returned')
|
||||
seen.add(native_rowid)
|
||||
record={'pid':pid,'database_id':db['id'],'native_rowid':row[0],
|
||||
'message_row_id':row[1],'chat_id':row[2],'timestamp':row[3]}
|
||||
if row[4] is not None:text_count+=1
|
||||
if args.redact_text:record['indexed_text_length']=len(row[4]) if row[4] is not None else None
|
||||
else:record['indexed_text']=row[4]
|
||||
exported_stream.write(json.dumps(record,ensure_ascii=False)+'\n')
|
||||
rows_written+=1
|
||||
after=rows[-1][0];exported_stream.flush()
|
||||
final_count=script.exports_sync.indexbounds(db['id'])[0]
|
||||
result['index_exports'].append({'database_id':db['id'],'initial_count':int(expected),
|
||||
'final_count':int(final_count),'rows_written':rows_written,
|
||||
'rows_with_text':text_count,'duplicate_rowids':0,
|
||||
'snapshot_atomic':False})
|
||||
if args.query or args.oldest:
|
||||
for db in discovered['databases']:
|
||||
if not {'message','message_fts'}.issubset(db['tables']):continue
|
||||
count,earliest,latest=script.exports_sync.stats(db['id'])
|
||||
db['indexed_message_count']=int(count)
|
||||
db['earliest_indexed_timestamp']=earliest
|
||||
db['latest_indexed_timestamp']=latest
|
||||
rows = script.exports_sync.oldest(db['id'], args.limit) if args.oldest else script.exports_sync.search(db['id'],args.query,args.limit)
|
||||
for row in rows:
|
||||
message={'database_id':db['id'],'message_row_id':row[0],'chat_id':row[1],'timestamp':row[2]}
|
||||
if args.redact_text:message['indexed_text_length']=len(row[3]) if row[3] is not None else None
|
||||
else:message['indexed_text']=row[3]
|
||||
result['matches'].append(message)
|
||||
reports.append(result)
|
||||
except Exception as error:reports.append({'pid':pid,'error':str(error),'matches':[]})
|
||||
finally:
|
||||
if script:
|
||||
try:script.exports_sync.close()
|
||||
except Exception:pass
|
||||
if session:session.detach()
|
||||
report={'mode':'Native process injection; existing WinRT SQLite connection; SELECT only',
|
||||
'cdp':False,'ui_automation':False,'results':reports,
|
||||
'rea_evidence':['ev_fd70aa06525b6f2259bf6d98bc879adb2472f63f2407a2d0d1186cc9e27b2f21',
|
||||
'ev_e744051231ce7d2e1f4c72c194492b1029b9e9336a5ff90145bb5d48d332ad8e'],
|
||||
'limits':['Search returns native indexed text, not decoded complete IndexedDB message objects.',
|
||||
'Whitespace/prefix query construction does not fully reproduce WhatsApp multilingual tokenization.',
|
||||
'COM layout is version-specific. Discovery and invocation are experimental.']}
|
||||
if exported_stream:
|
||||
exported_stream.close()
|
||||
successful=any(r.get('index_exports') for r in reports) and not any('error' in r for r in reports if r.get('index_exports'))
|
||||
if successful:
|
||||
partial_path.rename(exported_path)
|
||||
report['export_file']=str(exported_path.resolve())
|
||||
else:report['partial_export_file']=str(partial_path.resolve())
|
||||
args.output.write_text(json.dumps(report,indent=2,ensure_ascii=False),encoding='utf-8')
|
||||
print(json.dumps({'databases':sum(len(r.get('native_sqlite',{}).get('databases',[])) for r in reports),
|
||||
'matches':sum(len(r['matches']) for r in reports),
|
||||
'errors':[{'pid':r['pid'],'error':r['error']} for r in reports if 'error' in r],
|
||||
'report':str(args.output.resolve())}))
|
||||
return 0 if any(any({'message','message_fts'}.issubset(db['tables']) for db in r.get('native_sqlite',{}).get('databases',[])) and 'error' not in r for r in reports) else 2
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,589 @@
|
||||
"""Standalone fixed arithmetic and capability tests in the supported WhatsApp WebView.
|
||||
|
||||
Windows x64 and 64-bit Python; standard library only. The bundled native DLL is
|
||||
hash-verified and cached in the workspace. Capability tests use fixed queries,
|
||||
empty-input reads and simple getters. Explicit message-json and contacts
|
||||
suites serialize the fixed search results or a five-contact sample.
|
||||
There is no arbitrary JavaScript CLI and settings setters are never invoked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import ctypes
|
||||
from ctypes import wintypes as w
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import sys
|
||||
|
||||
from .common import BASE, verified_helper
|
||||
from .desktop import visible_ui_threads, detect_desktop_pid, check_core_build, SUPPORTED_BUILD, CORE_SHA256
|
||||
WAKE = 0x8000 + 0x498
|
||||
PROTOCOL = 0x4d415432
|
||||
# SHA-256 of assets/math_probe.dll.
|
||||
EMBEDDED_SHA256 = '136620d96f4745a4c41edfe1f4a9272ef25fc060ddace1c0eec1027bf252e02a'
|
||||
|
||||
CASES = (
|
||||
('(17 * 23) - 19', 372),
|
||||
('2 ** 10', 1024),
|
||||
('Math.sqrt(144) + Math.cbrt(27)', 15),
|
||||
('Math.hypot(3, 4)', 5),
|
||||
('Math.sin(Math.PI / 2)', 1),
|
||||
('[1,2,3,4,5].reduce((sum,n) => sum + n*n, 0)', 55),
|
||||
('0.1 + 0.2', 0.3),
|
||||
('(12345678901234567890n * 9n).toString()', '111111110111111111010'),
|
||||
)
|
||||
|
||||
# Fixed probes only. Availability checks never invoke the selected member.
|
||||
CAPABILITIES = (
|
||||
('search.entry', 'search', 'WAWebMsgCollection', 'MsgCollection.search', 'search'),
|
||||
('search.local', 'search', 'WAWebDBMessageFindLocal', 'msgFindSearch', 'presence'),
|
||||
('search.fts', 'search', 'WAWebFtsClient', 'ftsClient.search', 'presence'),
|
||||
('messages.by_id', 'messages', 'WAWebMsgCollection', 'MsgCollection.getMessagesById', 'empty_messages'),
|
||||
('messages.hydrate', 'messages', 'WAWebMsgCollection', 'MsgCollection.hydrateOrGetMessages', 'empty_array'),
|
||||
('messages.before', 'messages', 'WAWebDBMessageFindLocal', 'msgFindBefore', 'presence'),
|
||||
('messages.after', 'messages', 'WAWebDBMessageFindLocal', 'msgFindAfter', 'presence'),
|
||||
('messages.media', 'messages', 'WAWebDBMessageFindLocal', 'msgFindMedia', 'presence'),
|
||||
('messages.serialization', 'messages', 'WAWebDBMessageSerialization', 'messageFromDbRow', 'presence'),
|
||||
('storage.message_table', 'storage', 'WAWebSchemaMessage', 'getMessageTable', 'table_empty'),
|
||||
('storage.indexeddb', 'storage', 'WAWebIndexedDB', 'default.open', 'presence'),
|
||||
('storage.class', 'storage', 'WAWebStorage', 'Storage', 'presence'),
|
||||
('storage.permanent', 'storage', 'WAWebPermanentStorage', 'default.getItem', 'presence'),
|
||||
('settings.theme', 'settings', 'WAWebUserPrefsGeneral', 'getTheme', 'theme'),
|
||||
('settings.theme_setter', 'settings', 'WAWebUserPrefsGeneral', 'setTheme', 'presence'),
|
||||
('settings.dark_theme', 'settings', 'WAWebThemeSession', 'getIsDarkTheme', 'boolean'),
|
||||
('settings.notifications', 'settings', 'WAWebUserPrefsNotifications', 'getGlobalNotifications', 'flag'),
|
||||
('settings.notifications_setter', 'settings', 'WAWebUserPrefsNotifications', 'setGlobalNotifications', 'presence'),
|
||||
('settings.privacy', 'settings', 'WAWebUserPrefsGeneral', 'getUserPrivacySettings', 'privacy_shape'),
|
||||
('settings.auto_download', 'settings', 'WAWebUserPrefsGeneral', 'getAutoDownloadPhotos', 'boolean'),
|
||||
('settings.archive', 'settings', 'WAWebSettingsGetters', 'getArchive', 'presence'),
|
||||
('settings.apply', 'settings', 'WAWebSettingsBridgeApi', 'SettingsBridgeApi.applyAppSetting', 'presence'),
|
||||
('settings.sync', 'settings', 'WAWebSettingsSyncBridge', 'sendSettingChange', 'presence'),
|
||||
('chats.collection', 'chats', 'WAWebChatCollection', 'ChatCollection.get', 'presence'),
|
||||
('contacts.collection', 'contacts', 'WAWebContactCollection', 'ContactCollection.get', 'presence'),
|
||||
('bridge.ready', 'bridge', 'WAWebWindowsHybridBridgeFactory', 'getIsBridgeReady', 'boolean'),
|
||||
('bridge.get', 'bridge', 'WAWebWindowsHybridBridgeFactory', 'getWindowsBridge', 'presence'),
|
||||
('bridge.frontend', 'bridge', 'WAWebBackendApi', 'frontendSendAndReceive', 'presence'),
|
||||
('bridge.worker', 'bridge', 'WAWebWorkerSafeBackendApi', 'workerSafeSendAndReceive', 'presence'),
|
||||
)
|
||||
|
||||
|
||||
def capability_expressions(probe_id):
|
||||
key = '__whatsapp_fixed_capability_' + probe_id
|
||||
specs = json.dumps(CAPABILITIES, separators=(',', ':'))
|
||||
start = r'''(() => {
|
||||
const key=__KEY__,id=__ID__,specs=__SPECS__;
|
||||
const state={probe_id:id,state:'pending',checks:[]};
|
||||
window[key]=state;
|
||||
if(typeof require!=='function'){
|
||||
state.state='error';state.error='WhatsApp module loader unavailable';return {probe_id:id,state:'started'};
|
||||
}
|
||||
async function bounded(fn){
|
||||
let timer;
|
||||
try{return await Promise.race([Promise.resolve().then(fn),new Promise((_,reject)=>{
|
||||
timer=setTimeout(()=>reject(Error('Fixed getter/query timeout')),2500);
|
||||
})]);}finally{clearTimeout(timer);}
|
||||
}
|
||||
(async()=>{
|
||||
const loaded=new Map();
|
||||
for(const [test,area,module,path,action] of specs){
|
||||
const row={id:test,area,module,member:path,probe:action,invoked:false,passed:false};
|
||||
try{
|
||||
let exports=loaded.get(module);
|
||||
if(!exports){exports=require(module);loaded.set(module,exports);}
|
||||
const parts=path.split('.');let owner=exports;
|
||||
// Meta's global require may unwrap a default-only module.
|
||||
if(parts[0]==='default'&&parts.length>1&&exports?.default===undefined&&
|
||||
typeof exports?.[parts[parts.length-1]]==='function'){
|
||||
parts.shift();row.import_shape='default-export-unwrapped';
|
||||
}
|
||||
for(const part of parts.slice(0,-1))owner=owner?.[part];
|
||||
const value=owner?.[parts[parts.length-1]];
|
||||
row.available=typeof value==='function';
|
||||
if(!row.available){
|
||||
row.status='unavailable';
|
||||
row.summary={module_type:typeof exports,default_type:typeof exports?.default,
|
||||
direct_member_type:typeof exports?.[parts[parts.length-1]],
|
||||
export_names:exports&&typeof exports==='object'?Object.keys(exports).slice(0,12):[]};
|
||||
state.checks.push(row);continue;
|
||||
}
|
||||
if(action==='presence'){row.passed=true;row.status='available';}
|
||||
else{
|
||||
row.invoked=true;
|
||||
if(action==='search'){
|
||||
const result=await bounded(()=>value.call(owner,'meow',1,3));
|
||||
row.summary={query:'meow',matches:Array.isArray(result?.messages)?result.messages.length:null,
|
||||
canceled:result?.canceled===true,eof:result?.eof===true};
|
||||
row.passed=Array.isArray(result?.messages)&&result?.canceled!==true;
|
||||
}else if(action==='empty_messages'||action==='empty_array'){
|
||||
const result=await bounded(()=>value.call(owner,[]));
|
||||
const list=action==='empty_array'?result:result?.messages;
|
||||
row.summary={input_count:0,returned_count:Array.isArray(list)?list.length:null};
|
||||
row.passed=Array.isArray(list)&&list.length===0;
|
||||
}else if(action==='table_empty'){
|
||||
const table=value.call(owner);
|
||||
row.summary={methods:['get','bulkGet','all','count'].filter(name=>typeof table?.[name]==='function')};
|
||||
if(typeof table?.bulkGet!=='function')throw Error('Message table bulkGet unavailable in this context');
|
||||
const result=await bounded(()=>table.bulkGet([]));
|
||||
row.summary.input_count=0;row.summary.returned_count=Array.isArray(result)?result.length:null;
|
||||
row.passed=Array.isArray(result)&&result.length===0;
|
||||
}else{
|
||||
const result=await bounded(()=>value.call(owner));
|
||||
if(action==='privacy_shape'){
|
||||
row.summary={type:typeof result,fields:result&&typeof result==='object'?Object.keys(result).sort():[]};
|
||||
row.passed=result!==null&&typeof result==='object'&&!Array.isArray(result);
|
||||
}else if(action==='theme'){
|
||||
row.summary={type:typeof result};
|
||||
if(['light','dark','system','auto'].includes(result))row.summary.value=result;
|
||||
row.passed=typeof result==='string';
|
||||
}else{
|
||||
row.summary={type:typeof result};
|
||||
row.passed=action==='boolean'?typeof result==='boolean':
|
||||
result==null||typeof result==='boolean'||typeof result==='number';
|
||||
}
|
||||
}
|
||||
row.status=row.passed?'passed':'unexpected-result';
|
||||
}
|
||||
}catch(error){row.status='error';row.error=String(error).slice(0,400);}
|
||||
state.checks.push(row);
|
||||
}
|
||||
state.state='done';
|
||||
})().catch(error=>{state.state='error';state.error=String(error).slice(0,400);});
|
||||
return {probe_id:id,state:'started'};
|
||||
})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id)).replace('__SPECS__', specs)
|
||||
poll = '''(() => {const key=__KEY__,state=window[key];
|
||||
if(!state)return {probe_id:__ID__,state:'error',error:'Probe state missing'};
|
||||
if(state.state==='done'||state.state==='error')delete window[key];
|
||||
return state;})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id))
|
||||
remove = 'delete window[' + json.dumps(key) + ']'
|
||||
if len(start.encode('utf-16-le')) // 2 >= 8192:
|
||||
raise ValueError('Fixed capability probe exceeds native input buffer')
|
||||
return start, poll, remove
|
||||
|
||||
|
||||
def validate_capabilities(parsed, probe_id):
|
||||
if parsed.get('probe_id') != probe_id or parsed.get('state') != 'done':
|
||||
raise ValueError('Capability probe incomplete: ' + str(parsed.get('error', parsed.get('state'))))
|
||||
checks = parsed.get('checks', [])
|
||||
if [row.get('id') for row in checks] != [spec[0] for spec in CAPABILITIES]:
|
||||
raise ValueError('Capability response does not match the fixed test list')
|
||||
return checks
|
||||
|
||||
|
||||
def data_expressions(probe_id, kind):
|
||||
"""Fixed meow search or loaded-contact sample; no arbitrary query/code input."""
|
||||
key = '__whatsapp_fixed_data_' + probe_id
|
||||
start = r'''(() => {
|
||||
const key=__KEY__,id=__ID__,kind=__KIND__;
|
||||
const state={probe_id:id,state:'pending'};window[key]=state;
|
||||
function snapshot(model){
|
||||
if(typeof model?.serialize!=='function')throw Error('App model serializer unavailable');
|
||||
return model.serialize();
|
||||
}
|
||||
Promise.resolve().then(async()=>{
|
||||
if(typeof require!=='function')throw Error('WhatsApp module loader unavailable');
|
||||
let payload;
|
||||
if(kind==='message-json'){
|
||||
const collection=require('WAWebMsgCollection').MsgCollection;
|
||||
const result=await collection.search('meow',1,3);
|
||||
if(!Array.isArray(result?.messages)||result?.canceled)throw Error('Search did not complete');
|
||||
payload={probe_id:id,kind,query:'meow',matched_count:result.messages.length,
|
||||
eof:result.eof===true,serializer:'BaseModel.serialize',messages:result.messages.map(snapshot)};
|
||||
}else if(kind==='history-read'){
|
||||
payload={probe_id:id,kind,...await __HISTORY_PROBE__};
|
||||
}else{
|
||||
const collection=require('WAWebContactCollection').ContactCollection;
|
||||
if(typeof collection?.getModelsArray!=='function')throw Error('Contact getter unavailable');
|
||||
const contacts=collection.getModelsArray();
|
||||
if(!Array.isArray(contacts))throw Error('Contact getter returned an unexpected type');
|
||||
payload={probe_id:id,kind,loaded_count:contacts.length,sample_limit:5,
|
||||
serializer:'BaseModel.serialize',contacts:contacts.slice(0,5).map(snapshot)};
|
||||
}
|
||||
const seen=new WeakMap();let reference=0;
|
||||
state.json=JSON.stringify(payload,function(name,value){
|
||||
if(value===undefined)return {$type:'undefined'};
|
||||
if(typeof value==='bigint')return {$type:'BigInt',decimal:value.toString()};
|
||||
if(typeof value==='function')return {$type:'function',name:value.name};
|
||||
if(value&&typeof value==='object'){
|
||||
if(seen.has(value))return {$ref:seen.get(value)};
|
||||
seen.set(value,++reference);
|
||||
if(value instanceof ArrayBuffer)return {$type:'ArrayBuffer',bytes:Array.from(new Uint8Array(value))};
|
||||
if(ArrayBuffer.isView(value))return {$type:value.constructor.name,bytes:Array.from(new Uint8Array(value.buffer,value.byteOffset,value.byteLength))};
|
||||
if(value instanceof Map)return {$type:'Map',entries:Array.from(value.entries())};
|
||||
if(value instanceof Set)return {$type:'Set',values:Array.from(value.values())};
|
||||
}
|
||||
return value;
|
||||
});
|
||||
state.state='ready';
|
||||
}).catch(error=>{state.state='error';state.error=String(error).slice(0,400);});
|
||||
return {probe_id:id,state:'started'};
|
||||
})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id)).replace('__KIND__', json.dumps(kind))
|
||||
from .history_read import PROBE as history_probe
|
||||
start = start.replace('__HISTORY_PROBE__', history_probe)
|
||||
poll = '''(() => {const value=window[__KEY__];if(!value)return {probe_id:__ID__,state:'error',error:'Data probe state missing'};
|
||||
return {probe_id:value.probe_id,state:value.state,error:value.error,length:value.json?.length};})()'''.replace('__KEY__', json.dumps(key)).replace('__ID__', json.dumps(probe_id))
|
||||
return start, poll, key, 'delete window[' + json.dumps(key) + ']'
|
||||
|
||||
|
||||
def data_chunk_expression(key, offset):
|
||||
return '''(() => {const value=window[__KEY__];if(value?.state!=='ready')throw Error('Data not ready');
|
||||
const start=__OFFSET__;let end=Math.min(start+4096,value.json.length);
|
||||
if(end<value.json.length&&value.json.charCodeAt(end-1)>=0xd800&&value.json.charCodeAt(end-1)<=0xdbff)end++;
|
||||
return {probe_id:value.probe_id,offset:start,next:end,chunk:value.json.slice(start,end)};})()'''.replace('__KEY__', json.dumps(key)).replace('__OFFSET__', str(offset))
|
||||
|
||||
|
||||
def extract_helper():
|
||||
return verified_helper('math_probe.dll', EMBEDDED_SHA256, '.whatsapp-math-native', 'math_probe.dll')
|
||||
|
||||
|
||||
def fixed_expression(probe_id):
|
||||
values = ','.join('{expression:' + json.dumps(expression) + ',value:(' + expression + ')}'
|
||||
for expression, _ in CASES)
|
||||
return '({probe_id:' + json.dumps(probe_id) + ',cases:[' + values + ']})'
|
||||
|
||||
|
||||
def validate_result(raw, probe_id):
|
||||
parsed = json.loads(raw)
|
||||
if parsed.get('probe_id') != probe_id or len(parsed.get('cases', [])) != len(CASES):
|
||||
raise ValueError('Arithmetic response does not match this run')
|
||||
results = []
|
||||
for (expression, expected), observed in zip(CASES, parsed['cases']):
|
||||
value = observed.get('value')
|
||||
passed = observed.get('expression') == expression
|
||||
if isinstance(expected, float):
|
||||
passed = passed and type(value) in (int, float) and math.isclose(
|
||||
value, expected, rel_tol=1e-12, abs_tol=1e-12)
|
||||
else:
|
||||
passed = passed and type(value) is type(expected) and value == expected
|
||||
results.append(dict(expression=expression, expected=expected, actual=value, passed=passed))
|
||||
return results
|
||||
|
||||
|
||||
class Packet(ctypes.Structure):
|
||||
_fields_ = [('state', w.LONG), ('hr', w.LONG), ('thread', w.DWORD),
|
||||
('protocol', w.DWORD), ('script', w.WCHAR * 8192),
|
||||
('result', w.WCHAR * 32768)]
|
||||
|
||||
|
||||
def windows_api():
|
||||
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
user = ctypes.WinDLL('user32', use_last_error=True)
|
||||
declarations = {
|
||||
'CreateFileMappingW': ([w.HANDLE, ctypes.c_void_p, w.DWORD, w.DWORD, w.DWORD, w.LPCWSTR], w.HANDLE),
|
||||
'MapViewOfFile': ([w.HANDLE, w.DWORD, w.DWORD, w.DWORD, ctypes.c_size_t], ctypes.c_void_p),
|
||||
'UnmapViewOfFile': ([ctypes.c_void_p], w.BOOL),
|
||||
'CloseHandle': ([w.HANDLE], w.BOOL),
|
||||
'LoadLibraryW': ([w.LPCWSTR], w.HMODULE),
|
||||
'FreeLibrary': ([w.HMODULE], w.BOOL),
|
||||
'GetProcAddress': ([w.HMODULE, ctypes.c_char_p], ctypes.c_void_p),
|
||||
'OpenProcess': ([w.DWORD, w.BOOL, w.DWORD], w.HANDLE),
|
||||
'WaitForSingleObject': ([w.HANDLE, w.DWORD], w.DWORD),
|
||||
}
|
||||
for name, (arguments, returns) in declarations.items():
|
||||
function = getattr(kernel, name)
|
||||
function.argtypes = arguments
|
||||
function.restype = returns
|
||||
user.SetWindowsHookExW.argtypes = [ctypes.c_int, ctypes.c_void_p, w.HMODULE, w.DWORD]
|
||||
user.SetWindowsHookExW.restype = w.HANDLE
|
||||
user.UnhookWindowsHookEx.argtypes = [w.HANDLE]
|
||||
user.UnhookWindowsHookEx.restype = w.BOOL
|
||||
user.PostThreadMessageW.argtypes = [w.DWORD, w.UINT, w.WPARAM, w.LPARAM]
|
||||
user.PostThreadMessageW.restype = w.BOOL
|
||||
return kernel, user
|
||||
|
||||
|
||||
def wait_for_state(packet, desired, seconds, wake, alive, phase):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
if not alive():
|
||||
raise RuntimeError('WhatsApp exited during ' + phase)
|
||||
wake()
|
||||
time.sleep(.025)
|
||||
if packet.state == desired:
|
||||
return
|
||||
if packet.state == -1:
|
||||
raise RuntimeError(phase + ': native error 0x' + format(packet.hr & 0xffffffff, '08x'))
|
||||
if phase == 'startup':
|
||||
raise TimeoutError('Helper startup not acknowledged within ' + str(seconds) +
|
||||
's; no JavaScript requested. Check DLL path access in packaged WhatsApp.')
|
||||
raise TimeoutError(phase + ' timed out (native state ' + str(packet.state) + ')')
|
||||
|
||||
|
||||
def helper_loaded(pid, path):
|
||||
"""Observe Windows' asynchronous hook-DLL unloading using module metadata."""
|
||||
class ModuleEntry(ctypes.Structure):
|
||||
_fields_ = [('dwSize', w.DWORD), ('th32ModuleID', w.DWORD),
|
||||
('th32ProcessID', w.DWORD), ('GlblcntUsage', w.DWORD),
|
||||
('ProccntUsage', w.DWORD), ('modBaseAddr', ctypes.c_void_p),
|
||||
('modBaseSize', w.DWORD), ('hModule', w.HMODULE),
|
||||
('szModule', w.WCHAR * 256), ('szExePath', w.WCHAR * 260)]
|
||||
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
kernel.CreateToolhelp32Snapshot.argtypes = [w.DWORD, w.DWORD]
|
||||
kernel.CreateToolhelp32Snapshot.restype = w.HANDLE
|
||||
kernel.CloseHandle.argtypes = [w.HANDLE]
|
||||
kernel.CloseHandle.restype = w.BOOL
|
||||
for name in ('Module32FirstW', 'Module32NextW'):
|
||||
function = getattr(kernel, name)
|
||||
function.argtypes = [w.HANDLE, ctypes.POINTER(ModuleEntry)]
|
||||
function.restype = w.BOOL
|
||||
snapshot = kernel.CreateToolhelp32Snapshot(8 | 16, pid)
|
||||
if snapshot == ctypes.c_void_p(-1).value:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
try:
|
||||
entry = ModuleEntry(dwSize=ctypes.sizeof(ModuleEntry))
|
||||
present = kernel.Module32FirstW(snapshot, ctypes.byref(entry))
|
||||
while present:
|
||||
if os.path.normcase(entry.szExePath) == os.path.normcase(str(path)):
|
||||
return True
|
||||
present = kernel.Module32NextW(snapshot, ctypes.byref(entry))
|
||||
error = ctypes.get_last_error()
|
||||
if error != 18: # ERROR_NO_MORE_FILES
|
||||
raise ctypes.WinError(error)
|
||||
return False
|
||||
finally:
|
||||
kernel.CloseHandle(snapshot)
|
||||
|
||||
|
||||
def run_math(pid=None, suite='math', _operation=None):
|
||||
scopes = {'math': 'fixed-arithmetic-only', 'capabilities': 'fixed-capability-probes',
|
||||
'all': 'fixed-arithmetic-and-capability-probes',
|
||||
'message-json': 'fixed-meow-message-models', 'contacts': 'loaded-contact-sample',
|
||||
'history-read': 'oldest-local-messages-through-javascript-no-cdp',
|
||||
'history-export': 'all-local-message-rows-through-javascript-no-cdp',
|
||||
'send-self': 'one-text-message-to-own-account-through-javascript',
|
||||
'contacts-export': 'all-local-contact-records-through-javascript'}
|
||||
report = {'passed': False, 'scope': scopes.get(suite, 'invalid-suite'),
|
||||
'suite': suite, 'frida_used': False,
|
||||
'startup_acknowledged': False, 'javascript_executed': False,
|
||||
'cleanup': {'acknowledged': False, 'hook_removed': False},
|
||||
'supported_build': SUPPORTED_BUILD}
|
||||
kernel = user = None
|
||||
module = hook = mapping = view = process = None
|
||||
packet = None
|
||||
remove_expression = None
|
||||
execute = None
|
||||
try:
|
||||
if suite not in scopes:
|
||||
raise ValueError('Unknown suite; choose math, capabilities, all, message-json or contacts')
|
||||
if os.name != 'nt' or ctypes.sizeof(ctypes.c_void_p) != 8:
|
||||
raise ValueError('Windows and 64-bit Python are required')
|
||||
selected = detect_desktop_pid(pid)
|
||||
thread = next(iter(visible_ui_threads(selected)))
|
||||
report.update(pid=selected, ui_thread_id=thread, pid_auto_detected=pid is None)
|
||||
kernel, user = windows_api()
|
||||
process = kernel.OpenProcess(0x100000, False, selected) # SYNCHRONIZE only
|
||||
if not process:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
dll, cache = extract_helper()
|
||||
report.update(native_helper_path=str(dll), cache=cache)
|
||||
ctypes.set_last_error(0)
|
||||
mapping = kernel.CreateFileMappingW(w.HANDLE(-1), None, 4, 0, ctypes.sizeof(Packet),
|
||||
'Local\\WhatsAppMathProbe-v2-' + str(selected))
|
||||
mapping_error = ctypes.get_last_error()
|
||||
if not mapping:
|
||||
raise ctypes.WinError(mapping_error)
|
||||
if mapping_error == 183:
|
||||
raise ValueError('Another math probe owns this WhatsApp instance; no hook installed')
|
||||
view = kernel.MapViewOfFile(mapping, 0xf001f, 0, 0, ctypes.sizeof(Packet))
|
||||
if not view:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
packet = Packet.from_address(view)
|
||||
packet.thread = thread
|
||||
packet.protocol = PROTOCOL
|
||||
packet.state = 3 # Startup only: native helper must acknowledge before any JS.
|
||||
module = kernel.LoadLibraryW(str(dll))
|
||||
if not module:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
callback = kernel.GetProcAddress(module, b'ProbeHook')
|
||||
if not callback:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
hook = user.SetWindowsHookExW(3, callback, module, thread)
|
||||
if not hook:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
def wake():
|
||||
if not user.PostThreadMessageW(thread, WAKE, 0, 0):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
|
||||
def alive():
|
||||
status = kernel.WaitForSingleObject(process, 0)
|
||||
if status == 0xffffffff:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
return status == 258
|
||||
|
||||
started = time.monotonic()
|
||||
wait_for_state(packet, 4, 5, wake, alive, 'startup')
|
||||
report.update(startup_acknowledged=True, startup_seconds=round(time.monotonic() - started, 3))
|
||||
probe_id = uuid.uuid4().hex
|
||||
report['probe_id'] = probe_id
|
||||
|
||||
def execute(expression, phase):
|
||||
if len(expression.encode('utf-16-le')) // 2 >= 8192:
|
||||
raise ValueError('Fixed probe exceeds native input buffer')
|
||||
packet.script = expression
|
||||
packet.result = ''
|
||||
packet.hr = 0
|
||||
packet.state = 0
|
||||
wait_for_state(packet, 2, 20, wake, alive, phase)
|
||||
report['javascript_executed'] = True
|
||||
return packet.result
|
||||
|
||||
if suite in ('history-export', 'send-self', 'contacts-export'):
|
||||
if _operation is None:
|
||||
raise ValueError('Use the export-zip command for history export')
|
||||
_operation(execute, probe_id, report)
|
||||
report['operation_passed'] = True
|
||||
|
||||
if suite in ('math', 'all'):
|
||||
report['tests'] = validate_result(execute(fixed_expression(probe_id), 'arithmetic'), probe_id)
|
||||
report['math_passed'] = all(result['passed'] for result in report['tests'])
|
||||
if suite in ('capabilities', 'all'):
|
||||
start, poll, remove_expression = capability_expressions(probe_id)
|
||||
initial = json.loads(execute(start, 'capability startup'))
|
||||
if initial.get('probe_id') != probe_id or initial.get('state') != 'started':
|
||||
raise ValueError('Capability startup response does not match this run')
|
||||
deadline = time.monotonic() + 80
|
||||
while time.monotonic() < deadline:
|
||||
parsed = json.loads(execute(poll, 'capability poll'))
|
||||
if parsed.get('state') != 'pending':
|
||||
checks = validate_capabilities(parsed, probe_id)
|
||||
report['capabilities'] = checks
|
||||
report['capability_summary'] = {
|
||||
'total': len(checks), 'passed': sum(row['passed'] for row in checks),
|
||||
'invoked': sum(row['invoked'] for row in checks),
|
||||
'unavailable': sum(row.get('status') == 'unavailable' for row in checks),
|
||||
'errors': sum(row.get('status') == 'error' for row in checks),
|
||||
}
|
||||
report['capabilities_passed'] = all(row['passed'] for row in checks)
|
||||
remove_expression = None # Poll deletes completed state in the page.
|
||||
report['page_probe_state_removed'] = True
|
||||
break
|
||||
time.sleep(.1)
|
||||
else:
|
||||
raise TimeoutError('Fixed capability suite did not finish within 80 seconds')
|
||||
if suite in ('message-json', 'contacts', 'history-read'):
|
||||
start, poll, key, remove_expression = data_expressions(probe_id, suite)
|
||||
initial = json.loads(execute(start, 'data startup'))
|
||||
if initial.get('probe_id') != probe_id or initial.get('state') != 'started':
|
||||
raise ValueError('Data probe startup does not match this run')
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
metadata = json.loads(execute(poll, 'data poll'))
|
||||
if metadata.get('probe_id') != probe_id:
|
||||
raise ValueError('Data probe session mismatch')
|
||||
if metadata.get('state') == 'error':
|
||||
raise ValueError(str(metadata.get('error')))
|
||||
if metadata.get('state') == 'ready':
|
||||
break
|
||||
time.sleep(.1)
|
||||
else:
|
||||
raise TimeoutError('Fixed data probe did not become ready')
|
||||
length = metadata.get('length')
|
||||
if type(length) is not int or not 0 < length <= 16 * 1024 * 1024:
|
||||
raise ValueError('Data probe exceeds the bounded JSON transport size')
|
||||
pieces = []
|
||||
offset = 0
|
||||
while offset < length:
|
||||
part = json.loads(execute(data_chunk_expression(key, offset), 'data chunk'))
|
||||
following = part.get('next')
|
||||
if (part.get('probe_id') != probe_id or part.get('offset') != offset
|
||||
or type(following) is not int or not offset < following <= length
|
||||
or not isinstance(part.get('chunk'), str)):
|
||||
raise ValueError('Invalid JSON transport chunk')
|
||||
if len(part['chunk'].encode('utf-16-le', errors='surrogatepass')) // 2 != following - offset:
|
||||
raise ValueError('JSON transport chunk length mismatch')
|
||||
pieces.append(part['chunk'])
|
||||
offset = following
|
||||
data = json.loads(''.join(pieces))
|
||||
if data.get('probe_id') != probe_id or data.get('kind') != suite:
|
||||
raise ValueError('JSON payload does not match this data probe')
|
||||
report['data'] = data
|
||||
report['data_passed'] = isinstance(data.get('contacts' if suite == 'contacts' else 'messages'), list)
|
||||
if suite == 'history-read':
|
||||
report['data_passed'] = report['data_passed'] and bool(data['messages']) and all(
|
||||
isinstance(row.get('timestamp'), (int, float)) and row['timestamp'] == row.get('selected_timestamp')
|
||||
for row in data['messages'])
|
||||
report['serialization_notes'] = (
|
||||
'Declared model properties and nested collections through app serialize(); '
|
||||
'undefined/BigInt/binary values are tagged. Repeated object references use traversal-order $ref IDs. '
|
||||
'This is a model snapshot, not prototype methods or every private heap field.'
|
||||
)
|
||||
execute(remove_expression, 'data state cleanup')
|
||||
remove_expression = None
|
||||
report['page_probe_state_removed'] = True
|
||||
except Exception as error:
|
||||
report['error'] = str(error)
|
||||
finally:
|
||||
cleanup = report['cleanup']
|
||||
if remove_expression and execute and packet is not None and packet.state in (2, 4, -1):
|
||||
try:
|
||||
execute(remove_expression, 'capability state cleanup')
|
||||
report['page_probe_state_removed'] = True
|
||||
except Exception as error:
|
||||
cleanup['page_state_error'] = str(error)
|
||||
if hook and packet is not None:
|
||||
try:
|
||||
packet.state = 6
|
||||
wait_for_state(packet, 7, 5, wake, alive, 'cleanup')
|
||||
cleanup['acknowledged'] = True
|
||||
cleanup['async_hresult'] = '0x' + format(packet.hr & 0xffffffff, '08x')
|
||||
if packet.hr < 0:
|
||||
cleanup['error'] = 'Native cancel/close failed: ' + cleanup['async_hresult']
|
||||
except Exception as error:
|
||||
cleanup['error'] = str(error)
|
||||
cleanup['hook_removed'] = bool(user.UnhookWindowsHookEx(hook))
|
||||
if not cleanup['hook_removed']:
|
||||
cleanup['unhook_error'] = str(ctypes.WinError(ctypes.get_last_error()))
|
||||
elif not hook:
|
||||
cleanup['not_started'] = True
|
||||
for name, handle, function in [('module', module, 'FreeLibrary'),
|
||||
('mapping_view', view, 'UnmapViewOfFile'),
|
||||
('mapping_handle', mapping, 'CloseHandle'),
|
||||
('process_handle', process, 'CloseHandle')]:
|
||||
if handle:
|
||||
ok = bool(getattr(kernel, function)(handle))
|
||||
cleanup[name + '_released'] = ok
|
||||
if not ok:
|
||||
cleanup[name + '_error'] = str(ctypes.WinError(ctypes.get_last_error()))
|
||||
if hook and cleanup['hook_removed']:
|
||||
try:
|
||||
deadline = time.monotonic() + 3
|
||||
while helper_loaded(selected, dll) and time.monotonic() < deadline:
|
||||
time.sleep(.05)
|
||||
cleanup['native_helper_unloaded'] = not helper_loaded(selected, dll)
|
||||
if not cleanup['native_helper_unloaded']:
|
||||
cleanup['unload_error'] = 'Windows has not unloaded the hook DLL yet'
|
||||
except Exception as error:
|
||||
cleanup['unload_error'] = str(error)
|
||||
suites_ok = ((suite not in ('math', 'all') or report.get('math_passed', False))
|
||||
and (suite not in ('capabilities', 'all') or report.get('capabilities_passed', False))
|
||||
and (suite not in ('message-json', 'contacts', 'history-read') or report.get('data_passed', False))
|
||||
and (suite not in ('history-export', 'send-self', 'contacts-export') or report.get('operation_passed', False)))
|
||||
report['passed'] = (suites_ok and 'error' not in report and cleanup['acknowledged']
|
||||
and cleanup['hook_removed'] and not any(
|
||||
key.endswith('error') for key in cleanup))
|
||||
return report
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--suite', choices=('math', 'capabilities', 'all', 'message-json', 'contacts', 'history-read'), default='math',
|
||||
help='Fixed tests (default: math); message-json prints meow matches, contacts prints a five-contact sample')
|
||||
parser.add_argument('--pid', type=int, help='Optional supported Desktop host PID')
|
||||
parser.add_argument('--output', type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
if args.output is None:
|
||||
args.output = BASE / ('whatsapp-' + args.suite + '-report.json')
|
||||
report = run_math(args.pid, args.suite)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps(report, indent=2))
|
||||
return 0 if report['passed'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Bounded live native WebView search backend."""
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from .common import BASE, verified_helper
|
||||
from .desktop import detect_desktop_pid, visible_ui_threads
|
||||
|
||||
|
||||
EMBEDDED_PROBE_SHA256 = '911beb9f6bb52390cde8a75562ca1d6c46f4629f641426a5250286903de60895'
|
||||
|
||||
|
||||
def build_native_probe():
|
||||
"""Extract the bundled Windows x64 helper; no compiler or downloads."""
|
||||
return verified_helper('search_probe.dll', EMBEDDED_PROBE_SHA256, '.whatsapp-native', 'probe.dll')[0]
|
||||
|
||||
|
||||
def native_webview_test(pid, output, config, search_query=None, limit=50):
|
||||
"""Python/Windows backend; no Frida import, server, agent or runtime."""
|
||||
import ctypes
|
||||
from ctypes import wintypes as w
|
||||
import time
|
||||
import hashlib
|
||||
automatic_pid = pid is None
|
||||
if automatic_pid:
|
||||
pid = detect_desktop_pid()
|
||||
threads = visible_ui_threads(pid)
|
||||
if len(threads)!=1:
|
||||
raise ValueError('Cannot identify one Desktop UI thread')
|
||||
thread = next(iter(threads))
|
||||
class Packet(ctypes.Structure):
|
||||
_fields_=[('state',w.LONG),('hr',w.LONG),('thread',w.DWORD),('reserved',w.DWORD),
|
||||
('script',w.WCHAR*8192),('result',w.WCHAR*32768)]
|
||||
kernel=ctypes.WinDLL('kernel32',use_last_error=True)
|
||||
user=ctypes.WinDLL('user32',use_last_error=True)
|
||||
declarations = {
|
||||
'CreateFileMappingW':([w.HANDLE,ctypes.c_void_p,w.DWORD,w.DWORD,w.DWORD,w.LPCWSTR],w.HANDLE),
|
||||
'MapViewOfFile':([w.HANDLE,w.DWORD,w.DWORD,w.DWORD,ctypes.c_size_t],ctypes.c_void_p),
|
||||
'UnmapViewOfFile':([ctypes.c_void_p],w.BOOL),
|
||||
'CloseHandle':([w.HANDLE],w.BOOL),
|
||||
'LoadLibraryW':([w.LPCWSTR],w.HMODULE),
|
||||
'FreeLibrary':([w.HMODULE],w.BOOL),
|
||||
'GetProcAddress':([w.HMODULE,ctypes.c_char_p],ctypes.c_void_p),
|
||||
}
|
||||
for name,(arguments,returns) in declarations.items():
|
||||
function=getattr(kernel,name);function.argtypes=arguments;function.restype=returns
|
||||
user.SetWindowsHookExW.argtypes=[ctypes.c_int,ctypes.c_void_p,w.HMODULE,w.DWORD]
|
||||
user.SetWindowsHookExW.restype=w.HANDLE
|
||||
user.UnhookWindowsHookEx.argtypes=[w.HANDLE];user.UnhookWindowsHookEx.restype=w.BOOL
|
||||
user.PostThreadMessageW.argtypes=[w.DWORD,w.UINT,w.WPARAM,w.LPARAM]
|
||||
user.PostThreadMessageW.restype=w.BOOL
|
||||
# PID discovery checks the shipped native library hash before any target injection.
|
||||
if pid != detect_desktop_pid():
|
||||
raise ValueError('Explicit PID is not the validated active Desktop instance')
|
||||
dll=build_native_probe()
|
||||
module=hook=mapping=view=None
|
||||
report={'pid':pid,'pid_auto_detected':automatic_pid,'backend':'python-win32-native',
|
||||
'frida_used':False,'mode':config['mode'],'passed':False,'javascript_executed':False,
|
||||
'ui_thread_id':thread,'native_helper_path':str(dll)}
|
||||
report.update({'test_count':len(config.get('cases',[]))} if search_query is None else {'query':search_query,'limit':limit})
|
||||
try:
|
||||
mapping=kernel.CreateFileMappingW(w.HANDLE(-1),None,4,0,ctypes.sizeof(Packet),
|
||||
'Local\\WhatsAppLocalProbe-'+str(pid))
|
||||
mapping_error=ctypes.get_last_error()
|
||||
if not mapping:raise ctypes.WinError(mapping_error)
|
||||
if mapping_error==183:raise ValueError('Another native probe already owns this Desktop instance')
|
||||
view=kernel.MapViewOfFile(mapping,0xf001f,0,0,ctypes.sizeof(Packet))
|
||||
if not view:raise ctypes.WinError(ctypes.get_last_error())
|
||||
packet=Packet.from_address(view);packet.thread=thread;packet.state=3
|
||||
module=kernel.LoadLibraryW(str(dll))
|
||||
if not module:raise ctypes.WinError(ctypes.get_last_error())
|
||||
callback=kernel.GetProcAddress(module,b'ProbeHook')
|
||||
if not callback:raise ctypes.WinError(ctypes.get_last_error())
|
||||
hook=user.SetWindowsHookExW(3,callback,module,thread)
|
||||
if not hook:raise ctypes.WinError(ctypes.get_last_error())
|
||||
deadline=time.monotonic()+30
|
||||
def execute(expression):
|
||||
if len(expression.encode('utf-16-le'))//2>=8192:raise ValueError('Probe expression too long')
|
||||
packet.script=expression;packet.result='';packet.hr=0;packet.state=0
|
||||
while time.monotonic()<deadline:
|
||||
if not user.PostThreadMessageW(thread,0x8000+0x497,0,0):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
time.sleep(.025)
|
||||
if packet.state==1:report['javascript_executed']=True
|
||||
if packet.state==-1:raise ValueError('Native HRESULT 0x'+format(packet.hr&0xffffffff,'08x'))
|
||||
if packet.state==2:
|
||||
report['javascript_executed']=True
|
||||
return packet.result
|
||||
if packet.state==0:
|
||||
raise TimeoutError('Native hook did not acknowledge the request; check helper path accessibility in the packaged Desktop process')
|
||||
raise TimeoutError('Timed out waiting for native WebView2 completion (state='+str(packet.state)+')')
|
||||
result=execute(config['start'])
|
||||
if config['mode']=='arithmetic':
|
||||
if config.get('cases'):
|
||||
import math
|
||||
parsed=json.loads(result)
|
||||
actual=parsed.get('cases',[])
|
||||
checks=[]
|
||||
if parsed.get('probe_id')!=config['probe_id'] or len(actual)!=len(config['cases']):
|
||||
raise ValueError('Arithmetic result does not match this test session')
|
||||
for expected,observed in zip(config['cases'],actual):
|
||||
value=observed.get('value')
|
||||
target=expected['expected']
|
||||
same=observed.get('expression')==expected['expression']
|
||||
if isinstance(target,float):
|
||||
same=same and isinstance(value,(int,float)) and not isinstance(value,bool) and math.isclose(value,target,rel_tol=1e-12,abs_tol=1e-12)
|
||||
else:
|
||||
same=same and type(value) is type(target) and value==target
|
||||
checks.append({'expression':expected['expression'],'expected':target,'actual':value,'passed':same})
|
||||
report.update({'probe_id':config['probe_id'],'tests':checks,'passed':all(c['passed'] for c in checks)})
|
||||
else:
|
||||
report.update({'result':result,'passed':result=='2'})
|
||||
else:
|
||||
parsed=json.loads(result)
|
||||
deadline = time.monotonic() + 30
|
||||
while parsed.get('state') in ('started','pending'):
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError('Desktop search did not finish within 30 seconds')
|
||||
time.sleep(.2);parsed=json.loads(execute(config['poll']))
|
||||
report.update({'search':parsed,'passed':parsed.get('state')=='done'})
|
||||
except Exception as error:
|
||||
report['error']=str(error)
|
||||
finally:
|
||||
if hook and view:
|
||||
packet.state=6
|
||||
close_deadline=time.monotonic()+3
|
||||
while packet.state!=7 and time.monotonic()<close_deadline:
|
||||
user.PostThreadMessageW(thread,0x8000+0x497,0,0);time.sleep(.025)
|
||||
if packet.state!=7:report['cleanup_error']='Owner-thread cleanup did not acknowledge completion'
|
||||
if hook:user.UnhookWindowsHookEx(hook)
|
||||
if module:kernel.FreeLibrary(module)
|
||||
if view:kernel.UnmapViewOfFile(view)
|
||||
if mapping:kernel.CloseHandle(mapping)
|
||||
output.parent.mkdir(parents=True,exist_ok=True)
|
||||
output.write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
|
||||
print(json.dumps(report,indent=2))
|
||||
if report.get('cleanup_error'):
|
||||
report['passed'] = False
|
||||
output.write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
|
||||
return 0 if report.get('passed') else 1
|
||||
|
||||
|
||||
def run_live_probe(pid, output, search_query=None, limit=50):
|
||||
"""Run the bounded native WebView2 probe without Frida."""
|
||||
import uuid
|
||||
if search_query is None:
|
||||
raise ValueError('A search query is required; use the live math suite for arithmetic')
|
||||
if not search_query.strip() or len(search_query) > 200 or not 1 <= limit <= 50:
|
||||
raise ValueError('Search requires 1..200 characters and a limit of 1..50')
|
||||
key = '__local_search_probe_' + uuid.uuid4().hex
|
||||
# JSON serialization keeps the keyword a JavaScript string literal.
|
||||
query_literal, key_literal = json.dumps(search_query), json.dumps(key)
|
||||
start = r'''(() => {
|
||||
const key=__KEY__,query=__QUERY__,limit=__LIMIT__;
|
||||
if(typeof require!=='function')return {state:'error',error:'WhatsApp module loader unavailable'};
|
||||
window[key]={state:'pending'};
|
||||
Promise.resolve().then(()=>{
|
||||
const collection=require('WAWebMsgCollection').MsgCollection;
|
||||
if(!collection||typeof collection.search!=='function')throw Error('MsgCollection.search unavailable');
|
||||
return collection.search(query,1,limit);
|
||||
}).then(result=>{
|
||||
const messages=Array.isArray(result.messages)?result.messages:[];
|
||||
window[key]={state:'done',query,entry:'WAWebMsgCollection.MsgCollection.search',
|
||||
returned:messages.length,eof:result.eof,canceled:result.canceled,
|
||||
messages:messages.slice(0,limit).map(message=>{
|
||||
const body=typeof message.body==='string'?message.body:'';
|
||||
const caption=typeof message.caption==='string'?message.caption:'';
|
||||
return {id:message.id?.toString()??null,chat_id:message.id?.remote?.toString()??null,
|
||||
timestamp:message.t??null,type:message.type??null,
|
||||
body_length:body.length,caption_length:caption.length,
|
||||
contains_query:(body+'\n'+caption).toLowerCase().includes(query.toLowerCase())};
|
||||
})};
|
||||
}).catch(error=>{window[key]={state:'error',error:String(error),query};});
|
||||
return {state:'started',query};
|
||||
})()'''.replace('__KEY__',key_literal).replace('__QUERY__',query_literal).replace('__LIMIT__',str(limit))
|
||||
poll = r'''(() => {
|
||||
const key=__KEY__,value=window[key];
|
||||
if(!value)return {state:'error',error:'Search state missing'};
|
||||
if(value.state==='done'||value.state==='error')delete window[key];
|
||||
return value;
|
||||
})()'''.replace('__KEY__',key_literal)
|
||||
config = {'mode': 'search', 'start': start, 'poll': poll}
|
||||
return native_webview_test(pid, output, config, search_query, limit)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Request a real WhatsApp mobile-linking QR in an isolated background browser.
|
||||
|
||||
The official WhatsApp Web client creates the keys and requests the server ref.
|
||||
This script reads the displayed QR payload and renders it locally. It does not
|
||||
extract credentials from the existing Desktop account or implement fake pairing.
|
||||
Requires: pip install websockets 'qrcode[pil]'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, base64, datetime as dt, hashlib, json, os
|
||||
from pathlib import Path
|
||||
import socket, subprocess, sys, time, urllib.parse, urllib.request
|
||||
import http.server,threading
|
||||
|
||||
from .common import BASE as ROOT
|
||||
READ_QR=r'''(() => {
|
||||
const element=Array.from(document.querySelectorAll('[data-ref]')).find(e=>{const v=e.getAttribute('data-ref');return v&&v.split(',').length>=4;});
|
||||
const payload=element?.getAttribute('data-ref')??null;
|
||||
let linked=false;
|
||||
try {linked=!!require('WAWebUserPrefsMeUser').getMaybeMePnUser();}catch{}
|
||||
let refreshRequested=false;
|
||||
if(!linked&&!payload){const button=document.querySelector('[data-testid="link_device_qr_expired_refresh_button"]');if(button){button.click();refreshRequested=true;}}
|
||||
return {payload,linked,refreshRequested,title:document.title,origin:location.origin};
|
||||
})()'''
|
||||
|
||||
def chrome_path(explicit):
|
||||
if explicit:return Path(explicit).resolve()
|
||||
candidates=[Path(os.environ.get('PROGRAMFILES','C:/Program Files'))/'Google/Chrome/Application/chrome.exe',Path(os.environ.get('PROGRAMFILES(X86)','C:/Program Files (x86)'))/'Google/Chrome/Application/chrome.exe',Path(os.environ.get('LOCALAPPDATA',''))/'Google/Chrome/Application/chrome.exe']
|
||||
return next((p for p in candidates if p.is_file()),None)
|
||||
|
||||
def call(url,expression):
|
||||
from websockets.sync.client import connect
|
||||
with connect(url,open_timeout=10,max_size=1_000_000) as ws:
|
||||
ws.send(json.dumps({'id':1,'method':'Runtime.evaluate','params':{'expression':expression,'returnByValue':True}}))
|
||||
while True:
|
||||
result=json.loads(ws.recv(timeout=15))
|
||||
if result.get('id')!=1:continue
|
||||
if result.get('error') or result.get('result',{}).get('exceptionDetails'):raise RuntimeError('Page probe failed')
|
||||
return result['result']['result'].get('value')
|
||||
|
||||
def describe(payload):
|
||||
parts=payload.split(',')
|
||||
lengths=[]
|
||||
for part in parts[1:]:
|
||||
try:lengths.append(len(base64.b64decode(part,validate=True)))
|
||||
except Exception:lengths.append(None)
|
||||
return {'field_count':len(parts),'payload_length':len(payload),'sha256':hashlib.sha256(payload.encode()).hexdigest(),'base64_field_byte_lengths':lengths}
|
||||
|
||||
def write_qr(payload,out):
|
||||
import qrcode
|
||||
qr=qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M,box_size=10,border=4)
|
||||
qr.add_data(payload);qr.make(fit=True)
|
||||
qr.make_image(fill_color='black',back_color='white').save(out/'pairing-qr.png')
|
||||
# No raw QR payload, identity secret or session key is written to the report.
|
||||
report={'timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),'status':'waiting_for_mobile_scan','source':'Official WhatsApp Web QR DOM in isolated headless Chrome','qr_metadata':describe(payload),'note':'QR rotates and requires the matching browser session to remain running.'}
|
||||
(out/'pairing-status.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
(out/'pairing-qr.html').write_text('<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="3"><title>WhatsApp test pairing</title></head><body style="font-family:Arial;text-align:center"><h1>Link your own WhatsApp phone</h1><p>WhatsApp → Linked devices → Link a device.</p><img src="pairing-qr.png" style="max-width:90vw"><p>This QR links the isolated Python test session. The image updates while the script runs.</p></body></html>',encoding='utf-8')
|
||||
|
||||
def serve_viewer(out):
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self,*a,**kw):super().__init__(*a,directory=str(out),**kw)
|
||||
def log_message(self,*a):pass
|
||||
def do_GET(self):
|
||||
if urllib.parse.urlsplit(self.path).path not in ('/pairing-qr.html','/pairing-qr.png','/pairing-status.json'):
|
||||
self.send_error(404);return
|
||||
super().do_GET()
|
||||
server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler)
|
||||
threading.Thread(target=server.serve_forever,daemon=True).start()
|
||||
return server,f'http://127.0.0.1:{server.server_port}/pairing-qr.html'
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--browser',type=Path);p.add_argument('--profile',type=Path,default=ROOT/'whatsapp-pairing-profile');p.add_argument('--output-dir',type=Path,default=ROOT/'whatsapp-pairing');p.add_argument('--watch-seconds',type=int,default=300);p.add_argument('--startup-seconds',type=int,default=90)
|
||||
args=p.parse_args(argv);browser=chrome_path(args.browser)
|
||||
if not browser or not browser.is_file():p.error('Chrome executable missing; supply --browser')
|
||||
if not 10<=args.watch_seconds<=3600:p.error('--watch-seconds must be 10..3600')
|
||||
profile=args.profile.resolve();out=args.output_dir.resolve()
|
||||
if '5319275a.whatsappdesktop' in str(profile).lower():p.error('Refusing the existing WhatsApp Desktop profile')
|
||||
marker=profile/'python-pairing-profile.json'
|
||||
if profile.exists() and any(profile.iterdir()) and not marker.exists():p.error('Profile must be empty or created by this script')
|
||||
profile.mkdir(parents=True,exist_ok=True);out.mkdir(parents=True,exist_ok=True)
|
||||
marker.write_text(json.dumps({'purpose':'isolated official WhatsApp Web pairing test'}),encoding='utf-8')
|
||||
with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
||||
command=[str(browser),'--headless=new','--no-first-run','--no-default-browser-check','--remote-debugging-address=127.0.0.1',f'--remote-debugging-port={port}',f'--user-data-dir={profile}','--window-size=1200,1000','https://web.whatsapp.com/']
|
||||
flags=subprocess.CREATE_NO_WINDOW if sys.platform=='win32' else 0
|
||||
proc=subprocess.Popen(command,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=flags)
|
||||
viewer,viewer_url=serve_viewer(out)
|
||||
endpoint=f'http://127.0.0.1:{port}';(out/'pairing-runtime.json').write_text(json.dumps({'browser_pid':proc.pid,'cdp_endpoint':endpoint,'viewer_url':viewer_url,'profile':str(profile)},indent=2),encoding='utf-8')
|
||||
start=time.monotonic();last_payload=None;generated=0;linked=False
|
||||
print(f'Isolated background session started; QR output: {out}',flush=True)
|
||||
print(f'REA observation endpoint: {endpoint}',flush=True)
|
||||
print(f'QR viewer (auto-refresh): {viewer_url}',flush=True)
|
||||
try:
|
||||
while time.monotonic()-start<args.watch_seconds:
|
||||
if proc.poll() is not None:raise RuntimeError('Background browser exited')
|
||||
try:
|
||||
with urllib.request.urlopen(endpoint+'/json/list',timeout=3) as response:targets=json.load(response)
|
||||
target=next((t for t in targets if t.get('type')=='page' and urllib.parse.urlsplit(t.get('url','')).hostname=='web.whatsapp.com'),None)
|
||||
if not target:time.sleep(1);continue
|
||||
state=call(target['webSocketDebuggerUrl'],READ_QR)
|
||||
except (OSError,StopIteration):time.sleep(1);continue
|
||||
if not state or state.get('origin')!='https://web.whatsapp.com':
|
||||
if time.monotonic()-start>args.startup_seconds:raise RuntimeError('WhatsApp page did not commit before startup timeout')
|
||||
time.sleep(1);continue
|
||||
if state.get('linked'):
|
||||
linked=True;(out/'pairing-status.json').write_text(json.dumps({'status':'linked','timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),'profile':str(profile),'note':'Browser is kept alive until the watch timeout; credentials remain only in its isolated profile.'},indent=2),encoding='utf-8')
|
||||
if last_payload is not None:print('Mobile linking confirmed.',flush=True);last_payload=None
|
||||
elif state.get('payload') and state['payload']!=last_payload:
|
||||
last_payload=state['payload'];write_qr(last_payload,out);generated+=1
|
||||
print(f'QR refreshed ({generated}); scan pairing-qr.png from your own phone.',flush=True)
|
||||
elif state.get('refreshRequested'):
|
||||
print('Expired QR: requested a fresh reference through the official reload button.',flush=True)
|
||||
elif not generated and time.monotonic()-start>args.startup_seconds:raise RuntimeError('No pairing QR appeared before startup timeout')
|
||||
time.sleep(2)
|
||||
return 0 if generated or linked else 1
|
||||
except KeyboardInterrupt:return 0
|
||||
finally:
|
||||
viewer.shutdown();viewer.server_close()
|
||||
try:
|
||||
with urllib.request.urlopen(endpoint+'/json/version',timeout=3) as response:version=json.load(response)
|
||||
with connect(version['webSocketDebuggerUrl'],open_timeout=3) as ws:ws.send(json.dumps({'id':1,'method':'Browser.close'}))
|
||||
except Exception:proc.terminate()
|
||||
try:proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:proc.kill()
|
||||
(out/'pairing-runtime.json').write_text(json.dumps({'running':False,'linked':linked,'qr_refreshes':generated},indent=2),encoding='utf-8')
|
||||
if not linked and (out/'pairing-status.json').exists():
|
||||
report=json.loads((out/'pairing-status.json').read_text());report['status']='expired_browser_closed';(out/'pairing-status.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Find WhatsApp Desktop pairing QR payload candidates using read-only process RAM.
|
||||
|
||||
No screenshots, browser, debug connection or injected code. Finding a payload
|
||||
does not establish that its server reference is active; no session is created.
|
||||
"""
|
||||
import argparse, base64, ctypes, datetime as dt, hashlib, json, re, struct, sys
|
||||
from pathlib import Path
|
||||
from .ram import Memory, discover
|
||||
|
||||
REA_EVIDENCE = 'ev_6c3a7930e51a95d162c3634ecdc0b73652cb640663681a7cb9962a5b45a9c1da'
|
||||
PATTERN = re.compile(r'(?:https://wa\.me/settings/linked_devices#)?[A-Za-z0-9+/=_:-]{8,512},[A-Za-z0-9+/]{43}=,[A-Za-z0-9+/]{43}=,[A-Za-z0-9+/]{43}=,8(?:,https://faq\.whatsapp\.com/r/ld)?')
|
||||
|
||||
def valid_payload(value):
|
||||
fields = value.removeprefix('https://wa.me/settings/linked_devices#').split(',')
|
||||
if len(fields) not in (5, 6) or fields[4] != '8': return False
|
||||
if len(fields) == 6 and fields[5] != 'https://faq.whatsapp.com/r/ld': return False
|
||||
try: return all(len(base64.b64decode(f, validate=True)) == 32 for f in fields[1:4])
|
||||
except ValueError: return False
|
||||
|
||||
def scan(mem, budget_mb):
|
||||
scanned, seen = 0, set()
|
||||
budget = budget_mb * 1024 * 1024
|
||||
for region in mem.regions():
|
||||
for offset in range(0, region['size'], 1024 * 1024):
|
||||
if scanned >= budget: return
|
||||
address = region['base'] + offset
|
||||
size = min(1024 * 1024, region['size'] - offset, budget - scanned)
|
||||
data = mem.read(address, min(size + 2048, region['size'] - offset))
|
||||
scanned += size
|
||||
variants = [(data.decode('latin-1'), 1, 0)]
|
||||
if b',\x008\x00' in data:
|
||||
for alignment in (0, 1):
|
||||
tail = len(data) - (len(data) - alignment) % 2
|
||||
variants.append((data[alignment:tail].decode('utf-16-le', errors='surrogatepass'), 2, alignment))
|
||||
for text, width, alignment in variants:
|
||||
for match in PATTERN.finditer(text):
|
||||
payload = match.group()
|
||||
if not valid_payload(payload): continue
|
||||
start = address + alignment + match.start() * width
|
||||
header = mem.read(start - 12, 12)
|
||||
if len(header) != 12: continue
|
||||
map_tag, _, length = struct.unpack('<III', header)
|
||||
if not map_tag & 1 or length != len(payload) or (start - 12) % 4: continue
|
||||
raw = payload.encode('latin-1' if width == 1 else 'utf-16-le')
|
||||
if mem.read(start, len(raw)) != raw: continue
|
||||
digest = hashlib.sha256(payload.encode()).hexdigest()
|
||||
if digest in seen: continue
|
||||
seen.add(digest)
|
||||
yield payload, {'pid': mem.pid, 'string_address': hex(start - 12),
|
||||
'encoding': 'Latin-1' if width == 1 else 'UTF-16LE',
|
||||
'payload_sha256': digest, 'server_validity': 'unverified',
|
||||
'validation': 'field lengths, candidate V8 string header, repeat memory read'}
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--output-dir', type=Path, default=Path.cwd() / 'whatsapp-desktop-pairing-memory')
|
||||
p.add_argument('--max-mb', type=int, default=512)
|
||||
args = p.parse_args(argv)
|
||||
import qrcode
|
||||
if sys.platform != 'win32' or ctypes.sizeof(ctypes.c_void_p) != 8: p.error('64-bit Windows Python required')
|
||||
if not 16 <= args.max_mb <= 4096: p.error('--max-mb must be 16..4096')
|
||||
targets = discover()
|
||||
candidates, diagnostics = [], []
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for target in targets:
|
||||
pid = target['ProcessId']
|
||||
try: mem = Memory(pid)
|
||||
except OSError as error:
|
||||
diagnostics.append({'pid': pid, 'error': str(error)}); continue
|
||||
try:
|
||||
for payload, metadata in scan(mem, args.max_mb):
|
||||
path = args.output_dir / ('pairing-candidate-' + metadata['payload_sha256'][:16] + '.png')
|
||||
qrcode.make(payload).save(path)
|
||||
metadata['file'] = str(path.resolve())
|
||||
candidates.append(metadata)
|
||||
diagnostics.append({'pid': pid, 'bytes_read': mem.read_bytes, 'debug_flags_present': target['DebugFlagsPresent']})
|
||||
finally: mem.close()
|
||||
report = {'timestamp_utc': dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
'mode': 'ReadProcessMemory; running Desktop renderer descendants only',
|
||||
'rea_static_evidence': REA_EVIDENCE, 'candidates': candidates, 'diagnostics': diagnostics,
|
||||
'limitations': ['Payloads can be expired or unreachable heap objects; active registration is not verified.',
|
||||
'Bounded scan of private readable memory; Cons/external strings or separately stored fields are not reconstructed.',
|
||||
'No new server reference is requested; a linked app may have no pairing payload.',
|
||||
'PNG candidates contain temporary pairing credentials. JSON omits raw payload and private keys.']}
|
||||
path = args.output_dir / 'report.json'
|
||||
path.write_text(json.dumps(report, indent=2), encoding='utf-8')
|
||||
print(json.dumps({'status': 'unverified_candidates_saved' if candidates else 'no_pairing_payload_found',
|
||||
'candidates': len(candidates), 'targets': len(targets), 'report': str(path.resolve())}))
|
||||
return 0 if candidates else 2
|
||||
|
||||
if __name__ == '__main__': raise SystemExit(main())
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Save the pairing QR displayed by the already-running WhatsApp Desktop app.
|
||||
|
||||
No browser, debug port, process injection, restart, or logout. Requires Pillow
|
||||
and zxing-cpp. The official Desktop app must be displaying its linking QR.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes as C
|
||||
from ctypes import wintypes as W
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
user = C.WinDLL('user32', use_last_error=True)
|
||||
gdi = C.WinDLL('gdi32', use_last_error=True)
|
||||
user.GetDC.argtypes = [W.HWND]; user.GetDC.restype = W.HDC
|
||||
user.ReleaseDC.argtypes = [W.HWND, W.HDC]
|
||||
user.GetWindowThreadProcessId.argtypes = [W.HWND, C.POINTER(W.DWORD)]
|
||||
user.GetWindowRect.argtypes = [W.HWND, C.POINTER(W.RECT)]
|
||||
user.PrintWindow.argtypes = [W.HWND, W.HDC, W.UINT]
|
||||
user.IsWindowVisible.argtypes = [W.HWND]
|
||||
gdi.CreateCompatibleDC.argtypes = [W.HDC]; gdi.CreateCompatibleDC.restype = W.HDC
|
||||
gdi.CreateCompatibleBitmap.argtypes = [W.HDC, C.c_int, C.c_int]; gdi.CreateCompatibleBitmap.restype = W.HBITMAP
|
||||
gdi.SelectObject.argtypes = [W.HDC, W.HGDIOBJ]; gdi.SelectObject.restype = W.HGDIOBJ
|
||||
gdi.DeleteObject.argtypes = [W.HGDIOBJ]
|
||||
gdi.DeleteDC.argtypes = [W.HDC]
|
||||
|
||||
class Header(C.Structure):
|
||||
_fields_ = [('size', W.DWORD), ('width', W.LONG), ('height', W.LONG),
|
||||
('planes', W.WORD), ('bits', W.WORD), ('compression', W.DWORD),
|
||||
('image_size', W.DWORD), ('xppm', W.LONG), ('yppm', W.LONG),
|
||||
('used', W.DWORD), ('important', W.DWORD)]
|
||||
|
||||
class Info(C.Structure):
|
||||
_fields_ = [('header', Header), ('colors', W.DWORD * 3)]
|
||||
|
||||
gdi.GetDIBits.argtypes = [W.HDC, W.HBITMAP, W.UINT, W.UINT, C.c_void_p, C.POINTER(Info), W.UINT]
|
||||
|
||||
def desktop_windows():
|
||||
query = "@(Get-CimInstance Win32_Process -Filter \"Name='WhatsApp.Root.exe'\" | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress"
|
||||
result = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', query], capture_output=True, text=True, check=True)
|
||||
data = json.loads(result.stdout) if result.stdout.strip() else []
|
||||
pids = set(data if isinstance(data, list) else [data])
|
||||
windows = []
|
||||
callback_type = C.WINFUNCTYPE(W.BOOL, W.HWND, W.LPARAM)
|
||||
@callback_type
|
||||
def callback(hwnd, _):
|
||||
pid = W.DWORD()
|
||||
user.GetWindowThreadProcessId(hwnd, C.byref(pid))
|
||||
if pid.value in pids and user.IsWindowVisible(hwnd):
|
||||
windows.append((hwnd, pid.value))
|
||||
return True
|
||||
user.EnumWindows.argtypes = [callback_type, W.LPARAM]
|
||||
user.EnumWindows(callback, 0)
|
||||
return windows
|
||||
|
||||
def capture(hwnd):
|
||||
from PIL import Image
|
||||
rect = W.RECT()
|
||||
if not user.GetWindowRect(hwnd, C.byref(rect)):
|
||||
return None
|
||||
width, height = rect.right - rect.left, rect.bottom - rect.top
|
||||
if not (100 <= width <= 10000 and 100 <= height <= 10000):
|
||||
return None
|
||||
screen = user.GetDC(hwnd)
|
||||
dc = gdi.CreateCompatibleDC(screen)
|
||||
bitmap = gdi.CreateCompatibleBitmap(screen, width, height)
|
||||
old = gdi.SelectObject(dc, bitmap)
|
||||
try:
|
||||
if not user.PrintWindow(hwnd, dc, 2):
|
||||
return None
|
||||
gdi.SelectObject(dc, old)
|
||||
info = Info(Header(C.sizeof(Header), width, -height, 1, 32, 0, 0, 0, 0, 0, 0))
|
||||
pixels = C.create_string_buffer(width * height * 4)
|
||||
if gdi.GetDIBits(dc, bitmap, 0, height, pixels, C.byref(info), 0) != height:
|
||||
return None
|
||||
return Image.frombytes('RGB', (width, height), pixels.raw, 'raw', 'BGRX')
|
||||
finally:
|
||||
gdi.SelectObject(dc, old)
|
||||
gdi.DeleteObject(bitmap)
|
||||
gdi.DeleteDC(dc)
|
||||
user.ReleaseDC(hwnd, screen)
|
||||
|
||||
def pairing_payload(text):
|
||||
import base64
|
||||
parts = text.split(',')
|
||||
if len(parts) not in (5, 6):
|
||||
return False
|
||||
try:
|
||||
return bool(parts[0]) and all(len(base64.b64decode(p, validate=True)) == 32 for p in parts[1:4]) and parts[4].isdigit()
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, default=Path.cwd() / 'whatsapp-desktop-qr.png')
|
||||
parser.add_argument('--wait-seconds', type=int, default=0)
|
||||
args = parser.parse_args(argv)
|
||||
import zxingcpp
|
||||
if not 0 <= args.wait_seconds <= 3600:
|
||||
parser.error('--wait-seconds must be 0..3600')
|
||||
deadline = time.monotonic() + args.wait_seconds
|
||||
while True:
|
||||
windows = desktop_windows()
|
||||
for hwnd, pid in windows:
|
||||
image = capture(hwnd)
|
||||
if image is None:
|
||||
continue
|
||||
for code in zxingcpp.read_barcodes(image):
|
||||
if code.format != zxingcpp.BarcodeFormat.QRCode or not pairing_payload(code.text):
|
||||
continue
|
||||
points = [code.position.top_left, code.position.top_right, code.position.bottom_left, code.position.bottom_right]
|
||||
margin = 25
|
||||
box = (max(0, min(p.x for p in points)-margin), max(0, min(p.y for p in points)-margin),
|
||||
min(image.width, max(p.x for p in points)+margin), min(image.height, max(p.y for p in points)+margin))
|
||||
cropped = image.crop(box)
|
||||
if not any(c.text == code.text for c in zxingcpp.read_barcodes(cropped)):
|
||||
continue
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
cropped.save(args.output, format='PNG')
|
||||
print(json.dumps({'status': 'saved', 'source': 'running WhatsApp Desktop window', 'pid': pid,
|
||||
'file': str(args.output.resolve()), 'payload_sha256': hashlib.sha256(code.text.encode()).hexdigest()}))
|
||||
return 0
|
||||
if time.monotonic() >= deadline:
|
||||
print(json.dumps({'status': 'no_active_desktop_qr', 'windows_found': len(windows),
|
||||
'note': 'Desktop must display its linking QR. A linked account has no login QR; GPU rendering or a minimized window can also prevent capture. No QR file was created or overwritten.'}))
|
||||
return 2
|
||||
time.sleep(2)
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Read-only external V8 heap reader for normal WhatsApp WebView2 processes.
|
||||
|
||||
No CDP, injected code, process writes, debugger attachment, or process restart.
|
||||
Uses OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ).
|
||||
Experimental layouts must pass runtime pointer/type/schema checks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, ctypes as C, ctypes.wintypes as W, json, struct, subprocess, sys, re
|
||||
import datetime as dt
|
||||
from pathlib import Path
|
||||
|
||||
class MBI(C.Structure):
|
||||
_fields_=[('BaseAddress',C.c_void_p),('AllocationBase',C.c_void_p),('AllocationProtect',W.DWORD),('PartitionId',W.WORD),('RegionSize',C.c_size_t),('State',W.DWORD),('Protect',W.DWORD),('Type',W.DWORD)]
|
||||
|
||||
class Memory:
|
||||
def __init__(self,pid):
|
||||
self.pid=pid; self.k=C.WinDLL('kernel32',use_last_error=True)
|
||||
self.k.OpenProcess.argtypes=[W.DWORD,W.BOOL,W.DWORD];self.k.OpenProcess.restype=W.HANDLE
|
||||
self.k.ReadProcessMemory.argtypes=[W.HANDLE,C.c_void_p,C.c_void_p,C.c_size_t,C.POINTER(C.c_size_t)];self.k.ReadProcessMemory.restype=W.BOOL
|
||||
self.k.VirtualQueryEx.argtypes=[W.HANDLE,C.c_void_p,C.POINTER(MBI),C.c_size_t];self.k.VirtualQueryEx.restype=C.c_size_t
|
||||
self.k.CloseHandle.argtypes=[W.HANDLE]
|
||||
self.handle=self.k.OpenProcess(0x410,False,pid)
|
||||
if not self.handle:raise C.WinError(C.get_last_error())
|
||||
self.cache={};self.read_bytes=0
|
||||
def close(self):self.k.CloseHandle(self.handle)
|
||||
def read(self,address,size):
|
||||
if not address or size<0 or size>8*1024*1024:return b''
|
||||
buf=C.create_string_buffer(size);n=C.c_size_t()
|
||||
self.k.ReadProcessMemory(self.handle,address,buf,size,C.byref(n))
|
||||
self.read_bytes+=n.value
|
||||
return buf.raw[:n.value]
|
||||
def u32(self,a):
|
||||
b=self.read(a,4);return struct.unpack('<I',b)[0] if len(b)==4 else None
|
||||
def regions(self):
|
||||
a=0
|
||||
while a<0x7fffffffffff:
|
||||
m=MBI()
|
||||
if not self.k.VirtualQueryEx(self.handle,a,C.byref(m),C.sizeof(m)):break
|
||||
base=m.BaseAddress or 0;size=m.RegionSize
|
||||
if m.State==0x1000 and m.Type==0x20000 and not(m.Protect&0x101) and m.Protect&0xee:
|
||||
yield {'base':base,'size':size,'protection':m.Protect,'allocation_base':m.AllocationBase or 0}
|
||||
if base+size<=a:break
|
||||
a=base+size
|
||||
|
||||
def discover():
|
||||
script=r'''$p=@(Get-CimInstance Win32_Process -Filter "Name='WhatsApp.Root.exe' OR Name='msedgewebview2.exe'" | Select-Object ProcessId,ParentProcessId,Name,CommandLine); ConvertTo-Json -InputObject $p -Compress'''
|
||||
r=subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-Command',script],capture_output=True,text=True,check=True)
|
||||
procs=json.loads(r.stdout);roots={p['ProcessId'] for p in procs if p['Name']=='WhatsApp.Root.exe'}
|
||||
descendants=set(roots)
|
||||
for _ in range(5):
|
||||
descendants.update(p['ProcessId'] for p in procs if p['ParentProcessId'] in descendants)
|
||||
selected=[p for p in procs if p['ProcessId'] in descendants and '--type=renderer' in (p.get('CommandLine') or '')]
|
||||
by_pid={p['ProcessId']:p for p in procs}
|
||||
for target in selected:
|
||||
chain=[];current=target;visited=set()
|
||||
while current and current['ProcessId'] not in visited:
|
||||
visited.add(current['ProcessId']);chain.append(current.get('CommandLine') or '');current=by_pid.get(current['ParentProcessId'])
|
||||
target['DebugFlagsPresent']=any(flag in command for command in chain for flag in ('--remote-debugging','--inspect'))
|
||||
return selected
|
||||
|
||||
def scan_markers(mem,regions,max_mb):
|
||||
markers=[b'msgRowOpaqueData',b'body',b'caption',b'internalId',b'fromMe',b'_serialized',b'__x_body',b'__x_id',b'__x_type',b'__x_t',b'__x_caption',b'_models']
|
||||
found=[];total=0
|
||||
for region in regions:
|
||||
for delta in range(0,region['size'],1024*1024):
|
||||
if total>=max_mb*1024*1024:return found
|
||||
a=region['base']+delta; b=mem.read(a,min(1024*1024,region['size']-delta));total+=len(b)
|
||||
for marker in markers:
|
||||
start=0
|
||||
while True:
|
||||
pos=b.find(marker,start)
|
||||
if pos<0:break
|
||||
start=pos+len(marker)
|
||||
# A sequential Latin-1 V8 String has length at payload-4.
|
||||
obj=a+pos-12
|
||||
if obj%4 or pos<12:continue
|
||||
map_tag,raw_hash,length=struct.unpack_from('<III',b,pos-12)
|
||||
if length!=len(marker) or not(map_tag&1):continue
|
||||
found.append({'name':marker.decode(),'address':obj,'map_tag':map_tag,'cage':obj&~0xffffffff})
|
||||
return found
|
||||
|
||||
class V8:
|
||||
"""Compressed-pointer layout from V8 15.4.11; all reads remain external."""
|
||||
def __init__(self,mem,cage):self.mem=mem;self.cage=cage;self.maps={};self.strings={};self.external_requests={};self.external_data_handles={};self.external_table=None
|
||||
def ptr(self,tag):return self.cage+(tag&~1) if tag is not None and tag&1 else 0
|
||||
def map(self,address):
|
||||
if address in self.maps:return self.maps[address]
|
||||
b=self.mem.read(address,40)
|
||||
if len(b)!=40:return None
|
||||
result={'tag':struct.unpack_from('<I',b)[0],'size':b[4]*4,'inobject_start':b[5]*4,'type':struct.unpack_from('<H',b,8)[0],'bits':struct.unpack_from('<I',b,12)[0],'descriptors':struct.unpack_from('<I',b,24)[0]}
|
||||
self.maps[address]=result;return result
|
||||
def object_type(self,address):
|
||||
tag=self.mem.u32(address);mp=self.map(self.ptr(tag))
|
||||
return mp['type'] if mp else None
|
||||
def string(self,tag,depth=0,max_length=100000):
|
||||
if tag is None or not tag&1 or depth>12:return None
|
||||
a=self.ptr(tag)
|
||||
if a in self.strings:return self.strings[a]
|
||||
b=self.mem.read(a,24)
|
||||
if len(b)!=24:return None
|
||||
mp=self.map(self.ptr(struct.unpack_from('<I',b)[0]))
|
||||
if not mp or mp['type']>=128:return None
|
||||
typ=mp['type'];length=struct.unpack_from('<i',b,8)[0]
|
||||
if not 0<=length<=max_length:return None
|
||||
rep=typ&7;result=None
|
||||
if rep==0:
|
||||
width=1 if typ&8 else 2;raw=self.mem.read(a+12,length*width)
|
||||
if len(raw)==length*width:result=raw.decode('latin1' if width==1 else 'utf-16-le',errors='replace')
|
||||
elif rep==1:
|
||||
left=self.string(struct.unpack_from('<I',b,12)[0],depth+1,max_length);right=self.string(struct.unpack_from('<I',b,16)[0],depth+1,max_length)
|
||||
if left is not None and right is not None:result=left+right
|
||||
elif rep==3:
|
||||
parent=self.string(struct.unpack_from('<I',b,12)[0],depth+1,max_length);offset=struct.unpack_from('<i',b,16)[0]>>1
|
||||
if parent is not None:result=parent[offset:offset+length]
|
||||
elif rep==5:result=self.string(struct.unpack_from('<I',b,12)[0],depth+1,max_length)
|
||||
elif rep==2:
|
||||
handle=struct.unpack_from('<I',b,12)[0];width=1 if typ&8 else 2
|
||||
if handle and not handle&63:self.external_requests[handle]=(length,width)
|
||||
data_handle=struct.unpack_from('<I',b,16)[0] if not typ&16 else None
|
||||
if data_handle and not data_handle&63:self.external_data_handles[handle]=data_handle
|
||||
if self.external_table and handle and not handle&63:
|
||||
slot=self.external_table+(handle>>6)*8;entry_bytes=self.mem.read(slot,8)
|
||||
if len(entry_bytes)==8:
|
||||
entry=struct.unpack('<Q',entry_bytes)[0]
|
||||
if (entry>>49)&127==2:
|
||||
resource=entry&0xff00ffffffffffff;header=self.mem.read(resource,16)
|
||||
if len(header)==16:
|
||||
vtable,data=struct.unpack('<QQ',header)
|
||||
if data_handle:
|
||||
data_entry=self.mem.read(self.external_table+(data_handle>>6)*8,8)
|
||||
if len(data_entry)==8:
|
||||
de=struct.unpack('<Q',data_entry)[0]
|
||||
data=de&0xff00ffffffffffff if (de>>49)&127==3 else 0
|
||||
raw=self.mem.read(data,length*width)
|
||||
if len(raw)==length*width and self.mem.read(slot,8)==entry_bytes and self.mem.read(a,20 if data_handle else 16)==b[:20 if data_handle else 16]:
|
||||
result=raw.decode('latin1' if width==1 else 'utf-16-le',errors='replace')
|
||||
if result is not None and len(result.encode('utf-16-le',errors='replace'))//2==length:
|
||||
self.strings[a]=result;return result
|
||||
return None
|
||||
def descriptors(self,address):
|
||||
if self.object_type(address)!=0x106:return None
|
||||
b=self.mem.read(address,16)
|
||||
if len(b)!=16:return None
|
||||
alln,n=struct.unpack_from('<HH',b,4)
|
||||
if not 0<n<=alln<=1024:return None
|
||||
raw=self.mem.read(address+16,n*12)
|
||||
if len(raw)!=n*12:return None
|
||||
fields=[]
|
||||
for i in range(n):
|
||||
key,details,value=struct.unpack_from('<III',raw,i*12)
|
||||
name=self.string(key,max_length=256)
|
||||
if name is None:return None
|
||||
d=details>>1
|
||||
fields.append({'name':name,'field':not bool(d&32),'offset':((d>>19)&2047)*4,'inobject':bool(d&(1<<30)),'representation':(d>>6)&7,'value':value})
|
||||
return fields
|
||||
def fields(self,address,mp,names):
|
||||
if mp['bits']&(1<<21):
|
||||
props=self.ptr(self.mem.u32(address+4))
|
||||
return self.dictionary(props,names) or {}
|
||||
descriptors=self.descriptors(self.ptr(mp['descriptors']))
|
||||
if descriptors is None:return {}
|
||||
own=(mp['bits']>>10)&1023;out={}
|
||||
for d in descriptors[:own]:
|
||||
if d['name'] not in names:continue
|
||||
if d['field']:
|
||||
base=address if d['inobject'] else self.ptr(self.mem.u32(address+4))
|
||||
tag=self.mem.u32(base+d['offset']) if base else None
|
||||
if d['representation']==2:
|
||||
raw=self.mem.read(base+d['offset'],8) if base else b''
|
||||
out[d['name']]=struct.unpack('<d',raw)[0] if len(raw)==8 else None
|
||||
else:out[d['name']]=tag
|
||||
else:out[d['name']]=d['value']
|
||||
return out
|
||||
def dictionary(self,address,names):
|
||||
b=self.mem.read(address,32)
|
||||
if len(b)!=32:return None
|
||||
map_tag,length,count,deleted,capacity=struct.unpack_from('<5I',b)
|
||||
if not map_tag&1 or any(x&1 for x in (count,deleted,capacity)):return None
|
||||
cap=capacity>>1
|
||||
if cap<4 or cap>2048 or cap&(cap-1) or length!=6+3*cap or (count+deleted)>>1>cap:return None
|
||||
mp=self.map(self.ptr(map_tag))
|
||||
if not mp or self.object_type(self.ptr(map_tag))!=0x110:return None
|
||||
raw=self.mem.read(address+32,cap*12)
|
||||
if len(raw)!=cap*12:return None
|
||||
result={}
|
||||
for i in range(cap):
|
||||
key,value,details=struct.unpack_from('<III',raw,i*12)
|
||||
name=self.string(key,max_length=256)
|
||||
if name in names:result[name]=value
|
||||
return result
|
||||
def number(self,tag):
|
||||
if tag is None:return None
|
||||
if not tag&1:return (tag if tag<0x80000000 else tag-0x100000000)>>1
|
||||
a=self.ptr(tag)
|
||||
if self.object_type(a)==0x82:
|
||||
b=self.mem.read(a+4,8)
|
||||
return struct.unpack('<d',b)[0] if len(b)==8 else None
|
||||
return None
|
||||
|
||||
def chunks(mem,regions,cage,max_mb):
|
||||
total=0
|
||||
for r in regions:
|
||||
if r['base']&~0xffffffff!=cage:continue
|
||||
for delta in range(0,r['size'],1024*1024):
|
||||
if total>=max_mb*1024*1024:return
|
||||
a=r['base']+delta;b=mem.read(a,min(1024*1024,r['size']-delta));total+=len(b)
|
||||
yield a,b
|
||||
|
||||
def refs(mem,regions,cage,tags,max_mb):
|
||||
patterns={struct.pack('<I',tag):tag for tag in tags};hits=[]
|
||||
for a,b in chunks(mem,regions,cage,max_mb):
|
||||
for pattern,tag in patterns.items():
|
||||
start=0
|
||||
while True:
|
||||
i=b.find(pattern,start)
|
||||
if i<0:break
|
||||
start=i+4
|
||||
if (a+i)%4==0:hits.append((a+i,tag))
|
||||
return hits
|
||||
|
||||
def locate_collection(v,regions,markers,max_mb):
|
||||
tags={(h['address']-v.cage)|1 for h in markers if h['cage']==v.cage and h['name']=='_models'}
|
||||
arrays={}
|
||||
for address,tag in refs(v.mem,regions,v.cage,tags,max_mb):
|
||||
base=max(v.cage,address-12304);raw=v.mem.read(base,address-base+16)
|
||||
for i in range(min(1024,(address-base)//12+1)):
|
||||
candidate=address-16-i*12;offset=candidate-base
|
||||
if offset<0 or offset+16>len(raw):continue
|
||||
map_tag,alln,n=struct.unpack_from('<IHH',raw,offset)
|
||||
if not map_tag&1 or not i<n<=alln<=1024:continue
|
||||
d=v.descriptors(candidate)
|
||||
if d and d[i]['name']=='_models' and {'pendingAdd','ftsCache','_models','byChat','byParentMessage'}.issubset({x['name'] for x in d}):arrays[candidate]=d
|
||||
maps={}
|
||||
for reference,tag in refs(v.mem,regions,v.cage,{(a-v.cage)|1 for a in arrays},max_mb):
|
||||
candidate=reference-24;mp=v.map(candidate)
|
||||
if not mp or v.object_type(candidate)!=0x110 or mp['bits']&(1<<21):continue
|
||||
own=(mp['bits']>>10)&1023
|
||||
if {'pendingAdd','ftsCache','_models','byChat','byParentMessage'}.issubset({d['name'] for d in arrays[v.ptr(tag)][:own]}):maps[(candidate-v.cage)|1]=(candidate,mp)
|
||||
collections=[]
|
||||
for address,tag in refs(v.mem,regions,v.cage,maps,max_mb):
|
||||
map_address,mp=maps[tag]
|
||||
if mp['type']<1000:continue
|
||||
fields=v.fields(address,mp,{'_models'});arr=v.ptr(fields.get('_models'))
|
||||
if not arr or v.object_type(arr)!=0x84c:continue
|
||||
n=v.number(v.mem.u32(arr+12))
|
||||
if n is None or not 0<=n<=100000:continue
|
||||
n=int(n);elements=v.ptr(v.mem.u32(arr+8));capacity=v.mem.u32(elements+4)
|
||||
if capacity is None or capacity<n or capacity>1000000:continue
|
||||
raw=v.mem.read(elements+8,n*4)
|
||||
if len(raw)!=n*4:continue
|
||||
members=[v.ptr(t[0]) for t in struct.iter_unpack('<I',raw)]
|
||||
if v.mem.u32(address)!=tag or v.mem.u32(arr+12)!=(n<<1):continue
|
||||
collections.append({'owner_address':hex(address),'array_address':hex(arr),'length':n,'members':members})
|
||||
return collections
|
||||
|
||||
def locate_external_table(v,regions,max_mb):
|
||||
"""Locate the shared resource table from several observed external handles.
|
||||
|
||||
Calibration uses public field/type names, never a guessed message string.
|
||||
It verifies independent resource handles before accepting an aligned base.
|
||||
"""
|
||||
requests={h:spec for h,spec in v.external_requests.items() if 0<spec[0]<=64}
|
||||
if len(requests)<2:return None
|
||||
constants={'chat','image','video','audio','document','sticker','id','remote','fromMe','type','body','caption','t','$1','participant','self'}
|
||||
checked=set();total=0
|
||||
for region in regions:
|
||||
if region['base']&~0xffffffff==v.cage:continue
|
||||
for delta in range(0,region['size'],1024*1024):
|
||||
if total>=max_mb*1024*1024:return None
|
||||
address=region['base']+delta;raw=v.mem.read(address,min(1024*1024,region['size']-delta));total+=len(raw)
|
||||
for hit in re.finditer(rb'[\x04\x05]\x00',raw):
|
||||
pos=hit.start()-6
|
||||
if pos<0 or (address+pos)%8:continue
|
||||
entry=struct.unpack_from('<Q',raw,pos)[0];resource=entry&0xff00ffffffffffff
|
||||
if not 0x10000<resource<0x7fffffffffff:continue
|
||||
native=v.mem.read(resource,16)
|
||||
if len(native)!=16:continue
|
||||
vtable,data=struct.unpack('<QQ',native)
|
||||
if not 0x7ff000000000<vtable<0x7fffffffffff:continue
|
||||
for handle,(length,width) in requests.items():
|
||||
base=address+pos-(handle>>6)*8
|
||||
if base<0x10000 or base%65536 or base in checked:continue
|
||||
text=v.mem.read(data,length*width)
|
||||
if len(text)!=length*width:continue
|
||||
if text.decode('latin1' if width==1 else 'utf-16-le',errors='replace') not in constants:continue
|
||||
checked.add(base);valid=0
|
||||
for another,(n,w) in requests.items():
|
||||
b=v.mem.read(base+(another>>6)*8,8)
|
||||
if len(b)!=8:continue
|
||||
e=struct.unpack('<Q',b)[0]
|
||||
if (e>>49)&127!=2:continue
|
||||
res=e&0xff00ffffffffffff;native2=v.mem.read(res,16)
|
||||
if len(native2)!=16:continue
|
||||
vt,chars=struct.unpack('<QQ',native2)
|
||||
if not 0x7ff000000000<vt<0x7fffffffffff:continue
|
||||
data_handle=v.external_data_handles.get(another)
|
||||
if data_handle:
|
||||
data_entry=v.mem.read(base+(data_handle>>6)*8,8)
|
||||
if len(data_entry)!=8:continue
|
||||
de=struct.unpack('<Q',data_entry)[0]
|
||||
if (de>>49)&127!=3:continue
|
||||
chars=de&0xff00ffffffffffff
|
||||
if len(v.mem.read(chars,n*w))==n*w:valid+=1
|
||||
if valid>=3:return base
|
||||
return None
|
||||
|
||||
def read_model(v,address,collection_verified=False):
|
||||
tag=v.mem.u32(address);mp=v.map(v.ptr(tag))
|
||||
if not mp or mp['type']!=0x421:return None
|
||||
props=v.mem.u32(address+4)
|
||||
fields=v.fields(address,mp,{'__x_body','__x_caption','__x_id','__x_type','__x_t'})
|
||||
if '__x_id' not in fields:return None
|
||||
key_address=v.ptr(fields.get('__x_id'));key_tag=v.mem.u32(key_address) if key_address else None
|
||||
key_map=v.map(v.ptr(key_tag)) if key_address else None
|
||||
key_fields=v.fields(key_address,key_map,{'$1','id','fromMe','remote'}) if key_map else {}
|
||||
if not {'id','fromMe','remote'}.issubset(key_fields):return None
|
||||
body=v.string(fields.get('__x_body'));caption=v.string(fields.get('__x_caption'))
|
||||
typ=v.string(fields.get('__x_type'),max_length=64);message_id=v.string(key_fields.get('id'),max_length=256)
|
||||
key=v.string(key_fields.get('$1'));timestamp=v.number(fields.get('__x_t'))
|
||||
if timestamp is not None and not 1200000000<=timestamp<=2200000000:return None
|
||||
if v.mem.u32(address)!=tag or v.mem.u32(address+4)!=props or v.mem.u32(key_address)!=key_tag:return None
|
||||
if v.fields(address,mp,{'__x_body','__x_caption','__x_id','__x_type','__x_t'})!=fields:return None
|
||||
states={}
|
||||
for name,field,value in [('body','__x_body',body),('caption','__x_caption',caption),('type','__x_type',typ)]:
|
||||
raw=fields.get(field);object_type=v.object_type(v.ptr(raw)) if raw and raw&1 else None
|
||||
states[name]='decoded' if value is not None else 'unsupported_string_representation' if object_type is not None and object_type<128 else 'not_a_string_or_absent'
|
||||
states['message_id']='decoded' if message_id is not None else 'unsupported_or_absent'
|
||||
states['timestamp']='decoded' if timestamp is not None else 'unsupported_or_absent'
|
||||
key_parts=key.split('_',2) if key else []
|
||||
chat_id=key_parts[1] if len(key_parts)==3 and key_parts[0] in ('true','false') else None
|
||||
from_me=(key_parts[0]=='true') if chat_id is not None else None
|
||||
return {'pid':v.mem.pid,'object_address':hex(address),'map_address':hex(v.ptr(tag)),'message_key':key,'message_id':message_id,'chat_id':chat_id,'from_me':from_me,'type':typ,'timestamp':timestamp,'body':body,'caption':caption,'field_status':states,'in_message_collection':collection_verified,'confidence':'validated V8 Msg model and MsgKey layout; '+('membership in MsgCollection._models' if collection_verified else 'collection membership not established')}
|
||||
|
||||
def recover(mem,regions,markers,max_mb,limit):
|
||||
messages=[];diagnostics=[]
|
||||
for cage in sorted({h['cage'] for h in markers if h['name']=='__x_body'}):
|
||||
v=V8(mem,cage);tags={(h['address']-cage)|1 for h in markers if h['cage']==cage and h['name']=='__x_body'}
|
||||
arrays={}
|
||||
for address,tag in refs(mem,regions,cage,tags,max_mb):
|
||||
# Search backwards for an actual DescriptorArray containing this key.
|
||||
raw=mem.read(max(cage,address-12304),min(address-cage,12304)+16)
|
||||
base=max(cage,address-12304)
|
||||
for index in range(min(1024,(address-base)//12+1)):
|
||||
candidate=address-16-index*12
|
||||
pos=candidate-base
|
||||
if pos<0 or pos+16>len(raw):continue
|
||||
map_tag=struct.unpack_from('<I',raw,pos)[0]
|
||||
if not map_tag&1:continue
|
||||
alln,n=struct.unpack_from('<HH',raw,pos+4)
|
||||
if not index<n<=alln<=1024:continue
|
||||
if v.object_type(candidate)!=0x106:continue
|
||||
d=v.descriptors(candidate)
|
||||
if d and d[index]['name']=='__x_body' and {'__x_id','__x_type','__x_t'}.issubset({f['name'] for f in d}):arrays[candidate]=d
|
||||
maps={}
|
||||
for reference,tag in refs(mem,regions,cage,{(a-cage)|1 for a in arrays},max_mb):
|
||||
candidate=reference-24;mp=v.map(candidate)
|
||||
if not mp or v.object_type(candidate)!=0x110 or mp['bits']&(1<<21):continue
|
||||
own=(mp['bits']>>10)&1023;descs=arrays.get(v.ptr(tag),[])[:own]
|
||||
if {'__x_body','__x_id','__x_type','__x_t'}.issubset({d['name'] for d in descs}):maps[(candidate-cage)|1]=(candidate,mp)
|
||||
candidates=refs(mem,regions,cage,maps,max_mb)
|
||||
# WhatsApp's large models usually use a NameDictionary rather than fast properties.
|
||||
dictionaries=set()
|
||||
for address,tag in refs(mem,regions,cage,tags,max_mb):
|
||||
base=max(cage,address-24608);raw=mem.read(base,address-base+16)
|
||||
for i in range(min(2048,(address-base)//12+1)):
|
||||
obj=address-32-12*i;offset=obj-base
|
||||
if offset<0 or offset+20>len(raw):continue
|
||||
map_tag,length,count,deleted,capacity=struct.unpack_from('<5I',raw,offset)
|
||||
cap=capacity>>1
|
||||
if any(x&1 for x in (count,deleted,capacity)) or cap<4 or cap>2048 or cap&(cap-1) or i>=cap or length!=6+cap*3 or (count+deleted)>>1>cap:continue
|
||||
d=v.dictionary(obj,{'__x_body','__x_id','__x_type','__x_t','__x_caption'})
|
||||
if d and {'__x_body','__x_id','__x_type','__x_t'}.issubset(d):dictionaries.add(obj)
|
||||
for reference,dict_tag in refs(mem,regions,cage,{(a-cage)|1 for a in dictionaries},max_mb):
|
||||
obj=reference-4;obj_tag=mem.u32(obj);mp=v.map(v.ptr(obj_tag))
|
||||
if not mp or not mp['bits']&(1<<21) or v.object_type(v.ptr(obj_tag))!=0x110:continue
|
||||
# The candidate's actual properties pointer must equal the discovered dictionary.
|
||||
if mem.u32(obj+4)!=dict_tag:continue
|
||||
maps[obj_tag]=(v.ptr(obj_tag),mp);candidates.append((obj,obj_tag))
|
||||
seen=set()
|
||||
for address,tag in candidates:
|
||||
if address in seen:continue
|
||||
seen.add(address);map_address,mp=maps[tag]
|
||||
# Race check: field reads are discarded if the object's Map changes.
|
||||
fields=v.fields(address,mp,{'__x_body','__x_caption','__x_id','__x_type','__x_t'})
|
||||
body=v.string(fields.get('__x_body'));caption=v.string(fields.get('__x_caption'));typ=v.string(fields.get('__x_type'),max_length=64)
|
||||
if body is None and caption is None:continue
|
||||
if not typ or not re.fullmatch(r'[a-zA-Z_]{1,40}',typ):continue
|
||||
key_address=v.ptr(fields.get('__x_id'));key_map=v.map(v.ptr(mem.u32(key_address))) if key_address else None
|
||||
key_fields=v.fields(key_address,key_map,{'$1','id','fromMe','remote'}) if key_map else {}
|
||||
key=v.string(key_fields.get('$1'));message_id=v.string(key_fields.get('id'),max_length=256)
|
||||
if not message_id or not re.fullmatch(r'[A-Za-z0-9_-]{6,200}',message_id):continue
|
||||
timestamp=v.number(fields.get('__x_t'))
|
||||
if timestamp is None or not 1200000000<=timestamp<=2200000000:continue
|
||||
if mem.u32(address)!=tag:continue
|
||||
messages.append({'pid':mem.pid,'object_address':hex(address),'map_address':hex(map_address),'message_key':key,'message_id':message_id,'type':typ,'timestamp':timestamp,'body':body,'caption':caption,'confidence':'validated V8 Map/descriptors and MsgKey shape; liveness not established'})
|
||||
if len(messages)>=limit:break
|
||||
collections=locate_collection(v,regions,markers,max_mb)
|
||||
member_addresses={a for c in collections for a in c['members']}
|
||||
# The preliminary schema reads above record handles for external strings.
|
||||
v.external_table=locate_external_table(v,regions,max_mb)
|
||||
if collections:
|
||||
messages=[message for message in messages if message['pid']!=mem.pid]
|
||||
for collection in collections:
|
||||
for address in collection['members']:
|
||||
if len(messages)>=limit:break
|
||||
message=read_model(v,address,True)
|
||||
if message:messages.append(message)
|
||||
diagnostics.append({'cage_base':hex(cage),'descriptor_arrays':len(arrays),'message_dictionaries':len(dictionaries),'message_maps':len(maps),'candidate_objects':len(candidates),'external_string_table_found':v.external_table is not None,'collections':[{k:val for k,val in c.items() if k!='members'} for c in collections]})
|
||||
if len(messages)>=limit:break
|
||||
return messages,diagnostics
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--pid',type=int);p.add_argument('--max-mb',type=int,default=512);p.add_argument('--limit',type=int,default=100);p.add_argument('--redact-text',action='store_true');p.add_argument('--output',type=Path,default=Path('whatsapp-ram-report.json'))
|
||||
p.add_argument('--export-db',action='store_true',help='Export all local database rows through an already-exposed Desktop CDP interface')
|
||||
p.add_argument('--cdp',help='Existing WhatsApp Desktop loopback CDP endpoint for --export-db')
|
||||
p.add_argument('--batch-size',type=int,default=100)
|
||||
args=p.parse_args(argv)
|
||||
if args.export_db:
|
||||
from .history import export
|
||||
from .analyzer import endpoint
|
||||
if not 1<=args.batch_size<=500:p.error('--batch-size must be 1..500')
|
||||
destination=Path('whatsapp-local-history.jsonl') if args.output==Path('whatsapp-ram-report.json') else args.output
|
||||
try:
|
||||
report=export(endpoint(args.cdp) if args.cdp else None,destination,args.batch_size,args.redact_text)
|
||||
except (RuntimeError,OSError,argparse.ArgumentTypeError) as error:
|
||||
print(str(error));return 2
|
||||
print(json.dumps(report));return 0 if not report['row_errors'] else 1
|
||||
if args.cdp:p.error('--cdp requires --export-db')
|
||||
if sys.platform!='win32' or C.sizeof(C.c_void_p)!=8:p.error('64-bit Windows Python required')
|
||||
if not 1<=args.limit<=1000:p.error('--limit must be between 1 and 1000')
|
||||
if not 16<=args.max_mb<=4096:p.error('--max-mb must be between 16 and 4096')
|
||||
targets=discover()
|
||||
if args.pid:targets=[t for t in targets if t['ProcessId']==args.pid]
|
||||
results=[]
|
||||
for target in targets:
|
||||
try:mem=Memory(target['ProcessId'])
|
||||
except OSError as error:
|
||||
results.append({'pid':target['ProcessId'],'messages':[],'markers':[],'diagnostics':[],'error':str(error)});continue
|
||||
try:
|
||||
regions=list(mem.regions());hits=scan_markers(mem,regions,args.max_mb)
|
||||
messages,diagnostics=recover(mem,regions,hits,args.max_mb,args.limit)
|
||||
if args.redact_text:
|
||||
for message in messages:
|
||||
for field in ('body','caption'):
|
||||
message[field+'_length']=len(message[field]) if message.get(field) is not None else None
|
||||
message.pop(field,None)
|
||||
results.append({'pid':mem.pid,'debug_flags_present':target['DebugFlagsPresent'],'private_readable_bytes':sum(r['size'] for r in regions),'markers':hits,'messages':messages,'diagnostics':diagnostics,'bytes_read':mem.read_bytes})
|
||||
finally:mem.close()
|
||||
report={'timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),'mode':'ReadProcessMemory only; normal running application','layout':'V8 15.4.11 compressed pointers; runtime type and schema validated','rea_static_evidence':'ev_6186f4a795decd53dc3dde7935f9f6dd6bb2d2bb32816e7640842d5d41010d3d','results':results,'limitations':['Live heap can move during collection; this is not an atomic snapshot.','MsgCollection membership is checked when the collection is located; fallback objects are explicitly marked unverified.','Only loaded models can be recovered; all chat history is not necessarily in RAM.','Sequential, Cons, Thin, Sliced and validated external strings are supported. Null/uncacheable external resources or unexpected layouts are marked unsupported.','Numeric V8 instance types were corroborated for this WebView2 build; another engine version may require layout changes.','Missing fields remain null and are marked in field_status; no public JavaScript getter is executed to synthesize defaults.','Output contains private message text unless --redact-text is used. No credentials, keys or full process dump are exported.']}
|
||||
args.output.write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
print(json.dumps({'targets':len(results),'markers':[len(r['markers']) for r in results],'messages':[len(r['messages']) for r in results],'diagnostics':[r['diagnostics'] for r in results],'report':str(args.output.resolve())}))
|
||||
return 0 if any(r['messages'] for r in results) else 1
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Search loaded WhatsApp Desktop message objects via read-only process RAM.
|
||||
|
||||
Does not invoke WhatsApp's native FTS engine or load older history. The query
|
||||
matches Unicode-normalized, case-insensitive literal text in body/caption.
|
||||
"""
|
||||
from .common import normalized as normalize
|
||||
import argparse, ctypes, datetime as dt, json, sys, unicodedata
|
||||
from pathlib import Path
|
||||
from .ram import Memory, discover, scan_markers, recover
|
||||
|
||||
EVIDENCE='ev_fd70aa06525b6f2259bf6d98bc879adb2472f63f2407a2d0d1186cc9e27b2f21'
|
||||
|
||||
|
||||
def search_models(models,query,chat_id=None,redact=False):
|
||||
needle=normalize(query)
|
||||
if not needle.strip():raise ValueError('Query must contain non-whitespace text')
|
||||
matches=[]
|
||||
for model in models:
|
||||
# Reject schema-only candidates without verified collection membership.
|
||||
if not model.get('in_message_collection'):continue
|
||||
if chat_id is not None and model.get('chat_id')!=chat_id:continue
|
||||
fields=[field for field in ('body','caption') if isinstance(model.get(field),str) and needle in normalize(model[field])]
|
||||
if not fields:continue
|
||||
result=dict(model);result['matched_fields']=fields
|
||||
if redact:
|
||||
for field in ('body','caption'):
|
||||
value=result.pop(field,None)
|
||||
result[field+'_length']=len(value) if isinstance(value,str) else None
|
||||
matches.append(result)
|
||||
return matches
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('query')
|
||||
p.add_argument('--chat-id')
|
||||
p.add_argument('--max-mb',type=int,default=512)
|
||||
p.add_argument('--max-models',type=int,default=10000)
|
||||
p.add_argument('--redact-text',action='store_true')
|
||||
p.add_argument('--output',type=Path,default=Path('whatsapp-process-search.json'))
|
||||
args=p.parse_args(argv)
|
||||
if sys.platform!='win32' or ctypes.sizeof(ctypes.c_void_p)!=8:p.error('64-bit Windows Python required')
|
||||
if not args.query.strip():p.error('Query must contain non-whitespace text')
|
||||
if not 16<=args.max_mb<=4096:p.error('--max-mb must be 16..4096')
|
||||
if not 1<=args.max_models<=10000:p.error('--max-models must be 1..10000')
|
||||
if args.output.exists():p.error('Output already exists; choose a new --output path')
|
||||
targets=discover();results=[]
|
||||
for target in targets:
|
||||
pid=target['ProcessId']
|
||||
try:mem=Memory(pid)
|
||||
except OSError as error:
|
||||
results.append({'pid':pid,'error':str(error),'matches':[]});continue
|
||||
try:
|
||||
regions=list(mem.regions())
|
||||
markers=scan_markers(mem,regions,args.max_mb)
|
||||
models,diagnostics=recover(mem,regions,markers,args.max_mb,args.max_models)
|
||||
verified=[m for m in models if m.get('in_message_collection')]
|
||||
matches=search_models(verified,args.query,args.chat_id,args.redact_text)
|
||||
results.append({'pid':pid,'loaded_models_examined':len(verified),'matches':matches,
|
||||
'diagnostics':diagnostics,'bytes_read':mem.read_bytes,
|
||||
'model_limit_reached':len(models)>=args.max_models,
|
||||
'debug_flags_present':target['DebugFlagsPresent']})
|
||||
finally:mem.close()
|
||||
report={'timestamp_utc':dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
'mode':'ReadProcessMemory; literal search of collection-verified loaded Msg objects',
|
||||
'whatsapp_native_search_invoked':False,'rea_search_evidence':EVIDENCE,
|
||||
'results':results,'limitations':['Loaded message objects only; not the full IndexedDB or native FTS index.',
|
||||
'No process execution, writes, injection, debugger, UI control, or new history query.',
|
||||
'Live heap may move; the reader checks object layout and collection membership but is not an atomic snapshot.',
|
||||
'Search uses NFC/casefold literal matching; it does not reproduce WhatsApp tokenizer, prefix matching or ranking.',
|
||||
'No match is not evidence of absence from account history.']}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(report,indent=2,ensure_ascii=False),encoding='utf-8')
|
||||
print(json.dumps({'targets':len(results),'loaded_models_examined':sum(r.get('loaded_models_examined',0) for r in results),
|
||||
'matches':sum(len(r['matches']) for r in results),'native_search_invoked':False,
|
||||
'report':str(args.output.resolve())}))
|
||||
return 0 if any(r.get('loaded_models_examined') for r in results) else 2
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Execute regression tests and write a machine-readable result."""
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from .common import BASE, _BUNDLE
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--live', action='store_true', help='Also run fixed arithmetic probes in running WhatsApp Desktop')
|
||||
parser.add_argument('--capabilities', action='store_true', help='Also run fixed Desktop capability checks (implies --live)')
|
||||
parser.add_argument('--pid', type=int, help='Desktop host PID for live checks')
|
||||
parser.add_argument('--output', type=Path, default=BASE / 'analysis/whatsapp-suite/results.json')
|
||||
args = parser.parse_args(argv)
|
||||
if args.pid is not None and not (args.live or args.capabilities):
|
||||
parser.error('--pid requires --live or --capabilities')
|
||||
started = time.monotonic()
|
||||
if _BUNDLE:
|
||||
from . import _regression
|
||||
tests = unittest.defaultTestLoader.loadTestsFromModule(_regression)
|
||||
else:
|
||||
tests = unittest.defaultTestLoader.discover(str(BASE / 'tests'), pattern='test_whatsapp_*.py')
|
||||
result = unittest.TextTestRunner(verbosity=2).run(tests)
|
||||
report = {
|
||||
'passed': result.wasSuccessful() and result.testsRun > 0,
|
||||
'offline': {'tests': result.testsRun, 'failures': len(result.failures),
|
||||
'errors': len(result.errors), 'skipped': len(result.skipped),
|
||||
'failure_details': [{'test': str(test), 'detail': detail} for test, detail in result.failures + result.errors],
|
||||
'skip_details': [{'test': str(test), 'reason': reason} for test, reason in result.skipped]},
|
||||
'live': {'status': 'not_requested'},
|
||||
}
|
||||
if args.live or args.capabilities:
|
||||
from .live import run_math
|
||||
# Keep complete live probe data in the JSON report, with a compact console summary.
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
try:
|
||||
report['live'] = run_math(args.pid, 'all' if args.capabilities else 'math')
|
||||
except Exception as error:
|
||||
report['live'] = {'passed': False, 'error': str(error)}
|
||||
report['passed'] = report['passed'] and report['live'].get('passed', False)
|
||||
report['duration_seconds'] = round(time.monotonic() - started, 3)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps({'passed': report['passed'], 'tests': result.testsRun,
|
||||
'skipped': len(result.skipped), 'report': str(args.output)}))
|
||||
return 0 if report['passed'] else 1
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Temporary app-scoped WebView2 debugging for the installed Desktop session."""
|
||||
from contextlib import contextmanager
|
||||
import json, socket, subprocess, time, urllib.request
|
||||
|
||||
KEY=r'Software\Policies\Microsoft\Edge\WebView2\AdditionalBrowserArguments'
|
||||
APP='WhatsApp.Root.exe'
|
||||
ACTIVATE=r"Get-Process -Name WhatsApp.Root -ErrorAction SilentlyContinue | Stop-Process; Start-Process -FilePath explorer.exe -ArgumentList 'shell:AppsFolder\5319275A.WhatsAppDesktop_cv1g1gvanyjgm!App' -WindowStyle Hidden"
|
||||
|
||||
def restart():
|
||||
subprocess.run(['powershell.exe','-NoProfile','-NonInteractive','-Command',ACTIVATE],capture_output=True,text=True,check=True)
|
||||
|
||||
@contextmanager
|
||||
def temporary_desktop_cdp():
|
||||
import winreg
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
||||
key=winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER,KEY,0,winreg.KEY_READ|winreg.KEY_SET_VALUE)
|
||||
try:
|
||||
try:old=winreg.QueryValueEx(key,APP)
|
||||
except FileNotFoundError:old=None
|
||||
flags=f'--remote-debugging-address=127.0.0.1 --remote-debugging-port={port}'
|
||||
if old and old[1]!=winreg.REG_SZ:raise RuntimeError('Unexpected existing WebView2 policy value type')
|
||||
if old and '--remote-debugging' in old[0]:raise RuntimeError('Existing debug policy requires explicit endpoint selection')
|
||||
winreg.SetValueEx(key,APP,0,winreg.REG_SZ,((old[0]+' ') if old else '')+flags)
|
||||
try:
|
||||
print('Restarting WhatsApp Desktop with a temporary loopback execution interface.',flush=True)
|
||||
restart();endpoint=f'http://127.0.0.1:{port}'
|
||||
deadline=time.monotonic()+60
|
||||
while time.monotonic()<deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(endpoint+'/json/list',timeout=2) as response:targets=json.load(response)
|
||||
if any(t.get('type')=='page' and t.get('url','').startswith('https://web.whatsapp.com/') for t in targets):
|
||||
yield endpoint
|
||||
return
|
||||
except OSError:pass
|
||||
time.sleep(1)
|
||||
raise RuntimeError('Desktop did not expose its WhatsApp page within 60 seconds')
|
||||
finally:
|
||||
if old is None:winreg.DeleteValue(key,APP)
|
||||
else:winreg.SetValueEx(key,APP,0,old[1],old[0])
|
||||
restart()
|
||||
print('Temporary setting restored; Desktop restarted normally.',flush=True)
|
||||
finally:winreg.CloseKey(key)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Send one fixed test message exclusively to the signed-in account itself."""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from .live import run_math
|
||||
from .zip_export import evaluate
|
||||
|
||||
PROBE = r'''(async () => {
|
||||
const prefs=require('WAWebUserPrefsMeUser');
|
||||
const me=prefs.getMaybeMeLidUser()??prefs.getMaybeMePnUser();
|
||||
if(!me||!prefs.isMeAccount(me))throw Error('Own account identity unavailable');
|
||||
const {chat}=await require('WAWebFindChatAction').findOrCreateLatestChat(me,'sendSelfTest');
|
||||
if(!chat||!prefs.isMeAccount(chat.id))throw Error('Refusing to send outside own account');
|
||||
const action=require('WAWebSendTextMsgChatAction');
|
||||
const body=__BODY__;
|
||||
const data=await action.createTextMsgData(chat,body,{});
|
||||
if(!data?.id||!prefs.isMeAccount(data.id.remote))throw Error('Outgoing message is not addressed to self');
|
||||
const id=data.id.toString();
|
||||
await action.addAndSendTextMsg(chat,data);
|
||||
const model=chat.msgs.get(data.id)??require('WAWebMsgCollection').MsgCollection.get(data.id);
|
||||
const row=await require('WAWebSchemaMessage').getMessageTable().get(id);
|
||||
const decoded=row?require('WAWebDBMessageSerialization').messageFromDbRow(row):null;
|
||||
return {recipient_is_self:true,message_id:id,ack:model?.ack??null,
|
||||
send_completed:true,persisted:!!row,plaintext_matches:decoded?.body===body,
|
||||
timestamp:decoded?.t??null,body_length:body.length,cdp:false,ui_automation:false};
|
||||
})()'''
|
||||
|
||||
|
||||
def send_self(output, pid=None):
|
||||
output=Path(output)
|
||||
if output.exists():
|
||||
raise ValueError('Receipt exists; choose a new output. Sending is never retried automatically.')
|
||||
output.parent.mkdir(parents=True,exist_ok=True)
|
||||
body='WhatsApp JS-Sendetest an mich selbst — '+dt.datetime.now().isoformat(timespec='seconds')+' — '+uuid.uuid4().hex[:8]
|
||||
# Reserve the receipt before sending; a failed/uncertain attempt must be inspected before retrying.
|
||||
output.write_text(json.dumps({'status':'reserved','test_message':body}),encoding='utf-8')
|
||||
def operation(execute,session,report):
|
||||
result=evaluate(execute,PROBE.replace('__BODY__',json.dumps(body)),session)
|
||||
report['send_result']=result
|
||||
if not (result.get('recipient_is_self') and result.get('send_completed') and result.get('persisted') and result.get('plaintext_matches')):
|
||||
raise RuntimeError('Self-message send/readback verification failed; do not retry without checking the receipt')
|
||||
report=run_math(pid,'send-self',_operation=operation)
|
||||
report['test_message']=body
|
||||
output.write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
return report
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--pid',type=int)
|
||||
parser.add_argument('--output',type=Path,default=Path('analysis/whatsapp-suite')/('self-message-'+dt.datetime.now().strftime('%Y%m%d-%H%M%S')+'.json'))
|
||||
args=parser.parse_args(argv)
|
||||
report=send_self(args.output,args.pid)
|
||||
print(json.dumps({'passed':report['passed'],'error':report.get('error'),'send_result':report.get('send_result'),'receipt':str(args.output)}))
|
||||
return 0 if report['passed'] else 1
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Isolated WebView2 test host."""
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from .common import BASE
|
||||
ROOT = BASE / "webview2-harmless-test"
|
||||
VERSION = "1.0.2903.40"
|
||||
|
||||
SOURCE = r'''
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Microsoft.Web.WebView2.WinForms;
|
||||
class Probe : Form {
|
||||
WebView2 view = new WebView2();
|
||||
string report;
|
||||
Timer deadline = new Timer();
|
||||
bool completed;
|
||||
Probe(string path) {
|
||||
report = path; ShowInTaskbar = false; Opacity = 0;
|
||||
Controls.Add(view); view.Dock = DockStyle.Fill;
|
||||
deadline.Interval = 45000;
|
||||
deadline.Tick += (s,e) => Finish("{\"passed\":false,\"error\":\"timeout\"}", 2);
|
||||
Shown += async (s,e) => {
|
||||
deadline.Start();
|
||||
try {
|
||||
var env = await CoreWebView2Environment.CreateAsync(null,
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "test-profile"));
|
||||
await view.EnsureCoreWebView2Async(env);
|
||||
var result = await view.CoreWebView2.ExecuteScriptAsync(
|
||||
"({arithmetic:1+1,marker:'isolated-webview2-test',url:location.href})");
|
||||
bool ok = result.Contains("\"arithmetic\":2") && result.Contains("isolated-webview2-test");
|
||||
Finish("{\"passed\":" + (ok ? "true" : "false") +
|
||||
",\"method\":\"CoreWebView2.ExecuteScriptAsync\",\"external_process_injection\":false," +
|
||||
"\"result\":" + result + "}", ok ? 0 : 1);
|
||||
} catch(Exception ex) {
|
||||
File.WriteAllText(report + ".error.txt", ex.ToString());
|
||||
Finish("{\"passed\":false,\"error\":\"See .error.txt\"}", 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
void Finish(string data, int code) {
|
||||
if(completed) return; completed=true; deadline.Stop();
|
||||
File.WriteAllText(report,data); Environment.ExitCode=code;
|
||||
view.Dispose(); Close();
|
||||
}
|
||||
[STAThread] static void Main(string[] args) {
|
||||
Application.EnableVisualStyles(); Application.Run(new Probe(args[0]));
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
def run_webview_test():
|
||||
ROOT.mkdir(exist_ok=True)
|
||||
package = ROOT / ('webview2-' + VERSION + '.nupkg')
|
||||
if not package.exists():
|
||||
urllib.request.urlretrieve(
|
||||
'https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/' + VERSION +
|
||||
'/microsoft.web.webview2.' + VERSION + '.nupkg', package)
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
for name in ('lib/net462/Microsoft.Web.WebView2.Core.dll',
|
||||
'lib/net462/Microsoft.Web.WebView2.WinForms.dll',
|
||||
'runtimes/win-x64/native/WebView2Loader.dll'):
|
||||
with archive.open(name) as src, (ROOT / pathlib.Path(name).name).open('wb') as dest:
|
||||
shutil.copyfileobj(src, dest)
|
||||
source = ROOT / 'Probe.cs'
|
||||
source.write_text(SOURCE, encoding='utf-8')
|
||||
exe = ROOT / 'Probe.exe'
|
||||
compiler = r'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe'
|
||||
subprocess.run([compiler, '/nologo', '/target:winexe', '/platform:x64',
|
||||
'/out:' + str(exe), '/r:System.Windows.Forms.dll',
|
||||
'/r:System.Drawing.dll',
|
||||
'/r:' + str(ROOT / 'Microsoft.Web.WebView2.Core.dll'),
|
||||
'/r:' + str(ROOT / 'Microsoft.Web.WebView2.WinForms.dll'),
|
||||
str(source)], check=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
report = ROOT / 'result.json'
|
||||
report.unlink(missing_ok=True)
|
||||
run = subprocess.run([str(exe), str(report)], cwd=ROOT, timeout=60,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
if report.exists():
|
||||
print(json.dumps(json.loads(report.read_text(encoding='utf-8-sig')), indent=2))
|
||||
else:
|
||||
raise RuntimeError('Test host exited without a report: ' + str(run.returncode))
|
||||
return run.returncode
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Export all local message rows to ZIP via the native WebView JavaScript bridge.
|
||||
|
||||
No CDP, UI automation, app restart, or phone history sync. The ZIP contains
|
||||
messages.jsonl and manifest.json; media binaries are not downloaded.
|
||||
"""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
from .history import expression
|
||||
from .live import data_chunk_expression, run_math
|
||||
|
||||
|
||||
def evaluate(execute, value, session):
|
||||
"""Run the fixed async database probe and stream its result across the bridge."""
|
||||
key = '__whatsapp_zip_' + session
|
||||
literal = json.dumps(key)
|
||||
start = '''(() => {
|
||||
const key=__KEY__,id=__ID__,state={probe_id:id,state:'pending'};window[key]=state;
|
||||
Promise.resolve(__VALUE__).then(value=>{
|
||||
state.json=JSON.stringify(value);state.state='ready';
|
||||
}).catch(error=>{state.state='error';state.error=String(error).slice(0,500);});
|
||||
return {probe_id:id,state:'started'};
|
||||
})()'''.replace('__KEY__', literal).replace('__ID__', json.dumps(session)).replace('__VALUE__', value)
|
||||
poll = '''(() => {const v=window[__KEY__];return {probe_id:v?.probe_id,state:v?.state,error:v?.error,length:v?.json?.length};})()'''.replace('__KEY__', literal)
|
||||
try:
|
||||
initial = json.loads(execute(start, 'export batch startup'))
|
||||
if initial.get('probe_id') != session or initial.get('state') != 'started':
|
||||
raise ValueError('Export startup response mismatch')
|
||||
deadline = time.monotonic() + 60
|
||||
while True:
|
||||
meta = json.loads(execute(poll, 'export batch poll'))
|
||||
if meta.get('probe_id') != session:
|
||||
raise ValueError('Export session mismatch')
|
||||
if meta.get('state') == 'error':
|
||||
raise RuntimeError(meta.get('error', 'JavaScript export failed'))
|
||||
if meta.get('state') == 'ready':
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError('JavaScript export batch timed out')
|
||||
time.sleep(.05)
|
||||
length = meta.get('length')
|
||||
if type(length) is not int or not 0 < length <= 16 * 1024 * 1024:
|
||||
raise ValueError('Export batch exceeds 16 MiB transport bound; reduce --batch-size')
|
||||
offset, parts = 0, []
|
||||
while offset < length:
|
||||
part = json.loads(execute(data_chunk_expression(key, offset), 'export batch chunk'))
|
||||
following = part.get('next')
|
||||
if (part.get('probe_id') != session or part.get('offset') != offset
|
||||
or type(following) is not int or not offset < following <= length
|
||||
or not isinstance(part.get('chunk'), str)
|
||||
or len(part['chunk'].encode('utf-16-le', errors='surrogatepass')) // 2 != following-offset):
|
||||
raise ValueError('Export transport chunk mismatch')
|
||||
parts.append(part['chunk'])
|
||||
offset = following
|
||||
return json.loads(''.join(parts))
|
||||
finally:
|
||||
execute('delete window[' + literal + ']', 'export JavaScript state cleanup')
|
||||
|
||||
|
||||
def write_messages(read, archive, batch, redact):
|
||||
"""Stream a bounded keyset scan, preserving a record for every row error."""
|
||||
bounds = read({'mode': 'bounds'})
|
||||
count, upper = bounds['count'], bounds['upper']
|
||||
after, rows, errors, seen = None, 0, 0, set()
|
||||
with archive.open('messages.jsonl', 'w', force_zip64=True) as stream:
|
||||
while upper is not None and after != upper:
|
||||
page = read({'mode': 'batch', 'upper': upper, 'after': after, 'batch': batch, 'redact': redact})
|
||||
keys, messages = page['keys'], page['messages']
|
||||
if not keys:
|
||||
raise RuntimeError('History cursor ended before the initial upper bound')
|
||||
if len(keys) != len(messages) or page['last'] != keys[-1] or page['last'] == after:
|
||||
raise RuntimeError('Invalid or nonprogressing history page')
|
||||
for key, message in zip(keys, messages):
|
||||
if key in seen or message.get('id') != key:
|
||||
raise RuntimeError('Duplicate key or mismatched history message')
|
||||
seen.add(key)
|
||||
stream.write((json.dumps(message, ensure_ascii=True) + '\n').encode('utf-8'))
|
||||
rows += 1
|
||||
errors += int('error' in message)
|
||||
after = page['last']
|
||||
print(json.dumps({'exported_rows': rows, 'initial_rows': count, 'row_errors': errors}), flush=True)
|
||||
final = read({'mode': 'bounds'})
|
||||
return {'rows': rows, 'row_errors': errors, 'initial_row_count': count,
|
||||
'final_row_count': final['count'], 'initial_upper_key': upper,
|
||||
'reached_initial_upper': after == upper, 'snapshot_atomic': False,
|
||||
'count_matches_initial': rows == count}
|
||||
|
||||
|
||||
def export_zip(output, pid=None, batch=100, redact=False):
|
||||
output = Path(output).resolve()
|
||||
if not 1 <= batch <= 500:
|
||||
raise ValueError('Batch size must be 1..500')
|
||||
partial = output.with_suffix(output.suffix + '.partial')
|
||||
report_path = output.with_suffix(output.suffix + '.report.json')
|
||||
if output.exists() or partial.exists() or report_path.exists():
|
||||
raise ValueError('Output or partial/report exists; select a new --output')
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
summary = {'cdp': False, 'ui_automation': False, 'transport': 'native WebView ExecuteScriptAsync',
|
||||
'source': 'WhatsApp JavaScript message table getter and decoder',
|
||||
'scope': 'All local message rows up to initial upper primary key; no phone sync or media download',
|
||||
'created_utc': dt.datetime.now(dt.timezone.utc).isoformat(), 'text_redacted': redact}
|
||||
def operation(execute, session, bridge):
|
||||
with zipfile.ZipFile(partial, 'x', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
summary.update(write_messages(lambda options: evaluate(execute, expression(options), session), archive, batch, redact))
|
||||
bridge = run_math(pid, 'history-export', _operation=operation)
|
||||
summary['bridge'] = bridge
|
||||
summary['passed'] = bool(bridge['passed'] and summary.get('reached_initial_upper') and not summary.get('row_errors'))
|
||||
summary['status'] = 'completed' if summary['passed'] else 'completed_with_errors' if bridge['passed'] else 'failed_partial_export'
|
||||
try:
|
||||
if bridge['passed']:
|
||||
with zipfile.ZipFile(partial, 'a', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('manifest.json', json.dumps(summary, indent=2))
|
||||
with zipfile.ZipFile(partial) as archive:
|
||||
bad = archive.testzip()
|
||||
if bad:
|
||||
raise RuntimeError('ZIP integrity failed: ' + bad)
|
||||
# Publish without overwriting an output created during the live scan.
|
||||
os.link(partial, output)
|
||||
partial.unlink()
|
||||
summary['output'] = str(output)
|
||||
else:
|
||||
summary['partial_output'] = str(partial)
|
||||
except Exception as error:
|
||||
summary.update(passed=False, status='failed_partial_export', error=str(error), partial_output=str(partial))
|
||||
report_path.write_text(json.dumps(summary, indent=2) + '\n', encoding='utf-8')
|
||||
return summary
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, default=Path('exports') / ('whatsapp-' + dt.datetime.now().strftime('%Y%m%d-%H%M%S') + '.zip'))
|
||||
parser.add_argument('--pid', type=int)
|
||||
parser.add_argument('--batch-size', type=int, default=100)
|
||||
parser.add_argument('--redact-text', action='store_true')
|
||||
args = parser.parse_args(argv)
|
||||
report = export_zip(args.output, args.pid, args.batch_size, args.redact_text)
|
||||
print(json.dumps({k: report[k] for k in ('passed', 'status', 'rows', 'row_errors', 'output', 'partial_output', 'error') if k in report}))
|
||||
return 0 if report['passed'] else 1
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Run the consolidated WhatsApp suite (same CLI as python -m whatsapp_suite)."""
|
||||
from whatsapp_suite.cli import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user