Check an iPhone sysdiagnose for traces of known mercenary spyware - entirely in your browser.
Live: https://tracescan.pages.dev/
Trace makes credible spyware forensics accessible to people who have never opened a terminal, without asking them to trust anyone with their data. Parsing and indicator matching run as a Rust/WebAssembly module inside the browser tab. There is no upload endpoint - the privacy claim is architectural, not a policy promise, and you can verify it yourself: load the page, turn on Airplane Mode, and scan.
- Capture a sysdiagnose on the iPhone (hold both volume buttons + side button ~1.5s, wait ~10 minutes, find it under Settings → Privacy & Security → Analytics & Improvements → Analytics Data).
- AirDrop the
sysdiagnose_….tar.gzfile to a computer. - Drag it into the Trace page. Analysis takes well under two minutes.
The archive is streamed chunk-by-chunk through a WASM pipeline (gzip → tar → parsers); only the few artifact files being analyzed are held in memory (hard-capped, so a crafted archive cannot exhaust it), and nothing leaves the machine. A scan that hits a safety cap, or an archive that arrives truncated, is reported as incomplete - never as clean. Four artifact types are analyzed:
| Artifact | Signal |
|---|---|
system_logs.logarchive/Extra/shutdown.log (rotated shutdown.0.log on iOS 26) |
Processes that delayed shutdown, per reboot - the iShutdown technique; Pegasus artifacts run from roleaccountd.staging |
crashes_and_spins/*.ips |
Crashing process names/paths vs. process indicators |
ps.txt, ps_thread.txt |
Processes running at capture time vs. process indicators |
system_logs.logarchive tracev3 + uuidtext |
Every process that wrote a unified-log entry during the archive window (typically days of history), via Mandiant's parser at catalog level - process inventory only, log messages are never rendered, and each file is reduced and dropped so memory stays flat |
Both shutdown.log generations are handled and were verified against a real iOS 26.5.2 capture: the classic one-line format, and the iOS 26 format with rotated filenames, indented client lines, and a trailing binary-UUID path component (stripped before matching, or no name indicator could ever hit).
Indicators are STIX2 bundles from the open threat-intel ecosystem: Amnesty International's Security Lab publications plus the MVT project's aggregated indicator collection (Citizen Lab, Kaspersky, Google Threat Intelligence, Microsoft, iVerify, and others). Eight iOS-relevant campaigns are bundled: Pegasus, Predator, KingSpawn (QuaDream), Operation Triangulation, RCS Lab, Wintego Helios, Coruna, and DarkSword. Bundled snapshots are the offline floor; a scheduled workflow PRs upstream changes into the snapshots weekly, the app opportunistically refreshes them live at load, and every scan records the SHA-256 of the exact indicator files it used.
- "No matches" is not "clean." It means no known implant left known traces in the artifacts this tool reads. The UI says so, prominently, every time.
- Two coverage gaps are stated in every report: the public-IOC time lag, and artifact coverage (domain/URL indicators live in backup artifacts - browsing history, messages - that this version does not read; roughly 2,700 of the ~2,900 loaded indicators are in that category and results never imply they were checked).
- A hit routes to Access Now's Digital Security Helpline and Amnesty's Security Lab, with evidence-preservation guidance (don't wipe; keep the file; export the JSON report for responders).
- A sufficiently compromised device can lie in its own sysdiagnose. Detection is best-effort starting at evidence collection.
- VALIDATION.md states exactly what each detection surface has been validated against, and what could not be validated because the data is not public.
./build.sh # cargo test + wasm-pack build + regenerate demo fixtures
python3 -m http.server 8973 --directory web
# native CLI harness (same engine the browser runs):
cargo run --release --example scan -- <sysdiagnose.tar.gz> web/iocs/*.stix2
# browser end-to-end tests (needs web/pkg built first):
cd e2e && npm install && npx playwright install chromium && npm test
Requires Rust with the wasm32-unknown-unknown target, wasm-pack, jq and bsdtar (fixtures; bsdtar is the macOS default tar), and Node (E2E only). The demo fixtures are synthetic sysdiagnose archives, generated deterministically; the "infected" one seeds a real Pegasus process-name indicator so the genuine match path is exercised end-to-end.
Layout:
crates/trace-core/- Rust core: streaming tar/gzip, STIX2 extraction, the three parsers, report assembly.cargo testcovers all of it natively, including proptest property tests over the hostile-input surface (tests/properties.rs).web/- the static site (framework-free JS + CSS, service worker for offline, strict CSP). This directory is the entire deployable artifact.e2e/- Playwright browser tests: demo scans, verdict rendering, report export, scan-limit handling, and offline operation.fixtures/make_fixtures.sh- synthetic demo archive generator.
CI runs fmt, clippy, tests, cargo audit, and the browser E2E suite on every push and PR. A weekly workflow PRs upstream indicator changes into the bundled snapshots (requires the "Allow GitHub Actions to create and approve pull requests" repo setting).
Deployment notes: production is Cloudflare Pages (tracescan.pages.dev), built by its git integration on every push to main with output directory web/. The dashboard build command is kept in sync with this canonical copy (dash-compatible; the toolchain version comes from rust-toolchain.toml and wasm-pack is pinned by checksum):
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none && . "$HOME/.cargo/env" && rustup toolchain install && curl -sSfL https://github.com/wasm-bindgen/wasm-pack/releases/download/v0.14.0/wasm-pack-v0.14.0-x86_64-unknown-linux-musl.tar.gz -o /tmp/wp.tgz && echo "278a8d668085821f4d1a637bd864f1713f872b0ae3a118c77562a308c0abfe8d /tmp/wp.tgz" | sha256sum -c - && tar -xzf /tmp/wp.tgz -C /tmp && /tmp/wasm-pack-v0.14.0-x86_64-unknown-linux-musl/wasm-pack build crates/trace-core --target web --release --out-dir ../../web/pkg && rm -f web/pkg/.gitignore web/pkg/package.json web/pkg/*.d.ts && sed -i "s/const CACHE = 'trace-v1'/const CACHE = 'trace-${CF_PAGES_COMMIT_SHA}'/" web/sw.js
Cloudflare enforces web/_headers automatically, so the CSP, COOP, and nosniff headers are real there; the <meta> CSP in index.html remains as defense in depth for any host that cannot send headers. Note that Cloudflare builds track main directly rather than waiting for CI, so don't push to main with CI red. The old GitHub Pages URL now serves only a redirect plus a service-worker kill switch (redirect/, published by .github/workflows/deploy.yml); leave it in place indefinitely so returning visitors with the old origin cached get moved over.
Deliberate non-goals: real-time monitoring, removal claims, Android, backup parsing. Unified-log analysis is catalog-level by design (process inventory, no message rendering): message-content indicators are domains and URLs, which belong to the backup-artifact scope. See docs/design-unified-logs.md for that trade-off.
Built on public research and data from Amnesty International's Security Lab, the Mobile Verification Toolkit project, Kaspersky's iShutdown research, and Citizen Lab's publications. Trace is a front-end to the open threat-intel ecosystem, not a replacement for it.