diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index d3926ecac6..2773ad9621 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -1,5 +1,21 @@ # Native, managed, and packaged artifacts +## Desktop installation directories + +When an installation directory is supplied and its runtime is unknown, use +`inspect_desktop_application` with its absolute path. Default MCP summary +delivery retains complete Evidence. Read `files`, `omissions`, `candidates`, `components` or +`next_probes` with `inspect_analysis_view` kind `desktop-application`, selecting +exact paths, batched file/omission IDs or families and only needed fields and pages. +Summary `probe_issues` retains counts and bounded previews. Select files with +`has_issues: true` and fields `path`, `read_status`, `issues` to retrieve exact +failures directly; preview text is display data, while `file_id` selects full facts. Preserve source +coverage, read-range identity and unknowns; directory snapshot identity is not +a digest of every file's contents. Component path markers do not establish +runtime presence. For compact CLI output, use +`inspect-desktop-application PATH --detail summary --evidence_output BUNDLE`. +See [desktop installations](https://github.com/morluto/rea/blob/main/docs/desktop-applications.md). + ## Go build metadata Use `inspect_go_binary` for an explicit local ELF, PE or thin Mach-O when the diff --git a/README.md b/README.md index 31238bae52..5f6f4c3908 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ Additional tools and host support depend on the target: | Target | What REA returns | Requirements and guide | | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| Desktop installations | Runtime candidates, supporting files, component markers and next probes | Built-in bounded directory/header inspection; [desktop applications](docs/desktop-applications.md) | | Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](docs/native-investigation.md) | | Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) | | Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d12a323179..d49b4e42ca 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -50,6 +50,7 @@ export default defineConfig({ { text: ".NET assemblies", link: "/managed-code-analysis" }, { text: "Android applications", link: "/android-analysis" }, { text: "Apple applications", link: "/apple-application-analysis" }, + { text: "Desktop installations", link: "/desktop-applications" }, { text: "Firmware", link: "/firmware-analysis" }, { text: "SQLite databases", link: "/sqlite-databases" }, { text: "Packet captures", link: "/packet-captures" }, diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid index 435460319f..abbef6f9e4 100644 --- a/docs/architecture.mermaid +++ b/docs/architecture.mermaid @@ -34,7 +34,7 @@ graph TD GHIDRA["src/ghidra/
Ghidra discovery, profile & function analysis
snapshot, Unix/Windows transport, bounded lifecycle"] IDA["src/ida/
Upstream MCP contract adaptation
live GUI binding or owned headless database"] IDA_MCP["External ida-pro-mcp
GUI proxy or database supervisor
upstream installation and engine lifecycle"] - AUX["src/artifacts/ + src/native/
artifact inventory and extraction
format readers and native capabilities"] + AUX["src/artifacts/ + src/native/
artifact inventory, bounded directory triage and extraction
format readers and native capabilities"] BROWSER["src/browser/
CDP / Playwright capture
captured scripts and native module resolution
execution/: coverage, listener and source attribution"] JS_CODEC["src/javascript/sourceMaps/
pinned upstream map codec
owned heap-limited Node process, complete point evidence"] INSPECTOR["src/inspector/
passive V8 Inspector discovery and capture
script and target locations"] diff --git a/docs/desktop-applications.md b/docs/desktop-applications.md new file mode 100644 index 0000000000..305be1495c --- /dev/null +++ b/docs/desktop-applications.md @@ -0,0 +1,142 @@ +# Desktop installation directories + +Use `inspect_desktop_application` when an installation directory is the target +and its runtime is unknown. It reads directory entries, selected headers and +bounded metadata, then reports runtime candidates, supporting files, component +path markers and concrete follow-up operations. No native provider or active +binary session is needed. It does not run the application, extract archives, +follow child symlinks, contact services or change target files. + +## MCP + +```json +{ "path": "/artifacts/MyApplication" } +``` + +Default `detail: "summary"` retains complete source Evidence in the session and +returns candidates with representative supporting paths, counts, coverage, +limitations and grouped next probes. `normalized_result.parent_evidence_id` +identifies the complete record. A server without Evidence retention refuses +summary delivery before reading the target; select `detail: "complete"` to +receive every retained fact inline. + +Read exact files in one request: + +```json +{ + "source": { + "kind": "retained-evidence", + "evidence_id": "ev_" + }, + "view": { + "kind": "desktop-application", + "collection": "files", + "paths": ["Game_Data/Managed/Assembly-CSharp.dll", "GameAssembly.dll"], + "fields": ["path", "read_status", "ranges", "facts", "issues"], + "offset": 0, + "limit": 16 + } +} +``` + +Collections are `files`, `omissions`, `candidates`, `components` and `next_probes`. +Files and omissions accept exact `paths` and batched `file_ids` / `omission_ids`. +Files also accept `has_issues: true` to select probe diagnostics directly, or +`false` to select rows without reported issues. This filter uses the retained +issue facts; it does not re-read targets or infer that uninspected files are healthy. +When both are supplied, all filters apply; an empty selection returns no rows. +Omission records retain the observed path, kind and stable `omission_id`; their +payloads are not read, and other special-entry metadata remains unavailable. +`candidates` accepts exact `families`. +Omitted selectors match all retained rows; an empty selector matches none. +Every collection supports `fields`, `offset` and `limit`; its stable row ID is +always included. Selection changes delivery, not source inspection. Pages keep +source coverage and parent Evidence links. Page exhaustion does not establish +absence in a partial source. Complete Evidence remains accessible through +`get_evidence` and Evidence bundle export. + +The summary's `probe_issues` counts files with reported issues and their retained +issue entries. It previews at most two such files, including stable `file_id`, +read status and the first issue. Path/reason previews use at most 256 UTF-16 units +each without splitting surrogate pairs; `text_truncated` marks shortened text, +and `preview_truncated` marks additional files. These are display prefixes; +full caller paths and all reasons remain in Evidence. Counts describe retained +observations, while source coverage still identifies partial inspection. + +Use `inspect_analysis_view` with `collection: "files", has_issues: true` and +`fields: ["path", "read_status", "issues"]` for exact reasons, with the usual +paging and parent links. It intersects with exact paths and batched file IDs. + +An ASAR inventory probe first selects its exact path with `open_binary`; then +call `inspect_artifact` on that active target. Directory inspection itself does +not select or replace the session target. The JavaScript application workflow +accepts its own selected path directly. + +## CLI + +The one-shot CLI returns complete Evidence by default. For compact output, +explicitly retain it in a bundle: + +```bash +rea inspect-desktop-application /artifacts/MyApplication \ + --detail summary --evidence_output /reports/application.evidence.json --json +``` + +This returns `summary` Evidence and an `evidence_bundle` publication receipt. +Existing output files are never overwritten. Summary delivery requires +`--evidence_output`, checked before analysis. `inspect-evidence-bundle` selects +saved records; an exact saved record can also feed `inspect-analysis-view`. +`--detail complete` returns source Evidence inline and can optionally write the +same bundle. Export writes only the caller-selected output file. + +## Facts and uncertainty + +| Family | Evidence used | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| .NET | A PE optional header declares a nonempty CLI directory. | +| Unity Mono | A managed assembly below `*_Data/Managed` or `Contents/Resources/Data/Managed` has an exact UnityEngine assembly reference. | +| Unity IL2CPP | An IL2CPP metadata header coexists with a conventionally named native `GameAssembly.dll`, `GameAssembly.so`, `GameAssembly.dylib` or `libil2cpp.so` image. Build correspondence remains unknown. | +| Godot | A `GDPC` header at file start or a validated executable trailer offset. | +| GameMaker | A bounded `FORM` container starts with a `GEN8` chunk. | +| Unreal | `Content/Paks` path conventions or an IoStore TOC signature; arbitrary `.pak` filenames are insufficient. | +| Electron | A `resources/app.asar` or `resources/default_app.asar` path convention; archive validity is unverified. | + +Candidates are derived or inferred from these facts and do not prove loading or +execution. BepInEx, MelonLoader, Easy Anti-Cheat and BattlEye labels identify +path conventions only; runtime presence stays unknown. No bypass is provided. + +`file_id` includes the admitted root namespace, recorded path and available filesystem identity. The caller-selected root path and admitted canonical path/root identity are both retained; `local_path` identifies each observed file for follow-up operations. The selected root may be a symlink; child symlinks are not followed. Exact +metadata integers remain decimal strings. Read ranges have offsets, byte counts +and SHA-256 digests; typed facts expose CLI RVAs, metadata/pack versions and +assembly tokens. Directory and symlink identities unavailable from traversal +remain null. + +The directory's `snapshot_sha256` commits to retained paths, omission kinds, +available metadata and range digests, composed through the admitted root, +entry and omission identities. Caller ceilings, coverage counters, diagnostic +wording and interpretation facts remain in Evidence, outside source identity. +The same observations under different generous limits share a snapshot digest; +Evidence IDs still preserve request parameters and reported coverage. Different +retained entries or read ranges change the source snapshot. +Omission records share the entry and representation budgets and can be fetched +independently through their view. +**It is not a digest of all file contents.** These are sequential stable entry +observations, not an atomic directory snapshot. Unread bytes remain unknown. + +## Resource bounds and reuse + +Defaults are 20,000 examined entries and 8 MiB of total probe reads. Callers may +select up to 100,000 entries and 64 MiB. A separate 32 MiB conservative +representation budget bounds retained paths and facts before growth. Limits +and failed probes produce partial coverage. Traversal and reads are serial. +Each reader admits one inspection at a time; overlapping requests receive a +retryable capacity refusal without cancelling the admitted caller. + +Unity reference decoding stops after a witness in each `Data/Managed` directory; +other assemblies' references remain unexamined, while their headers are still +checked. AssemblyRef facts decode names and tokens only; they do not decode or +hash unrelated key/culture/hash payloads or establish runtime binding. Pack contents, encrypted data, metadata/address correspondence and +section-embedded PCKs without a trailer are separate capabilities. + +Filesystem results are not cached across calls. Retained views reuse validated +immutable metadata without rescanning files or retaining another inventory. diff --git a/docs/index.md b/docs/index.md index a9c444a6c7..27985ac645 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,6 +49,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). | Task | Guides | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | | Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | | Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | | Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 38e10c87ab..36a6190e92 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -146,6 +146,7 @@ npx rea-agents@latest setup | شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) | +| تثبيتات سطح المكتب | عائلات وقت التشغيل، والملفات الداعمة، وعلامات المكونات، والخطوات التالية | فحص محدود للملفات والترويسات;[تطبيقات سطح المكتب](../desktop-applications.md) | | JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](../javascript-application-workflows.md) | | المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](../browser-observation.md) | | تسجيلات الشبكة المحفوظة | الطلبات، والاستجابات، ومحتويات الحمولة المتاحة، ومواضعها في المصدر | HAR؛ وmitmdump على Linux لتسجيلات mitmproxy بصيغتها الأصلية؛ [دليل تسجيلات الشبكة](../web-network-captures.md) | diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index c619a32b22..2c7018c9b3 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -146,6 +146,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz | EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) | +| Desktop-Installationen | Laufzeitkandidaten, Quelldateien, Komponenten und nächste Schritte | Begrenzte Verzeichnis- und Headerprüfung;[Desktop-Anwendungen](../desktop-applications.md) | | JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](../javascript-application-workflows.md) | | Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](../browser-observation.md) | | Gespeicherte Netzwerkaufzeichnungen | Anfragen, Antworten, zugängliche Nutzdaten und Quellpositionen | HAR; mitmdump unter Linux für Aufzeichnungen im nativen mitmproxy-Format; [Leitfaden zu Netzwerkaufzeichnungen](../web-network-captures.md) | diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index f0410600a1..8b52de458f 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -153,6 +153,7 @@ npx rea-agents@latest setup | Bytecode EVM | Selectors επιλογής συνάρτησης, μετατοπίσεις σε bytes, εκτιμώμενα ορίσματα και δυνατότητα μεταβολής της κατάστασης | Τοπική είσοδος σε ακατέργαστα bytes ή δεκαεξαδική μορφή σε Linux x64· [οδηγός EVM χωρίς εκτέλεση](../evm-bytecode.md) | | Βάσεις δεδομένων SQLite | Πίνακες, στήλες, ευρετήρια, όψεις, triggers και επιλεγμένες γραμμές κανονικών πινάκων | Τοπικό στιγμιότυπο βάσης δεδομένων/WAL· Node.js 24.x >=24.15 ή 26+ με εγγενή όρια SQLite | | Καταγεγραμμένες καταρρεύσεις στο Linux | Ακατέργαστες εγγραφές notes, καταχωρητές/σήματα κάθε καταγεγραμμένου νήματος και προαιρετικές πιθανές αντιστοιχίσεις μνήμης | pwntools που παρέχει ο χρήστης σε Linux x64· προαιρετικά GDB/pwndbg· [καταγεγραμμένες καταρρεύσεις](../recorded-crashes.md) | +| Εγκαταστάσεις επιφάνειας εργασίας | Υποψήφια περιβάλλοντα εκτέλεσης, αρχεία τεκμηρίωσης, στοιχεία και επόμενα βήματα | Περιορισμένη επιθεώρηση καταλόγων και κεφαλίδων;[Εφαρμογές επιφάνειας εργασίας](../desktop-applications.md) | | JavaScript / Electron | Modules, εισαγωγές, source maps, διαδρομές, IPC και σχέσεις με πρόσθετα κώδικα μηχανής | Node.js και npm· [ανάλυση εφαρμογών](../javascript-application-workflows.md) | | Ιστότοποι | Δομή σελίδων, scripts, παρατηρήσεις δικτύου και ζητούμενα στιγμιότυπα οθόνης | Πρόγραμμα περιήγησης της οικογένειας Chrome· [ανάλυση μέσω προγράμματος περιήγησης](../browser-observation.md) | | Αποθηκευμένες καταγραφές δικτύου | Αιτήματα, αποκρίσεις, διαθέσιμα δεδομένα φορτίου και θέσεις στην πηγή | HAR ή SAZ του Fiddler Everywhere· mitmdump σε Linux για την εγγενή μορφή mitmproxy· [οδηγός καταγραφών](../web-network-captures.md) | diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index 5a718f8976..9d83e2dc18 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -146,6 +146,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h | Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) | +| Instalaciones de escritorio | Posibles entornos de ejecución, archivos de evidencia, componentes y siguientes pasos | Inspección acotada de directorios y cabeceras;[Aplicaciones de escritorio](../desktop-applications.md) | | JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](../javascript-application-workflows.md) | | Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](../browser-observation.md) | | Capturas de red guardadas | Solicitudes, respuestas, contenido accesible de las cargas útiles y ubicaciones de origen | HAR; mitmdump en Linux para capturas en formato nativo de mitmproxy; [guía de capturas](../web-network-captures.md) | diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index d858a7190f..a712031c17 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -138,6 +138,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np | بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](../recorded-crashes.md) | +| نصب‌های دسکتاپ | نامزدهای زمان اجرا، فایل‌های شاهد، نشانه‌های مؤلفه‌ها و مراحل بعدی | بررسی محدود پوشه‌ها و سرآیندها;[برنامه‌های دسکتاپ](../desktop-applications.md) | | JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](../javascript-application-workflows.md) | | وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](../browser-observation.md) | | داده‌های ضبط‌شدهٔ شبکه | درخواست‌ها، پاسخ‌ها، payloadهای آشکارشده و مکان آن‌ها در منبع | HAR؛ ابزار mitmdump در لینوکس برای ضبط‌های بومی mitmproxy؛ [راهنمای ضبط شبکه](../web-network-captures.md) | diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index 82e9634597..248eeedfdc 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -150,6 +150,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les | Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crashs Linux enregistrés | Notes brutes, registres et signaux de chaque thread enregistré, et correspondances de mappings facultatives | pwntools fourni par l'utilisateur sous Linux x64 ; GDB/pwndbg en option ; [crashs enregistrés](../recorded-crashes.md) | +| Installations de bureau | Environnements candidats, fichiers justificatifs, composants et étapes suivantes | Inspection bornée des répertoires et en-têtes;[Applications de bureau](../desktop-applications.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](../javascript-application-workflows.md) | | Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](../browser-observation.md) | | Captures réseau enregistrées | Requêtes, réponses, payloads exposés et emplacements dans le code source | HAR ; mitmdump sous Linux pour les captures mitmproxy natives ; [guide des captures](../web-network-captures.md) | diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index 0f4a8cbc8d..31a9be45ab 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -153,6 +153,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](../recorded-crashes.md) | +| Instalasi desktop | Kandidat runtime, berkas pendukung, penanda komponen, dan langkah berikutnya | Pemeriksaan direktori dan header terbatas;[Aplikasi desktop](../desktop-applications.md) | | JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](../javascript-application-workflows.md) | | Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](../browser-observation.md) | | Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](../web-network-captures.md) | diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index fb34457a7c..2a926da924 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -150,6 +150,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument | Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crash Linux registrati | Note grezze, registri/segnai di ogni thread registrato e candidati mapping opzionali | pwntools fornito dal chiamante; GDB/pwndbg opzionale; [crash registrati](../recorded-crashes.md) | +| Installazioni desktop | Runtime candidati, file di supporto, componenti e passi successivi | Ispezione limitata di directory e intestazioni;[Applicazioni desktop](../desktop-applications.md) | | JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](../javascript-application-workflows.md) | | Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](../browser-observation.md) | | Acquisizioni di rete salvate | Richieste, risposte, payload esposti e posizioni sorgente | HAR; mitmdump su Linux per acquisizioni mitmproxy native; [guida alle acquisizioni](../web-network-captures.md) | diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index 4cf664fc5b..b61ea1efa6 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -146,6 +146,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm | EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) | +| デスクトップのインストール先 | ランタイム候補、根拠ファイル、コンポーネントの目印、次の調査手順 | 上限付きのディレクトリ・ヘッダー検査;[デスクトップアプリ](../desktop-applications.md) | | JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](../javascript-application-workflows.md) | | ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](../browser-observation.md) | | 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](../web-network-captures.md) | diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index 98124587c5..7d29fc55f6 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -143,31 +143,32 @@ npx rea-agents@latest setup REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니다. 추가 도구와 호스트 지원은 대상에 따라 다릅니다. -| 대상 | REA가 반환하는 내용 | 요구 사항 및 가이드 | -| --------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](../native-investigation.md) | -| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) | -| Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 | -| Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) | -| WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` | -| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) | -| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) | -| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) | -| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; Linux: procps 호환 `ps`; [애플리케이션 분석](../javascript-application-workflows.md) | -| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](../browser-observation.md) | -| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR 또는 Fiddler Everywhere SAZ; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) | -| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 별도 도구 불필요(정적 검사만); [관리 코드 가이드](../managed-code-analysis.md) | -| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](../android-analysis.md) | -| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](../adb-device-analysis.md) | -| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](../jeb-analysis.md) | -| Android 리소스 | 디코딩된 매니페스트, 문자열 테이블, 로캘, 버전 정보 | 호출자 제공 Apktool 런처; [리소스 가이드](../apktool-resource-analysis.md) | -| Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](../flutter-analysis.md) | -| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](../firmware-analysis.md) | -| 기록된 패킷 | 선택 필드, 반복 값, 필터 및 디코딩 규칙 | Linux: TShark 4.x + prlimit; [캡처 가이드](../packet-captures.md) | -| 패킷 요약 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos(Linux에서 검증됨); [pcap 가이드](../pcap-analysis.md) | -| 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](../signal-recordings.md) | -| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](../javascript-artifact-reconstruction.md), [Apple 애플리케이션](../apple-application-analysis.md) | -| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](../process-capture.md) | +| 대상 | REA가 반환하는 내용 | 요구 사항 및 가이드 | +| ---------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](../native-investigation.md) | +| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) | +| Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 | +| Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) | +| WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` | +| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) | +| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) | +| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) | +| 데스크톱 설치 디렉터리 | 런타임 후보, 근거 파일, 구성 요소 표시와 다음 조사 단계 | 범위가 제한된 디렉터리 및 헤더 검사;[데스크톱 애플리케이션](../desktop-applications.md) | +| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; Linux: procps 호환 `ps`; [애플리케이션 분석](../javascript-application-workflows.md) | +| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](../browser-observation.md) | +| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR 또는 Fiddler Everywhere SAZ; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) | +| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 별도 도구 불필요(정적 검사만); [관리 코드 가이드](../managed-code-analysis.md) | +| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](../android-analysis.md) | +| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](../adb-device-analysis.md) | +| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](../jeb-analysis.md) | +| Android 리소스 | 디코딩된 매니페스트, 문자열 테이블, 로캘, 버전 정보 | 호출자 제공 Apktool 런처; [리소스 가이드](../apktool-resource-analysis.md) | +| Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](../flutter-analysis.md) | +| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](../firmware-analysis.md) | +| 기록된 패킷 | 선택 필드, 반복 값, 필터 및 디코딩 규칙 | Linux: TShark 4.x + prlimit; [캡처 가이드](../packet-captures.md) | +| 패킷 요약 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos(Linux에서 검증됨); [pcap 가이드](../pcap-analysis.md) | +| 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](../signal-recordings.md) | +| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](../javascript-artifact-reconstruction.md), [Apple 애플리케이션](../apple-application-analysis.md) | +| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](../process-capture.md) | 정적 JavaScript 및 .NET 검사는 제공된 파일을 읽으며 애플리케이션을 실행하지 않습니다. 런타임 캡처는 사용자의 권한으로 선택한 대상을 실행하거나 대상과 상호작용합니다. 각 런타임 가이드에서 그 영향을 설명합니다. diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index bd29b36f85..621242818f 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -155,6 +155,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.19), 24.x (>=24.11), അല്ലെ | EVM bytecode | Dispatch selectors, byte offsets, അനുമാനിച്ച arguments, mutability | Linux x64-ൽ local raw/hex carrier; [offline EVM guide](../evm-bytecode.md) | | SQLite databases | Tables, columns, indexes, views, triggers, തിരഞ്ഞെടുത്ത സാധാരണ table rows | Local database/WAL snapshot; native SQLite പരിമിതികളോടെ Node.js 24.x >=24.15 അല്ലെങ്കിൽ 26+ | | Recorded Linux crashes | Raw notes, രേഖപ്പെടുത്തിയ ഓരോ thread-ന്റെയും registers/signals, ഐച്ഛിക mapping candidates | Linux x64-ൽ caller നൽകുന്ന pwntools; ഐച്ഛിക GDB/pwndbg; [recorded crashes](../recorded-crashes.md) | +| ഡെസ്ക്ടോപ്പ് ഇൻസ്റ്റലേഷനുകൾ | റൺടൈം സാധ്യതകൾ, തെളിവ് ഫയലുകൾ, ഘടക സൂചനകൾ, തുടർ നടപടികൾ | പരിധിയുള്ള ഡയറക്ടറി, ഹെഡർ പരിശോധന;[ഡെസ്ക്ടോപ്പ് ആപ്ലിക്കേഷനുകൾ](../desktop-applications.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC, native add-on ബന്ധങ്ങൾ | Node.js, npm; Linux: procps-compatible `ps`; [application analysis](../javascript-application-workflows.md) | | Websites | Page structure, scripts, network observations, ആവശ്യപ്പെട്ട screenshots | Chrome-family browser; [browser analysis](../browser-observation.md) | | Saved network captures | Requests, responses, പുറത്തുകാണുന്ന payload-ുകൾ, source locations | HAR അല്ലെങ്കിൽ Fiddler Everywhere SAZ; native mitmproxy-നായി Linux-ൽ mitmdump; [capture guide](../web-network-captures.md) | diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index ac80ef5f62..70ab65f4de 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -146,6 +146,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na | Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) | +| Instalacje desktopowe | Kandydaci środowisk uruchomieniowych, pliki źródłowe dowodów, komponenty i dalsze kroki | Ograniczona inspekcja katalogów i nagłówków;[Aplikacje desktopowe](../desktop-applications.md) | | JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](../javascript-application-workflows.md) | | Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](../browser-observation.md) | | Zapisane przechwycenia ruchu sieciowego | Żądania, odpowiedzi, dostępne dane przesyłane i lokalizacje źródłowe | HAR; mitmdump w systemie Linux dla natywnych przechwyceń mitmproxy; [przewodnik przechwytywania](../web-network-captures.md) | diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index 671b8df004..abc6cbeb59 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -146,6 +146,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer | Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) | +| Instalações desktop | Ambientes candidatos, arquivos de evidência, componentes e próximos passos | Inspeção limitada de diretórios e cabeçalhos;[Aplicações desktop](../desktop-applications.md) | | JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](../javascript-application-workflows.md) | | Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](../browser-observation.md) | | Capturas de rede salvas | Requisições, respostas, payloads acessíveis e localizações de origem | HAR; mitmdump no Linux para capturas no formato nativo do mitmproxy; [guia de capturas](../web-network-captures.md) | diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index 0eee797a67..7cc68cda26 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -139,28 +139,29 @@ npx rea-agents@latest setup REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а также npm. Дополнительные инструменты и поддерживаемые системы зависят от цели: -| Цель | Что возвращает REA | Требования и руководство | -| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](../native-investigation.md) | -| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) | -| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) | -| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | -| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) | -| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](../javascript-application-workflows.md) | -| Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](../browser-observation.md) | -| Сохранённые сетевые захваты | Запросы, ответы, доступные полезные данные и позиции в источнике | HAR; mitmdump в Linux для захватов в собственном формате mitmproxy; [руководство по сетевым захватам](../web-network-captures.md) | -| Сборки .NET | Метаданные, инструкции CIL, объявленные нативные зависимости и сравнения сборок | Статическое исследование; [руководство по управляемому коду](../managed-code-analysis.md) | -| Android APK | Объявления манифеста, классы, декомпилированные методы и ссылки | JADX без графического интерфейса и полный JDK в Linux/macOS/Windows x64; [руководство по Android](../android-analysis.md) | -| Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](../adb-device-analysis.md) | -| Проекты JEB | Единицы проекта от движка, дайджесты артефактов и псевдокод типов/методов | Запущенный вызывающей стороной клиент JEB с интерфейсом MCP, выбирается через REA_JEB_MCP_URL; [руководство по JEB](../jeb-analysis.md) | -| Ресурсы Android | Декодированный манифест, таблицы строк, языки и сведения о версии | Запуск Apktool, предоставленный вызывающей стороной; [руководство по ресурсам](../apktool-resource-analysis.md) | -| Приложения Flutter | Хеш снапшота Dart, дайджесты библиотек по ABI и происхождение сборки движка | Не требуется (статический разбор APK); [руководство по Flutter](../flutter-analysis.md) | -| Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](../firmware-analysis.md) | -| Записанные пакеты | Выбранные поля, повторяющиеся значения, фильтры и правила декодирования | Linux: TShark 4.x + prlimit; [руководство по захватам](../packet-captures.md) | -| Захват пакетов | Конечные точки, DNS, TLS SNI, распределение протоколов и HTTP-запросы | Предоставленные вызывающей стороной tshark и capinfos; [руководство по pcap](../pcap-analysis.md) | -| Записанные логические сигналы | Аннотации декодера, диапазоны отсчётов и диагностика | Linux: sigrok-cli 0.7.2 + prlimit; [руководство по сигналам](../signal-recordings.md) | -| Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](../javascript-artifact-reconstruction.md), [приложения Apple](../apple-application-analysis.md) | -| Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](../process-capture.md) | +| Цель | Что возвращает REA | Требования и руководство | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](../native-investigation.md) | +| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) | +| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | +| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) | +| Установленные настольные приложения | Возможные среды выполнения, файлы с доказательствами, компоненты и дальнейшие шаги | Ограниченная проверка каталогов и заголовков;[Настольные приложения](../desktop-applications.md) | +| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](../javascript-application-workflows.md) | +| Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](../browser-observation.md) | +| Сохранённые сетевые захваты | Запросы, ответы, доступные полезные данные и позиции в источнике | HAR; mitmdump в Linux для захватов в собственном формате mitmproxy; [руководство по сетевым захватам](../web-network-captures.md) | +| Сборки .NET | Метаданные, инструкции CIL, объявленные нативные зависимости и сравнения сборок | Статическое исследование; [руководство по управляемому коду](../managed-code-analysis.md) | +| Android APK | Объявления манифеста, классы, декомпилированные методы и ссылки | JADX без графического интерфейса и полный JDK в Linux/macOS/Windows x64; [руководство по Android](../android-analysis.md) | +| Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](../adb-device-analysis.md) | +| Проекты JEB | Единицы проекта от движка, дайджесты артефактов и псевдокод типов/методов | Запущенный вызывающей стороной клиент JEB с интерфейсом MCP, выбирается через REA_JEB_MCP_URL; [руководство по JEB](../jeb-analysis.md) | +| Ресурсы Android | Декодированный манифест, таблицы строк, языки и сведения о версии | Запуск Apktool, предоставленный вызывающей стороной; [руководство по ресурсам](../apktool-resource-analysis.md) | +| Приложения Flutter | Хеш снапшота Dart, дайджесты библиотек по ABI и происхождение сборки движка | Не требуется (статический разбор APK); [руководство по Flutter](../flutter-analysis.md) | +| Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](../firmware-analysis.md) | +| Записанные пакеты | Выбранные поля, повторяющиеся значения, фильтры и правила декодирования | Linux: TShark 4.x + prlimit; [руководство по захватам](../packet-captures.md) | +| Захват пакетов | Конечные точки, DNS, TLS SNI, распределение протоколов и HTTP-запросы | Предоставленные вызывающей стороной tshark и capinfos; [руководство по pcap](../pcap-analysis.md) | +| Записанные логические сигналы | Аннотации декодера, диапазоны отсчётов и диагностика | Linux: sigrok-cli 0.7.2 + prlimit; [руководство по сигналам](../signal-recordings.md) | +| Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](../javascript-artifact-reconstruction.md), [приложения Apple](../apple-application-analysis.md) | +| Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](../process-capture.md) | Статическое исследование JavaScript и .NET читает предоставленные файлы, не запуская приложение. Захват во время выполнения запускает выбранную цель или взаимодействует с ней с правами вашего пользователя; соответствующие руководства описывают последствия. diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 21c51d7e5e..e7d6cba896 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -146,6 +146,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26 | ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) | +| ไดเรกทอรีติดตั้งแอปเดสก์ท็อป | รันไทม์ที่เป็นไปได้ ไฟล์หลักฐาน ตัวบ่งชี้ส่วนประกอบ และขั้นตอนถัดไป | ตรวจสอบไดเรกทอรีและส่วนหัวโดยจำกัดขอบเขต;[แอปเดสก์ท็อป](../desktop-applications.md) | | JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](../javascript-application-workflows.md) | | เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](../browser-observation.md) | | ข้อมูลเครือข่ายที่บันทึกไว้ | คำขอ คำตอบ เพย์โหลดที่ปรากฏ และตำแหน่งต้นทาง | HAR; mitmdump บน Linux สำหรับข้อมูลที่บันทึกในรูปแบบเนทีฟของ mitmproxy; [คู่มือการบันทึกข้อมูล](../web-network-captures.md) | diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 7cb145662e..26a5da1168 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -151,6 +151,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç | EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) | | SQLite veritabanları | Tablolar, sütunlar, index'ler, view'lar, trigger'lar ve seçilen sıradan tablo satırları | Yerel database/WAL snapshot'ı; native SQLite sınırlarıyla Node.js 24.x >=24.15 veya 26+ | | Kaydedilmiş Linux çökmeleri | Ham note kayıtları, kaydedilen her iş parçacığının yazmaçları/sinyalleri ve isteğe bağlı eşleme adayları | Linux x64 üzerinde çağıranın sağladığı pwntools; isteğe bağlı GDB/pwndbg; [kaydedilmiş çökmeler](../recorded-crashes.md) | +| Masaüstü kurulumları | Çalışma zamanı adayları, kanıt dosyaları, bileşen işaretleri ve sonraki adımlar | Sınırlı dizin ve başlık incelemesi;[Masaüstü uygulamaları](../desktop-applications.md) | | JavaScript / Electron | Modüller, içe aktarmalar, kaynak haritaları, rotalar, IPC ve yerel eklenti ilişkileri | Node.js ve npm; [uygulama analizi](../javascript-application-workflows.md) | | Web siteleri | Sayfa yapısı, betikler, ağ gözlemleri ve istenen ekran görüntüleri | Chrome ailesinden bir tarayıcı; [tarayıcı analizi](../browser-observation.md) | | Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR veya Fiddler Everywhere SAZ; Linux'ta native mitmproxy için mitmdump; [ağ yakalama rehberi](../web-network-captures.md) | diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 6a845a1a58..639e7355d5 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -146,6 +146,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та | Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) | +| Встановлені настільні програми | Можливі середовища виконання, файли доказів, компоненти та наступні кроки | Обмежена перевірка каталогів і заголовків;[Настільні програми](../desktop-applications.md) | | JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](../javascript-application-workflows.md) | | Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](../browser-observation.md) | | Збережені мережеві захоплення | Запити, відповіді, доступні корисні дані та місця в джерелі | HAR; mitmdump у Linux для захоплень у власному форматі mitmproxy; [посібник мережевих захоплень](../web-network-captures.md) | diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index bac108e039..e370dcb1da 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -139,28 +139,29 @@ Tác nhân gọi REA qua MCP để kiểm tra mục tiêu và lần theo mã li REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với npm. Công cụ bổ sung và nền tảng chạy REA được hỗ trợ tùy thuộc vào mục tiêu: -| Mục tiêu | REA trả về gì | Yêu cầu và hướng dẫn | -| ----------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| Tệp nhị phân mã máy | Mã giả, hợp ngữ, chuỗi, ký hiệu, lời gọi và tham chiếu | Hopper, Ghidra hoặc IDA; [phân tích mã máy](../native-investigation.md) | -| Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) | -| Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) | -| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | -| Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) | -| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](../javascript-application-workflows.md) | -| Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](../browser-observation.md) | -| Bản ghi lưu lượng mạng đã lưu | Yêu cầu, phản hồi, payload có thể truy cập và vị trí nguồn | HAR; mitmdump trên Linux cho bản ghi định dạng gốc của mitmproxy; [hướng dẫn bản ghi mạng](../web-network-captures.md) | -| Bắt gói tin | Điểm cuối, DNS, TLS SNI, phân bố giao thức và yêu cầu HTTP | tshark và capinfos do nơi gọi cung cấp; [hướng dẫn pcap](../pcap-analysis.md) | -| Assembly .NET | Siêu dữ liệu, lệnh CIL, các phụ thuộc mã máy được khai báo và so sánh bản dựng | Kiểm tra tĩnh; [hướng dẫn mã được quản lý](../managed-code-analysis.md) | -| APK Android | Khai báo manifest, lớp, phương thức được dịch ngược và tham chiếu | JADX không giao diện đồ họa và JDK đầy đủ trên Linux/macOS/Windows x64; [hướng dẫn Android](../android-analysis.md) | -| Thiết bị Android | Thiết bị, gói, tiến trình, nhật ký, dump, ảnh chụp màn hình và truyền APK/tệp | adb do nơi gọi cung cấp (trình giả lập hoặc thiết bị); [hướng dẫn ADB](../adb-device-analysis.md) | -| Dự án JEB | Đơn vị dự án từ engine, hash của artifact và mã giả của kiểu/phương thức | Một ứng dụng JEB do nơi gọi khởi động, phục vụ MCP, chọn bằng REA_JEB_MCP_URL; [hướng dẫn JEB](../jeb-analysis.md) | -| Tài nguyên Android | Manifest đã giải mã, bảng chuỗi, ngôn ngữ và thông tin phiên bản | Trình khởi chạy Apktool do nơi gọi cung cấp; [hướng dẫn tài nguyên](../apktool-resource-analysis.md) | -| Ứng dụng Flutter | Băm snapshot Dart, hash thư viện theo ABI và nguồn gốc build engine | Không cần (phân tích APK tĩnh); [hướng dẫn Flutter](../flutter-analysis.md) | -| Phần sụn | Các vùng, kết quả trích xuất và dữ liệu chuyển sang phân tích mã máy | Binwalk / Unblob trên Linux; [hướng dẫn phần sụn](../firmware-analysis.md) | -| Gói tin đã ghi | Trường được chọn, giá trị lặp, bộ lọc và quy tắc giải mã | Linux: TShark 4.x + prlimit; [hướng dẫn bản ghi gói tin](../packet-captures.md) | -| Tín hiệu logic đã ghi | Chú thích bộ giải mã, phạm vi mẫu và chẩn đoán | Linux: sigrok-cli 0.7.2 + prlimit; [hướng dẫn tín hiệu](../signal-recordings.md) | -| Gói và tài nguyên | Danh sách tệp, giá trị băm, plist, cấu trúc bundle Apple và tài nguyên được trích xuất | [Hướng dẫn hiện vật phần mềm và JavaScript](../javascript-artifact-reconstruction.md), [ứng dụng Apple](../apple-application-analysis.md) | -| Hành vi tiến trình | Đầu ra terminal, tương tác, quan sát khi thoát và hệ thống tệp, cùng so sánh các lần chạy | Linux/macOS có PTY gốc; [ghi lại tiến trình](../process-capture.md) | +| Mục tiêu | REA trả về gì | Yêu cầu và hướng dẫn | +| --------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Tệp nhị phân mã máy | Mã giả, hợp ngữ, chuỗi, ký hiệu, lời gọi và tham chiếu | Hopper, Ghidra hoặc IDA; [phân tích mã máy](../native-investigation.md) | +| Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) | +| Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | +| Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) | +| Thư mục cài đặt ứng dụng máy tính | Các runtime có khả năng được dùng, tệp bằng chứng, dấu hiệu thành phần và bước tiếp theo | Kiểm tra thư mục và phần đầu tệp có giới hạn;[Ứng dụng máy tính](../desktop-applications.md) | +| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](../javascript-application-workflows.md) | +| Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](../browser-observation.md) | +| Bản ghi lưu lượng mạng đã lưu | Yêu cầu, phản hồi, payload có thể truy cập và vị trí nguồn | HAR; mitmdump trên Linux cho bản ghi định dạng gốc của mitmproxy; [hướng dẫn bản ghi mạng](../web-network-captures.md) | +| Bắt gói tin | Điểm cuối, DNS, TLS SNI, phân bố giao thức và yêu cầu HTTP | tshark và capinfos do nơi gọi cung cấp; [hướng dẫn pcap](../pcap-analysis.md) | +| Assembly .NET | Siêu dữ liệu, lệnh CIL, các phụ thuộc mã máy được khai báo và so sánh bản dựng | Kiểm tra tĩnh; [hướng dẫn mã được quản lý](../managed-code-analysis.md) | +| APK Android | Khai báo manifest, lớp, phương thức được dịch ngược và tham chiếu | JADX không giao diện đồ họa và JDK đầy đủ trên Linux/macOS/Windows x64; [hướng dẫn Android](../android-analysis.md) | +| Thiết bị Android | Thiết bị, gói, tiến trình, nhật ký, dump, ảnh chụp màn hình và truyền APK/tệp | adb do nơi gọi cung cấp (trình giả lập hoặc thiết bị); [hướng dẫn ADB](../adb-device-analysis.md) | +| Dự án JEB | Đơn vị dự án từ engine, hash của artifact và mã giả của kiểu/phương thức | Một ứng dụng JEB do nơi gọi khởi động, phục vụ MCP, chọn bằng REA_JEB_MCP_URL; [hướng dẫn JEB](../jeb-analysis.md) | +| Tài nguyên Android | Manifest đã giải mã, bảng chuỗi, ngôn ngữ và thông tin phiên bản | Trình khởi chạy Apktool do nơi gọi cung cấp; [hướng dẫn tài nguyên](../apktool-resource-analysis.md) | +| Ứng dụng Flutter | Băm snapshot Dart, hash thư viện theo ABI và nguồn gốc build engine | Không cần (phân tích APK tĩnh); [hướng dẫn Flutter](../flutter-analysis.md) | +| Phần sụn | Các vùng, kết quả trích xuất và dữ liệu chuyển sang phân tích mã máy | Binwalk / Unblob trên Linux; [hướng dẫn phần sụn](../firmware-analysis.md) | +| Gói tin đã ghi | Trường được chọn, giá trị lặp, bộ lọc và quy tắc giải mã | Linux: TShark 4.x + prlimit; [hướng dẫn bản ghi gói tin](../packet-captures.md) | +| Tín hiệu logic đã ghi | Chú thích bộ giải mã, phạm vi mẫu và chẩn đoán | Linux: sigrok-cli 0.7.2 + prlimit; [hướng dẫn tín hiệu](../signal-recordings.md) | +| Gói và tài nguyên | Danh sách tệp, giá trị băm, plist, cấu trúc bundle Apple và tài nguyên được trích xuất | [Hướng dẫn hiện vật phần mềm và JavaScript](../javascript-artifact-reconstruction.md), [ứng dụng Apple](../apple-application-analysis.md) | +| Hành vi tiến trình | Đầu ra terminal, tương tác, quan sát khi thoát và hệ thống tệp, cùng so sánh các lần chạy | Linux/macOS có PTY gốc; [ghi lại tiến trình](../process-capture.md) | Kiểm tra tĩnh JavaScript và .NET đọc các tệp được cung cấp mà không chạy ứng dụng. Ghi lại khi chạy sẽ khởi chạy hoặc tương tác với mục tiêu đã chọn bằng quyền người dùng của bạn; từng hướng dẫn mô tả tác động của nó. diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index 1585e4348e..ba17bf7ef8 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -150,6 +150,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) | +| 桌面安裝目錄 | 執行階段候選、支持檔案、元件標記與後續操作 | 有界的目錄與檔頭檢查;[桌面應用](../desktop-applications.md) | | JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](../javascript-application-workflows.md) | | 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](../browser-observation.md) | | 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](../web-network-captures.md) | diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index a54982b5df..5721c0c6e5 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -150,6 +150,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) | +| 桌面安装目录 | 运行时候选、支持文件、组件标记和后续操作 | 有界的目录与文件头检查;[桌面应用](../desktop-applications.md) | | JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](../javascript-application-workflows.md) | | 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](../browser-observation.md) | | 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](../web-network-captures.md) | diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 4e8e10f1b9..db6208621e 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -31,6 +31,7 @@ export const toolFamilyCatalog = (sources) => { surface: "artifact-provider", contracts: [ ...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, + ...sources.desktopContracts.DESKTOP_APPLICATION_TOOL_CONTRACTS, ...sources.evmContracts.EVM_TOOL_CONTRACTS, ...sources.goContracts.GO_TOOL_CONTRACTS, ...sources.rustContracts.RUST_TOOL_CONTRACTS, @@ -279,7 +280,10 @@ export const providerCatalog = (sources) => { }, { identity: sources.artifactProviders.ARTIFACT_GRAPH_PROVIDER, - contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, + contracts: [ + ...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, + ...sources.desktopContracts.DESKTOP_APPLICATION_TOOL_CONTRACTS, + ], }, { identity: sources.artifactProviders.MANAGED_STATIC_PROVIDER, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index afa8bbcb55..a96c8c88ad 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -72,6 +72,7 @@ const SOURCE_PATHS = { rustContracts: "dist/contracts/rust/rustToolContracts.js", rustProvider: "dist/rust/RustBinaryProvider.js", artifactContracts: "dist/contracts/artifactToolContracts.js", + desktopContracts: "dist/contracts/desktopApplicationToolContracts.js", managedContracts: "dist/contracts/managed/managedToolContracts.js", firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js", firmwareProvider: "dist/firmware/FirmwareRelease.js", diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 5fa568254e..9bcd580df9 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -409,6 +409,7 @@ const workflowAvailabilityFor = ({ policy, }: AvailabilityContext): Availability | null => { if ( + name === "inspect_desktop_application" || name === "inspect_go_binary" || name === "inspect_rust_binary" || name === "inspect_electron_fuses" diff --git a/src/application/artifacts/DesktopApplicationPort.ts b/src/application/artifacts/DesktopApplicationPort.ts new file mode 100644 index 0000000000..acd2882e77 --- /dev/null +++ b/src/application/artifacts/DesktopApplicationPort.ts @@ -0,0 +1,16 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { + DesktopApplication, + InspectDesktopApplicationInput, +} from "../../domain/desktopApplication.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Result } from "../../domain/result.js"; + +/** Directory facts from bounded filesystem reads, without a native-analysis session. */ +export interface DesktopApplicationPort { + inspect( + input: InspectDesktopApplicationInput, + options?: ExecutionOptions, + ): Promise>; + close(): Promise; +} diff --git a/src/application/artifacts/DesktopApplicationService.ts b/src/application/artifacts/DesktopApplicationService.ts new file mode 100644 index 0000000000..851e00cd5f --- /dev/null +++ b/src/application/artifacts/DesktopApplicationService.ts @@ -0,0 +1,73 @@ +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { DesktopApplicationPort } from "./DesktopApplicationPort.js"; +import { ARTIFACT_GRAPH_PROVIDER } from "../InvestigationProviders.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { + AnalysisInputError, + AnalysisOutputError, + AnalysisCancelledError, +} from "../../domain/analysisErrorCore.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { createDesktopApplicationEvidence } from "../../domain/desktopApplicationEvidence.js"; +import { + desktopApplicationSchema, + inspectDesktopApplicationInputSchema, +} from "../../domain/desktopApplication.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { err, type Result } from "../../domain/result.js"; + +/** One input, interpretation and Evidence boundary shared by CLI, MCP and embedders. */ +export class DesktopApplicationService { + constructor(readonly reader: DesktopApplicationPort) {} + close(): Promise { + return this.reader.close(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + const operation = "inspect_desktop_application"; + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError(operation, { cause: options.signal.reason }), + ); + const parsed = inspectDesktopApplicationInputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues(operation, parsed.error.issues, rawInput, { + cause: parsed.error, + }), + ); + if (!isAbsolute(parsed.data.path)) + return err( + new AnalysisInputError(operation, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute directory path on this host.", + }, + ]), + ); + const result = await this.reader.inspect(parsed.data, options); + if (!result.ok) return result; + const report = desktopApplicationSchema.safeParse(result.value); + if (!report.success || report.data.root.path !== parsed.data.path) + return err( + new AnalysisOutputError( + operation, + "Directory inspection changed the selected root or returned malformed facts.", + ), + ); + return completeProviderEvidence( + createDesktopApplicationEvidence( + parsed.data, + report.data, + ARTIFACT_GRAPH_PROVIDER, + ), + options?.signal, + ); + } +} diff --git a/src/artifacts/ArtifactOperationFailure.ts b/src/artifacts/ArtifactOperationFailure.ts new file mode 100644 index 0000000000..f6630ba9a0 --- /dev/null +++ b/src/artifacts/ArtifactOperationFailure.ts @@ -0,0 +1,48 @@ +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisInputError, + AnalysisCancelledError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import { ArtifactOperationError } from "../domain/artifactOperationError.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import { + artifactFileCancellationCause, + bindArtifactFileCleanup, +} from "./ArtifactFileOperation.js"; + +/** Translate one artifact boundary failure while retaining cleanup evidence. */ +export const translateArtifactFailure = ( + operation: ArtifactOperationError["operation"], + cause: unknown, +): AnalysisError => { + if (cause instanceof ArtifactReaderFailure) { + const error = new ArtifactOperationError( + operation, + cause.reason, + cause.details, + cause.message, + { + cause: + cause.reason === "cancelled" + ? artifactFileCancellationCause(cause) + : cause, + ...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }), + ...(cause.partialObservation === undefined + ? {} + : { partialObservation: cause.partialObservation }), + }, + ); + bindArtifactFileCleanup(cause, error); + return error; + } + // Caller-selection and unsupported-target failures are already typed; keep + // their correction details instead of reducing them to an I/O failure. + if ( + cause instanceof AnalysisInputError || + cause instanceof AnalysisCancelledError || + cause instanceof AnalysisUnsupportedTargetError + ) + return cause; + return new ArtifactOperationError(operation, "io"); +}; diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 46692f54f7..446a824334 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -1,3 +1,6 @@ +import { bindArtifactFileCleanup } from "./ArtifactFileOperation.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { translateArtifactFailure } from "./ArtifactOperationFailure.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { createAnalysisExecution, @@ -27,21 +30,14 @@ import { } from "../domain/binaryTargetTypes.js"; import { AnalysisCapabilityUnavailableError, - AnalysisCancelledError, AnalysisInputError, AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; -import { ArtifactOperationError } from "../domain/artifactOperationError.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { interfaceBuilderLimitsSchema } from "../domain/apple/interfaceBuilderGraph.js"; import { err, ok, type Result } from "../domain/result.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; -import { - artifactFileCancellationCause, - bindArtifactFileCleanup, -} from "./ArtifactFileOperation.js"; import { artifactCapabilities } from "./ArtifactProviderMetadata.js"; import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js"; import { createEvidence } from "../domain/evidence.js"; @@ -408,42 +404,6 @@ const isArtifactOperation = ( operation as (typeof ARTIFACT_ANALYSIS_OPERATIONS)[number], ); -/** Translate one artifact boundary failure while retaining cleanup evidence. */ -export const translateArtifactFailure = ( - operation: ArtifactAnalysisOperation, - cause: unknown, -): AnalysisError => { - if (cause instanceof ArtifactReaderFailure) { - const error = new ArtifactOperationError( - operation, - cause.reason, - cause.details, - cause.message, - { - cause: - cause.reason === "cancelled" - ? artifactFileCancellationCause(cause) - : cause, - ...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }), - ...(cause.partialObservation === undefined - ? {} - : { partialObservation: cause.partialObservation }), - }, - ); - bindArtifactFileCleanup(cause, error); - return error; - } - // Caller-selection and unsupported-target failures are already typed; keep - // their correction details instead of reducing them to an I/O failure. - if ( - cause instanceof AnalysisInputError || - cause instanceof AnalysisCancelledError || - cause instanceof AnalysisUnsupportedTargetError - ) - return cause; - return new ArtifactOperationError(operation, "io"); -}; - const subjectFor = ( path: string, manifest: { diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index fadae75f83..db504f0321 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -4,6 +4,7 @@ import type { Readable } from "node:stream"; import type { ArtifactCommand, ArtifactInventoryCoverage, + ArtifactTraversalOmissionKind, } from "../domain/artifactGraph.js"; import type { ZipPackageFormat } from "../domain/zipPackageFormat.js"; import type { @@ -106,13 +107,7 @@ export const sameArtifactEntry = ( /** One entry or subtree deliberately excluded from a traversal. */ export interface ArtifactTraversalOmission { readonly path: string; - readonly kind: - | "fifo" - | "socket" - | "block-device" - | "character-device" - | "unsupported" - | "directory-descendants"; + readonly kind: ArtifactTraversalOmissionKind; } /** Facts from an exhausted traversal, separate from entry content certainty. */ diff --git a/src/artifacts/ArtifactResourceScope.test.ts b/src/artifacts/ArtifactResourceScope.test.ts index eeeffa649a..f9bffdb185 100644 --- a/src/artifacts/ArtifactResourceScope.test.ts +++ b/src/artifacts/ArtifactResourceScope.test.ts @@ -15,7 +15,7 @@ import { ArtifactReaderFailure, type ArtifactReader, } from "./ArtifactReader.js"; -import { translateArtifactFailure } from "./ArtifactProvider.js"; +import { translateArtifactFailure } from "./ArtifactOperationFailure.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; const deferred = () => { diff --git a/src/artifacts/DesktopApplicationReader.ts b/src/artifacts/DesktopApplicationReader.ts new file mode 100644 index 0000000000..c9f7ee879e --- /dev/null +++ b/src/artifacts/DesktopApplicationReader.ts @@ -0,0 +1,443 @@ +import { desktopPathConventions } from "../domain/nativeConvention.js"; +import { isAbsolute } from "node:path"; +import { + DirectoryArtifactReader, + DirectoryArtifactTraversalLimit, + type DirectoryArtifactEntry, +} from "./DirectoryArtifactReader.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import { + ArtifactFileCancelled, + ArtifactFileCleanupFailure, +} from "./ArtifactFileOperation.js"; +import { + ArtifactResourceScope, + type ArtifactResourceOwner, +} from "./ArtifactResourceScope.js"; +import { abortIfNeeded } from "./ArtifactHash.js"; +import { translateArtifactFailure } from "./ArtifactOperationFailure.js"; +import { + inspectDesktopFile, + type DesktopReadBudget, +} from "./DesktopFileInspection.js"; +import type { DesktopApplicationPort } from "../application/artifacts/DesktopApplicationPort.js"; +import type { ExecutionOptions } from "../application/AnalysisProvider.js"; +import type { + DesktopApplication, + DesktopFile, + DesktopOmission, + InspectDesktopApplicationInput, +} from "../domain/desktopApplication.js"; +import { projectDesktopApplication } from "../domain/desktopApplicationProjection.js"; +import { digestCanonicalValue } from "../domain/canonicalDigest.js"; +import { err, ok } from "../domain/result.js"; +import { createDesktopApplicationEvidence } from "../domain/desktopApplicationEvidence.js"; +import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js"; +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; + +const RETAINED_METADATA_BYTES = 32 * 1024 * 1024; + +/** Stop traversal without discarding the preceding bounded observations. */ +class DesktopRepresentationLimit extends ArtifactReaderFailure { + constructor() { + super( + "limit", + "Directory metadata reached the 32 MiB representation budget; remaining entry and omission facts are unavailable.", + { boundary: "memory" }, + ); + } +} + +/** Existing artifact ownership with bounded metadata, serial probes and no payload cache. */ +export class DesktopApplicationReader implements DesktopApplicationPort { + readonly #resources = new ArtifactResourceScope(); + #running = false; + constructor( + private readonly directoryReaderFor: ( + path: string, + ) => DirectoryArtifactReader = (path) => new DirectoryArtifactReader(path), + ) {} + + async inspect( + input: InspectDesktopApplicationInput, + options?: ExecutionOptions, + ) { + if (this.#running) + return err( + new AnalysisResourceConstraintError( + "inspect_desktop_application", + "capacity", + "This artifact reader already owns a directory inspection", + { max_concurrent_inspections: 1 }, + { + retryable: true, + remediationAction: + "Wait for this directory inspection to finish before submitting another.", + }, + ), + ); + this.#running = true; + try { + return ok( + await this.#resources.run(() => this.#inspect(input, options?.signal)), + ); + } catch (cause: unknown) { + const error = translateArtifactFailure( + "inspect_desktop_application", + cause, + ); + return err(error); + } finally { + this.#running = false; + } + } + + close(): Promise { + return this.#resources.close(); + } + + async #inspect( + input: InspectDesktopApplicationInput, + signal?: AbortSignal, + ): Promise { + if (!isAbsolute(input.path)) + throw new ArtifactReaderFailure( + "path", + "Expected an absolute directory path", + ); + const reader = this.directoryReaderFor(input.path); + const owner: ArtifactResourceOwner = { + kind: "reader", + resource: input.path, + reader, + }; + let outcome: + | { kind: "completed"; value: DesktopApplication } + | { kind: "failed"; cause: unknown }; + let report: DesktopApplication | undefined; + try { + const scanned = await scanDirectory(reader, input, signal); + report = scanned.report; + outcome = + scanned.failure === undefined + ? { kind: "completed", value: report } + : { kind: "failed", cause: scanned.failure.cause }; + } catch (cause: unknown) { + outcome = { kind: "failed", cause }; + } + const closed = await this.#resources.release(owner); + const partial = + report === undefined || + (closed.kind !== "failed" && + outcome.kind !== "failed" && + signal?.aborted !== true) + ? undefined + : createDesktopApplicationEvidence( + input, + report, + ARTIFACT_GRAPH_PROVIDER, + ); + if (closed.kind === "failed") + throw this.#resources.cleanupFailure(outcome, [closed], partial); + if (outcome.kind === "failed") { + const cause = outcome.cause; + if (!(cause instanceof ArtifactReaderFailure) || partial === undefined) + throw cause; + throw new ArtifactReaderFailure( + cause.reason, + cause.message, + { + cause, + cancellation: cause.cancellation, + cleanup: cause.cleanup, + cleanupLeases: cause.cleanupLeases, + partialObservation: partial, + ...(cause.boundary === undefined ? {} : { boundary: cause.boundary }), + }, + cause.details, + ); + } + if (signal?.aborted) + throw new ArtifactReaderFailure( + "cancelled", + "Directory inspection cancelled during final cleanup", + { cause: signal.reason, partialObservation: partial }, + ); + return outcome.value; + } +} + +const scanDirectory = async ( + reader: DirectoryArtifactReader, + input: InspectDesktopApplicationInput, + signal?: AbortSignal, +): Promise<{ + readonly report: DesktopApplication; + readonly failure?: { readonly cause: unknown }; +}> => { + const admittedRoot = await reader.rootObservation(); + const root = { + canonical_path: admittedRoot.canonicalPath, + identity: { + device: admittedRoot.identity.dev.toString(), + inode: admittedRoot.identity.ino.toString(), + }, + }; + const namespaceId = `dsr_${digestCanonicalValue(root, "Admitted desktop directory namespace")}`; + const files: DesktopFile[] = []; + const omissions: DesktopOmission[] = []; + const unityWitnessRoots = new Set(); + const budget: DesktopReadBudget = { + bytes: 0, + maximum: input.max_probe_bytes, + retainedBytes: 1024 + (input.path.length + root.canonical_path.length) * 24, + maximumRetainedBytes: RETAINED_METADATA_BYTES, + }; + const reserve = (bytes: number) => { + if (bytes > budget.maximumRetainedBytes - budget.retainedBytes) + throw new DesktopRepresentationLimit(); + budget.retainedBytes += bytes; + }; + let entries = 0; + let probed = 0; + let symlinks = 0; + let special = 0; + let partial = false; + let failure: { readonly cause: unknown } | undefined; + const limitations = [ + "Runtime candidates and component markers do not establish loaded code, execution, or build correspondence.", + "The directory SHA-256 commits only to retained paths, omission kinds, available filesystem metadata and read-range digests, not to every file's contents. Unread bytes are unknown.", + "Special-entry payloads are not read. Their paths and kinds are retained; other special-entry metadata is unavailable in this report.", + "Child symlinks are not followed. Executable section-embedded PCKs without a trailer and encrypted contents remain unexamined.", + "This is a sequence of stable entry observations, not an atomic snapshot of the entire directory.", + "Unity reference decoding stops after a witness in each Data/Managed directory; other assemblies' references are unexamined. Header facts remain per-file.", + ]; + try { + for await (const entry of reader.entries(signal, { + maximumEntries: input.max_entries, + onOmission: (omission) => { + if (omission.kind !== "directory-descendants") { + special++; + entries++; + } + partial = true; + reserve(512 + omission.path.length * 24); + const semantic = { + root_namespace_id: namespaceId, + path: omission.path, + kind: omission.kind, + }; + omissions.push({ + omission_id: `dso_${digestCanonicalValue(semantic, "Desktop traversal omission")}`, + path: omission.path, + kind: omission.kind, + }); + }, + })) { + entries++; + abortIfNeeded(signal); + // Reserve a conservative representation estimate before retaining path-dependent objects. + const reservation = + 1024 + (entry.path.length + entry.adapterKey.length) * 24; + reserve(reservation); + const row = fileRow(entry, namespaceId); + files.push(row); + if (entry.kind === "symlink") { + symlinks++; + continue; + } + if (entry.kind !== "file") continue; + const managedRoot = addPathFacts(row); + if (!probeSelected(entry)) continue; + if (budget.bytes >= budget.maximum) { + row.read_status = "unavailable"; + row.issues.push( + "Probe byte budget exhausted; bytes were not inspected.", + ); + partial = true; + continue; + } + probed++; + try { + await reader.inspectFile( + entry, + (file) => + inspectDesktopFile( + file, + row, + budget, + signal, + managedRoot !== null && !unityWitnessRoots.has(managedRoot) + ? managedRoot + : null, + ), + signal, + ); + row.read_status = row.issues.length === 0 ? "examined" : "partial"; + if ( + managedRoot !== null && + row.facts.some((fact) => fact.kind === "unity-assembly-reference") + ) + unityWitnessRoots.add(managedRoot); + if (row.issues.length > 0) partial = true; + } catch (cause: unknown) { + if (!(cause instanceof ArtifactReaderFailure)) throw cause; + const probeIssueCount = row.issues.length; + row.read_status = "unavailable"; + row.issues.push(cause.message); + partial = true; + const completedRead = + cause instanceof ArtifactFileCancelled || + (cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "completed"); + if (completedRead) + row.read_status = probeIssueCount === 0 ? "examined" : "partial"; + if ( + !completedRead && + (cause.reason === "integrity" || + cause.reason === "io" || + cause.reason === "cancelled") + ) { + row.facts = row.facts.filter((fact) => fact.offset === null); + row.ranges = []; + } + if (cause.cleanup !== undefined || cause.reason === "cancelled") + throw cause; + } + } + } catch (cause: unknown) { + partial = true; + if (cause instanceof DirectoryArtifactTraversalLimit) { + entries = cause.examinedEntries; + limitations.push( + "Directory traversal reached max_entries; unvisited paths and candidate families remain unknown.", + ); + } else if (cause instanceof DesktopRepresentationLimit) { + limitations.push(cause.message); + } else { + failure = { cause }; + limitations.push( + "Inspection was interrupted; only preceding stable entry observations are retained.", + ); + } + } + files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + omissions.sort((a, b) => + a.path < b.path + ? -1 + : a.path > b.path + ? 1 + : a.kind < b.kind + ? -1 + : a.kind > b.kind + ? 1 + : 0, + ); + const coverage: DesktopApplication["coverage"] = { + status: partial ? "partial" : "complete-within-scan", + entries_examined: entries, + files_probed: probed, + bytes_read: budget.bytes, + symlinks_not_followed: symlinks, + special_entries_omitted: special, + limits: { + max_entries: input.max_entries, + max_probe_bytes: input.max_probe_bytes, + }, + }; + // Compose identities already owned by admitted root/entry observations and + // retained reads. Caller policy, interpretations and cleanup diagnostics stay + // in Evidence without renaming the same examined source. + const snapshot = digestCanonicalValue( + { + root_namespace_id: namespaceId, + files: files.map(({ file_id, bytes, ranges }) => ({ + file_id, + bytes, + ranges, + })), + omissions: omissions.map(({ omission_id }) => omission_id), + }, + "Examined desktop directory facts", + ); + const report: DesktopApplication = { + root: { + path: input.path, + ...root, + namespace_id: namespaceId, + snapshot_sha256: snapshot, + identity_scope: "examined-metadata-and-read-ranges", + }, + files, + omissions, + ...projectDesktopApplication(files), + coverage, + limitations, + }; + return { report, ...(failure === undefined ? {} : { failure }) }; +}; + +const fileRow = ( + entry: DirectoryArtifactEntry, + rootNamespaceId: string, +): DesktopFile => { + const source = entry.sourceIdentity; + const identity = + source === undefined + ? null + : { + device: source.device.toString(), + inode: source.inode.toString(), + size: source.size.toString(), + mode: source.mode.toString(), + mtime_ns: source.mtimeNs.toString(), + ctime_ns: source.ctimeNs.toString(), + }; + const semantic = { + root_namespace_id: rootNamespaceId, + path: entry.path, + kind: entry.kind, + identity, + }; + return { + file_id: `dsf_${digestCanonicalValue(semantic, "Desktop directory entry")}`, + path: entry.path, + local_path: entry.adapterKey, + kind: entry.kind, + bytes: entry.declaredSize, + identity, + read_status: "not-selected", + ranges: [], + facts: [], + issues: [], + }; +}; + +const addPathFacts = (row: DesktopFile): string | null => { + const conventions = desktopPathConventions(row.path); + if (conventions.electronArchive) + row.facts.push({ + kind: "electron-asar-convention", + offset: null, + value: row.path, + }); + if (conventions.unrealContainer) + row.facts.push({ + kind: "unreal-container-convention", + offset: null, + value: row.path, + }); + if (conventions.il2cppLibrary) + row.facts.push({ + kind: "il2cpp-library-convention", + offset: null, + value: row.path, + }); + return conventions.unityManagedDirectory; +}; + +const probeSelected = (entry: DirectoryArtifactEntry): boolean => + entry.executable || + /(?:\.(?:exe|dll|so|dylib|pck|utoc)|(?:^|\/)global-metadata\.dat|(?:^|\/)data\.win)$/iu.test( + entry.path, + ); diff --git a/src/artifacts/DesktopFileInspection.ts b/src/artifacts/DesktopFileInspection.ts new file mode 100644 index 0000000000..81315f1cf0 --- /dev/null +++ b/src/artifacts/DesktopFileInspection.ts @@ -0,0 +1,358 @@ +import type { FileHandle } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { setImmediate } from "node:timers/promises"; +import type { DesktopFile, DesktopFact } from "../domain/desktopApplication.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { jsonStringByteLength } from "../domain/jsonStringByteLength.js"; +import { parsePeRecord, peRvaToFileOffset } from "../domain/binaryTarget.js"; +import { readManagedMetadataLayout } from "../dotnet/ManagedMetadataLayout.js"; +import { readAssemblyReferenceName } from "../dotnet/ManagedMetadataInventoryRows.js"; +import { ManagedReaderFailure } from "../dotnet/ManagedReaderFailure.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import { abortIfNeeded } from "./ArtifactHash.js"; +import { hasMachoMagic } from "./apple/MachoLoadCommandReader.js"; + +export interface DesktopReadBudget { + bytes: number; + readonly maximum: number; + retainedBytes: number; + readonly maximumRetainedBytes: number; +} + +/** A resource refusal is separate from malformed format declarations. */ +class DesktopProbeLimit extends ArtifactReaderFailure { + constructor() { + super("limit", "Directory probe byte budget exhausted", { + boundary: "capacity", + }); + } +} + +/** Count the producer-owned, shallow fact shape without expanding target strings. */ +const factJsonBytes = (value: JsonValue): number => { + if (typeof value === "string") return jsonStringByteLength(value); + if (Array.isArray(value)) { + let bytes = 2 + Math.max(0, value.length - 1); + for (const child of value) bytes += factJsonBytes(child); + return bytes; + } + if (value !== null && typeof value === "object") { + const entries = Object.entries(value); + let bytes = 2 + Math.max(0, entries.length - 1); + for (const [key, child] of entries) + bytes += jsonStringByteLength(key) + 1 + factJsonBytes(child); + return bytes; + } + return String(value).length; +}; + +/** Inspect selected ranges on an already admitted descriptor; never hash payloads. */ +export const inspectDesktopFile = async ( + file: FileHandle, + row: DesktopFile, + budget: DesktopReadBudget, + signal?: AbortSignal, + unityManagedDirectory: string | null = null, +): Promise => { + const size = row.bytes; + if (size === null) + throw new ArtifactReaderFailure( + "limit", + "File extent exceeds exact host offsets", + { boundary: "file-size" }, + ); + const read = async (offset: number, bytes: number): Promise => { + abortIfNeeded(signal); + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(bytes) || + bytes < 0 || + offset > size || + bytes > size - offset + ) + throw new ArtifactReaderFailure( + "format", + "Declared byte range lies outside the file", + ); + if (bytes > budget.maximum - budget.bytes) throw new DesktopProbeLimit(); + const result = Buffer.alloc(bytes); + let consumed = 0; + while (consumed < bytes) { + abortIfNeeded(signal); + const part = await file.read( + result, + consumed, + bytes - consumed, + offset + consumed, + ); + budget.bytes += part.bytesRead; + if (part.bytesRead === 0) + throw new ArtifactReaderFailure( + "integrity", + "File ended before its observed extent", + ); + consumed += part.bytesRead; + } + abortIfNeeded(signal); + row.ranges.push({ + offset, + bytes, + sha256: createHash("sha256").update(result).digest("hex"), + }); + return result; + }; + const fact = (observation: DesktopFact) => { + const reservation = 512 + factJsonBytes(observation.value) * 12; + if (reservation > budget.maximumRetainedBytes - budget.retainedBytes) + throw new ArtifactReaderFailure( + "limit", + "Directory facts reached the retained metadata budget", + { boundary: "memory" }, + ); + budget.retainedBytes += reservation; + row.facts.push(observation); + }; + const prefix = await read(0, Math.min(size, 64)); + if (prefix.length >= 8 && prefix.readUInt32LE(0) === 0xfab11baf) + fact({ + kind: "il2cpp-metadata-header", + offset: 0, + value: { + magic: "0xfab11baf", + raw_version: prefix.readUInt32LE(4), + }, + }); + if (prefix.length >= 20 && prefix.toString("latin1", 0, 4) === "GDPC") + fact({ + kind: "godot-pck-header", + offset: 0, + value: { + pack_version: prefix.readUInt32LE(4), + engine_version: [ + prefix.readUInt32LE(8), + prefix.readUInt32LE(12), + prefix.readUInt32LE(16), + ], + placement: "standalone", + }, + }); + if ( + prefix.length >= 16 && + prefix.toString("latin1", 0, 4) === "FORM" && + prefix.toString("latin1", 8, 12) === "GEN8" + ) { + const containerBytes = prefix.readUInt32LE(4); + const chunkBytes = prefix.readUInt32LE(12); + if ( + containerBytes >= 8 && + containerBytes <= size - 8 && + chunkBytes <= containerBytes - 8 + ) + fact({ + kind: "gamemaker-form-header", + offset: 0, + value: { + container: "FORM", + first_chunk: "GEN8", + }, + }); + } + if ( + prefix.length >= 16 && + prefix.toString("latin1", 0, 16) === "-==--==--==--==-" + ) + fact({ + kind: "unreal-container-convention", + offset: 0, + value: { format: "IoStore TOC" }, + }); + const native = + prefix.length >= 4 && + (prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) || + hasMachoMagic(prefix)); + if (native) + fact({ + kind: "native-image-header", + offset: 0, + value: { + format: prefix[0] === 0x7f ? "elf" : "mach-o", + signature: prefix.subarray(0, 4).toString("hex"), + }, + }); + const observe = async (operation: () => Promise) => { + try { + await operation(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + (cause.reason !== "format" && cause.reason !== "limit") + ) + throw cause; + if (!row.issues.includes(cause.message)) row.issues.push(cause.message); + } + }; + if (prefix.length === 64 && prefix.readUInt16LE(0) === 0x5a4d) + await observe(() => + inspectPeFile(prefix, row, read, fact, unityManagedDirectory, signal), + ); + if ( + (native || (prefix.length >= 2 && prefix.readUInt16LE(0) === 0x5a4d)) && + size >= 12 + ) + await observe(() => inspectPckTrailer(size, read, fact)); +}; + +type DesktopByteRead = (offset: number, bytes: number) => Promise; +type RecordDesktopFact = (observation: DesktopFact) => void; + +const inspectPeFile = async ( + prefix: Buffer, + row: DesktopFile, + read: DesktopByteRead, + fact: RecordDesktopFact, + unityManagedDirectory: string | null, + signal?: AbortSignal, +): Promise => { + const peOffset = prefix.readUInt32LE(0x3c); + const coff = await read(peOffset, 24); + if (coff.toString("latin1", 0, 4) !== "PE\0\0") return; + const optionalSize = coff.readUInt16LE(20); + if (optionalSize > 4096) + throw new ArtifactReaderFailure( + "format", + "PE optional header exceeds the supported format bound", + ); + const optional = await read(peOffset + 24, optionalSize); + const parsed = parsePeRecord(Buffer.concat([coff, optional]), undefined); + if (!parsed.ok) { + row.issues.push(parsed.error); + return; + } + if (parsed.value.format !== "pe") return; + fact({ + kind: "native-image-header", + offset: peOffset, + value: { format: "pe", machine: coff.readUInt16LE(4) }, + }); + if (!parsed.value.managed) return; + const directories = optional.readUInt16LE(0) === 0x20b ? 112 : 96; + const cliRva = optional.readUInt32LE(directories + 14 * 8); + const cliSize = optional.readUInt32LE(directories + 14 * 8 + 4); + fact({ + kind: "pe-cli-directory", + offset: peOffset + 24 + directories + 14 * 8, + value: { rva: cliRva, bytes: cliSize }, + }); + if (unityManagedDirectory === null) return; + const sectionCount = coff.readUInt16LE(6); + const sections = await read(peOffset + 24 + optionalSize, sectionCount * 40); + const mapped = (rva: number, bytes: number) => + peRvaToFileOffset( + rva, + sections, + sectionCount, + optional.readUInt32LE(60), + bytes, + ); + const cliOffset = mapped(cliRva, 16); + if (cliOffset === undefined || cliSize < 72) + throw new ArtifactReaderFailure( + "format", + "CLI metadata directory cannot be mapped", + ); + const cli = await read(cliOffset, 16); + if (cli.readUInt32LE(0) < 72) + throw new ArtifactReaderFailure( + "format", + "CLI header declares a truncated runtime header", + ); + const metadataBytes = cli.readUInt32LE(12); + const metadataOffset = mapped(cli.readUInt32LE(8), metadataBytes); + if (metadataOffset === undefined) + throw new ArtifactReaderFailure( + "format", + "CLI metadata range cannot be mapped", + ); + const bytes = await read(metadataOffset, metadataBytes); + try { + const layout = readManagedMetadataLayout(bytes, 0, bytes.length); + const refs = layout.table(35); + for (let index = 1; index <= (refs?.rowCount ?? 0); index++) { + // Yield between bounded batches so metadata names cannot monopolize MCP. + if ((index - 1) % 256 === 0) { + await setImmediate(); + abortIfNeeded(signal); + } + const reference = readAssemblyReferenceName( + bytes, + layout, + index, + layout.strings.size, + ); + if ( + reference.name !== "UnityEngine" && + !reference.name.startsWith("UnityEngine.") + ) + continue; + fact({ + kind: "unity-assembly-reference", + offset: metadataOffset + reference.row_offset, + value: { + name: reference.name, + token: reference.token, + managed_directory: unityManagedDirectory, + }, + }); + return; + } + } catch (cause: unknown) { + if (!(cause instanceof ManagedReaderFailure)) throw cause; + throw new ArtifactReaderFailure( + cause.issue.code === "resource-limit" ? "limit" : "format", + cause.message, + { + cause, + ...(cause.issue.code === "resource-limit" + ? { boundary: "memory" as const } + : {}), + }, + ); + } +}; + +const inspectPckTrailer = async ( + size: number, + read: DesktopByteRead, + fact: RecordDesktopFact, +): Promise => { + const trailer = await read(size - 12, 12); + if (trailer.toString("latin1", 8, 12) === "GDPC") { + const packSize = trailer.readBigUInt64LE(0); + if (packSize < 20n || packSize > BigInt(size - 12)) + throw new ArtifactReaderFailure( + "format", + "Embedded PCK trailer declares an invalid pack extent", + ); + const offset = size - 12 - Number(packSize); + const header = await read(offset, 20); + if (header.toString("latin1", 0, 4) !== "GDPC") + throw new ArtifactReaderFailure( + "format", + "Embedded PCK trailer does not locate a pack header", + ); + fact({ + kind: "godot-pck-header", + offset, + value: { + pack_version: header.readUInt32LE(4), + engine_version: [ + header.readUInt32LE(8), + header.readUInt32LE(12), + header.readUInt32LE(16), + ], + placement: "embedded-trailer", + }, + }); + } +}; diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index a85c2b1b11..f3a45c54db 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -21,6 +21,26 @@ import { hostExecutableMarking } from "./hostExecutableMarking.js"; import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { readArtifactFileChunks } from "./readArtifactFileChunks.js"; +import { + withOwnedFileHandle, + RegularFileCleanupFailure, + RegularFileCancelled, +} from "../filesystem/OwnedRegularFile.js"; +import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js"; +import { + ArtifactFileCancelled, + ArtifactFileCleanupFailure, +} from "./ArtifactFileOperation.js"; +import { AnalysisCleanupLease } from "./AnalysisCleanupLease.js"; + +export type DirectoryArtifactEntry = ArtifactEntry & { + readonly kind: "file" | "directory" | "symlink"; +}; + +interface DirectoryArtifactRoot { + readonly canonicalPath: string; + readonly identity: { readonly dev: bigint; readonly ino: bigint }; +} interface DirectoryTraversalOptions extends ArtifactTraversalOptions { readonly descendInto?: (path: string) => boolean; @@ -43,11 +63,11 @@ export class DirectoryArtifactTraversalLimit extends ArtifactReaderFailure { /** Non-symlink-following directory reader. */ export class DirectoryArtifactReader implements ArtifactReader { readonly format = "directory" as const; - #rootPromise: Promise | undefined; - #rootIdentity: { readonly dev: bigint; readonly ino: bigint } | undefined; + #rootPromise: Promise | undefined; readonly #rootPath: string; readonly #directories = new Set(); readonly #files = new Map(); + readonly #fileCleanup = new WeakMap(); constructor(root: string) { this.#rootPath = root; @@ -57,8 +77,9 @@ export class DirectoryArtifactReader implements ArtifactReader { async *entries( signal?: AbortSignal, selection?: DirectoryTraversalOptions | ((path: string) => boolean), - ): AsyncIterable { - const root = await this.#resolveRoot(); + ): AsyncIterable { + const admittedRoot = await this.#resolveRoot(); + const root = admittedRoot.canonicalPath; const options = typeof selection === "function" ? { descendInto: selection } : selection; const maximum = options?.maximumEntries; @@ -81,7 +102,7 @@ export class DirectoryArtifactReader implements ArtifactReader { const directory = pending.pop(); if (directory === undefined) break; await withDirectoryIoContext("inspect directory", directory, () => - assertContainedDirectory(root, directory, this.#rootIdentity), + assertContainedDirectory(root, directory, admittedRoot.identity), ); for await (const entry of this.#readDirectory( directory, @@ -92,22 +113,34 @@ export class DirectoryArtifactReader implements ArtifactReader { )) yield entry; await withDirectoryIoContext("inspect directory", directory, () => - assertContainedDirectory(root, directory, this.#rootIdentity), + assertContainedDirectory(root, directory, admittedRoot.identity), ); } } - async #resolveRoot(): Promise { + /** Admission facts, shared with traversal; the selected root may be a symlink. */ + rootObservation(): Promise { + return this.#resolveRoot(); + } + + async #resolveRoot(): Promise { return (this.#rootPromise ??= (async () => { const root = await realpath(this.#rootPath); const metadata = await lstat(root, { bigint: true }); - if (metadata.isSymbolicLink() || !metadata.isDirectory()) + if (metadata.isSymbolicLink()) throw new ArtifactReaderFailure( "path", "Artifact root changed during traversal", ); - this.#rootIdentity = { dev: metadata.dev, ino: metadata.ino }; - return root; + if (!metadata.isDirectory()) + throw new ArtifactReaderFailure( + "path", + "Artifact root must be a directory", + ); + return Object.freeze({ + canonicalPath: root, + identity: Object.freeze({ dev: metadata.dev, ino: metadata.ino }), + }); })().catch((cause: unknown) => { if (cause instanceof ArtifactReaderFailure) throw cause; throw directoryIoFailure("resolve artifact root", this.#rootPath, cause); @@ -120,8 +153,9 @@ export class DirectoryArtifactReader implements ArtifactReader { signal: AbortSignal | undefined, options: DirectoryTraversalOptions | undefined, examine: () => void, - ): AsyncIterable { - const root = await this.#resolveRoot(); + ): AsyncIterable { + const admittedRoot = await this.#resolveRoot(); + const root = admittedRoot.canonicalPath; const handle = new OwnedDirectoryHandle( await opendir(directory).catch((cause: unknown) => { throw directoryIoFailure("open directory", directory, cause); @@ -137,7 +171,7 @@ export class DirectoryArtifactReader implements ArtifactReader { try { for (;;) { await withDirectoryIoContext("inspect directory", directory, () => - assertContainedDirectory(root, directory, this.#rootIdentity), + assertContainedDirectory(root, directory, admittedRoot.identity), ); const child = await handle.handle.read(); if (child === null) break; @@ -199,7 +233,10 @@ export class DirectoryArtifactReader implements ArtifactReader { } } - async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + async #openFile( + entry: ArtifactEntry, + signal?: AbortSignal, + ): Promise> { abortIfNeeded(signal); if (entry.kind !== "file") throw new ArtifactReaderFailure( @@ -234,14 +271,7 @@ export class DirectoryArtifactReader implements ArtifactReader { ); } abortIfNeeded(signal); - const source = Readable.from( - mapStableFileRead(owner.handle, entry, signal), - { objectMode: false }, - ); - source.once("close", () => { - void this.#closeFile(owner).catch(() => undefined); - }); - return source; + return owner; } catch (cause: unknown) { try { await this.#closeFile(owner); @@ -262,11 +292,89 @@ export class DirectoryArtifactReader implements ArtifactReader { } } + /** Borrow an admitted entry for bounded random reads; retain failed cleanup. */ + async inspectFile( + entry: ArtifactEntry, + inspect: (file: FileHandle) => Promise, + signal?: AbortSignal, + ): Promise { + const owner = await this.#openFile(entry, signal); + try { + const value = await withOwnedFileHandle( + entry.adapterKey, + owner, + signal === undefined ? {} : { signal }, + async (file) => { + let outcome: + | { kind: "completed"; value: Value } + | { kind: "failed"; cause: unknown }; + try { + outcome = { kind: "completed", value: await inspect(file) }; + } catch (cause: unknown) { + outcome = { kind: "failed", cause }; + } + const [opened, named] = await Promise.all([ + file.stat({ bigint: true }), + lstat(entry.adapterKey, { bigint: true }), + ]); + abortIfNeeded(signal); + if ( + !opened.isFile() || + !named.isFile() || + !sameArtifactSourceIdentity(entry.sourceIdentity, opened) || + !sameArtifactSourceIdentity(entry.sourceIdentity, named) + ) + throw new ArtifactReaderFailure( + "integrity", + `Directory entry changed during inspection: ${entry.path}`, + ); + if (outcome.kind === "failed") throw outcome.cause; + return outcome.value; + }, + ); + this.#files.delete(owner); + return value; + } catch (cause: unknown) { + if (cause instanceof RegularFileCleanupFailure) { + const lease = new AnalysisCleanupLease( + () => this.#closeFile(owner), + [entry.adapterKey], + ); + this.#fileCleanup.set(owner, lease); + throw new ArtifactFileCleanupFailure(cause, lease); + } + this.#files.delete(owner); + if (cause instanceof RegularFileCancelled) + throw new ArtifactFileCancelled(cause); + if (cause instanceof RegularFileOperationCancelled) + throw new ArtifactReaderFailure( + "cancelled", + "Directory inspection cancelled", + { cause: cause.reason }, + ); + throw directoryIoFailure("inspect entry", entry.adapterKey, cause); + } + } + + async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + const owner = await this.#openFile(entry, signal); + const source = Readable.from( + mapStableFileRead(owner.handle, entry, signal), + { objectMode: false }, + ); + source.once("close", () => { + void this.#closeFile(owner).catch(() => undefined); + }); + return source; + } + async close(): Promise { const failures: unknown[] = []; for (const [owner, path] of this.#files) { try { - await this.#closeFile(owner); + const lease = this.#fileCleanup.get(owner); + if (lease === undefined) await this.#closeFile(owner); + else await lease.release(); } catch (cause: unknown) { failures.push(directoryIoFailure("close opened entry", path, cause)); } @@ -303,6 +411,7 @@ export class DirectoryArtifactReader implements ArtifactReader { async #closeFile(owner: OwnedFileHandle): Promise { await owner.close(); this.#files.delete(owner); + this.#fileCleanup.delete(owner); } provenance(): readonly [] { @@ -327,7 +436,7 @@ const artifactEntry = ( path: string, absolute: string, metadata: BigIntStats, -): ArtifactEntry | undefined => { +): DirectoryArtifactEntry | undefined => { const kind = metadata.isSymbolicLink() ? "symlink" : metadata.isDirectory() diff --git a/src/cli.ts b/src/cli.ts index d73bfa21a6..9da20e6746 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { registerWasmCommands } from "./cli/wasmCommands.js"; import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; import { createDirectAnalysis } from "./composition/directAnalysis.js"; import { registerEvmCommands } from "./cli/evmCommands.js"; +import { registerDesktopApplicationCommands } from "./cli/desktopApplicationCommands.js"; import { registerGoCommands } from "./cli/goCommands.js"; import { registerRustCommands } from "./cli/rustCommands.js"; import { registerSqliteCommands } from "./cli/sqliteCommands.js"; @@ -116,6 +117,7 @@ export const createCli = ( registerCaptureCommands(cli, logger, environment); registerSignalCommands(cli, logger, environment); registerEvmCommands(cli, logger, environment); + registerDesktopApplicationCommands(cli, logger); registerGoCommands(cli, logger); registerRustCommands(cli, logger); registerSqliteCommands(cli, logger, environment); diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index aaaca23233..979dbc30ce 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -71,7 +71,7 @@ export const registerAnalysisViewCommands = ( }); cli.command(CLI_COMMANDS.inspectAnalysisView, { description: - "Project a selected view of completed binary-layout, JavaScript application, native function, artifact-inventory, WASM, Mach-O, Interface Builder, EVM, managed, Android package, Dart AOT, web network capture or pcap Evidence JSON", + "Project a selected view of completed binary-layout, JavaScript application, native function, artifact-inventory, desktop-directory, WASM, Mach-O, Interface Builder, EVM, managed, Android package, Dart AOT, web network capture or pcap Evidence JSON", args: z.object({ inputJson: z.string().describe("Inline workflow JSON or JSON file path"), }), diff --git a/src/cli/desktopApplicationCommands.ts b/src/cli/desktopApplicationCommands.ts new file mode 100644 index 0000000000..d448d217c6 --- /dev/null +++ b/src/cli/desktopApplicationCommands.ts @@ -0,0 +1,135 @@ +import type { Evidence } from "../domain/evidence.js"; +import type { AnalysisErrorProjection } from "../domain/analysisErrorProjection.js"; +import { z } from "incur"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { createDesktopApplicationService } from "../composition/desktop.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import { inspectAnalysisView } from "../application/analysisView/AnalysisViewService.js"; +import { writeEvidenceBundle } from "../application/EvidenceBundleFiles.js"; +import { createEvidenceBundle } from "../domain/evidenceBundle.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; + +const isEvidenceResult = ( + value: Evidence | AnalysisErrorProjection, +): value is Evidence => "evidence_id" in value && "normalized_result" in value; + +/** One-shot complete Evidence can be saved and queried with inspect-analysis-view. */ +export const registerDesktopApplicationCommands = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.inspectDesktopApplication, { + description: + "Identify desktop runtime candidates from bounded directory and header observations", + args: z.object({ + path: z.string().describe("Local installation directory"), + }), + options: z.object({ + max_entries: z + .number() + .int() + .positive() + .max(100_000) + .default(20_000) + .describe( + "Maximum directory entries to examine; exhaustion leaves partial coverage", + ), + detail: z + .enum(["complete", "summary"]) + .default("complete") + .describe( + "Deliver complete Evidence, or a summary with --evidence_output", + ), + evidence_output: z + .string() + .min(1) + .optional() + .describe( + "Save complete Evidence as a bundle; required for summary delivery. Never overwrites.", + ), + max_probe_bytes: z + .number() + .int() + .min(4096) + .max(64 * 1024 * 1024) + .default(8 * 1024 * 1024) + .describe("Maximum total bytes read from selected file ranges"), + }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand( + logger, + CLI_COMMANDS.inspectDesktopApplication, + async () => { + const { detail, evidence_output, ...limits } = options; + if (detail === "summary" && evidence_output === undefined) + return projectAnalysisError( + new AnalysisInputError( + "inspect_desktop_application", + undefined, + [ + { + path: ["evidence_output"], + reason: "invalid_value", + message: + "Summary delivery requires --evidence_output so complete source Evidence remains available.", + }, + ], + ), + ); + const service = createDesktopApplicationService(); + const complete = await finishAnalysisCommand( + () => + service.inspect( + { path: absoluteCallerPath(args.path), ...limits }, + { signal }, + ), + service, + "rea-artifact-graph", + "inspect_desktop_application", + signal, + ); + if (!isEvidenceResult(complete) || evidence_output === undefined) + return complete; + const resources = new ArtifactResourceScope(); + let receipt: + | { readonly path: string; readonly bytes: number } + | undefined; + const delivered = await finishAnalysisCommand( + async () => { + const written = await writeEvidenceBundle( + createEvidenceBundle([complete]), + absoluteCallerPath(evidence_output), + false, + resources, + signal, + ); + if (!written.ok) return written; + receipt = written.value; + return detail === "summary" + ? inspectAnalysisView({ + source: { kind: "inline", evidence: complete }, + view: { kind: "summary" }, + }) + : { ok: true as const, value: complete }; + }, + { close: () => resources.close() }, + "rea-artifact-graph", + "inspect_desktop_application", + signal, + ); + return isEvidenceResult(delivered) && detail === "summary" + ? { summary: delivered, evidence_bundle: receipt } + : delivered; + }, + ), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 84abeb9dbe..d46c2acae4 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -57,6 +57,7 @@ export const CLI_COMMANDS = Object.freeze({ debugWithGdb: "debug-with-gdb", debugWithRizin: "debug-with-rizin", + inspectDesktopApplication: "inspect-desktop-application", inspectGoBinary: "inspect-go-binary", inspectRustBinary: "inspect-rust-binary", inspectPeResources: "inspect-pe-resources", diff --git a/src/composition/desktop.ts b/src/composition/desktop.ts new file mode 100644 index 0000000000..6f3db6f6c8 --- /dev/null +++ b/src/composition/desktop.ts @@ -0,0 +1,6 @@ +import { DesktopApplicationService } from "../application/artifacts/DesktopApplicationService.js"; +import { DesktopApplicationReader } from "../artifacts/DesktopApplicationReader.js"; + +/** Use the existing artifact reader and Evidence identity without a deep provider. */ +export const createDesktopApplicationService = (): DesktopApplicationService => + new DesktopApplicationService(new DesktopApplicationReader()); diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index c2700c0cab..6fe55a1e27 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/desktopApplicationToolContracts.ts b/src/contracts/desktopApplicationToolContracts.ts new file mode 100644 index 0000000000..662b63fb08 --- /dev/null +++ b/src/contracts/desktopApplicationToolContracts.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { + desktopApplicationSchema, + inspectDesktopApplicationInputSchema, +} from "../domain/desktopApplication.js"; +import { desktopApplicationSummaryViewResultSchema } from "../domain/analysisView/analysisView.js"; +import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import type { ToolContract } from "./toolContractTypes.js"; + +/** Static directory triage belongs to the existing artifact family. */ +export const DESKTOP_APPLICATION_TOOL_CONTRACTS = [ + { + name: "inspect_desktop_application", + ...toolContractMetadata("inspect_desktop_application"), + kind: "artifact-provider", + description: + "Inspect a local desktop application directory using bounded listings, file headers and selected metadata. Returns .NET, Unity Mono/IL2CPP, Unreal, Godot, GameMaker and Electron candidates, supporting paths/range hashes, component path markers and concrete next probes. No target execution, extraction, network or target changes. Default summary retains complete Evidence for inspect_analysis_view kind desktop-application; collections files/candidates/components/next_probes support paging and field selection, with exact file paths or candidate families. detail complete returns every retained fact inline. Snapshot identity covers examined metadata/read ranges, not all file contents; coverage and unavailable probes remain explicit.", + inputSchema: inspectDesktopApplicationInputSchema.extend({ + detail: z.enum(["summary", "complete"]).default("summary"), + }), + outputSchema: evidenceResultOf( + z.union([ + desktopApplicationSchema, + desktopApplicationSummaryViewResultSchema, + ]), + ), + examples: [ + { + title: "Identify a desktop installation", + input: { path: "/artifacts/application" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 91627ee5b8..40bd3c275a 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -2,6 +2,7 @@ import { CAPTURE_TOOL_CONTRACTS } from "./captures/captureToolContracts.js"; import { SIGNAL_TOOL_CONTRACTS } from "./signals/signalToolContracts.js"; import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js"; import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; +import { DESKTOP_APPLICATION_TOOL_CONTRACTS } from "./desktopApplicationToolContracts.js"; import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js"; import { RUST_TOOL_CONTRACTS } from "./rust/rustToolContracts.js"; import { SQLITE_TOOL_CONTRACTS } from "./sqlite/sqliteToolContracts.js"; @@ -43,6 +44,7 @@ export const TOOL_CONTRACTS = [ ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, ...ANALYSIS_VIEW_TOOL_CONTRACTS, ...ARTIFACT_TOOL_CONTRACTS, + ...DESKTOP_APPLICATION_TOOL_CONTRACTS, ...EVM_TOOL_CONTRACTS, ...GO_TOOL_CONTRACTS, ...RUST_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index fbb92e2ab4..a7bafa9fd1 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -26,6 +26,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_native_load_image: "states that it does not execute the target", resolve_native_call_targets: "states that it does not execute code", inspect_pe_resources: "states that it does not execute the image", + inspect_desktop_application: + "states that directory triage performs no target execution", inspect_go_binary: "reads embedded metadata without executing the target", inspect_rust_binary: "reads an embedded dependency list without executing the target", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 1b47544a15..2cee154143 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -125,6 +125,7 @@ export const TOOL_EFFECTS: Readonly> = { executesForeignCode: true, idempotent: false, }), + inspect_desktop_application: evidence, inspect_go_binary: evidence, inspect_rust_binary: evidence, inspect_sqlite_database: effects({ diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 38002281e1..b8b8842cdb 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,4 +1,11 @@ import { z } from "zod"; +import { + desktopViewRequestSchema, + desktopViewItemsSchema, + desktopSummarySchema, + projectDesktopApplicationView, +} from "./desktopApplicationView.js"; +import { desktopCoverageSchema } from "../desktopApplication.js"; import { javascriptModuleReferencesRequestSchema, javascriptModuleReferenceSchema, @@ -225,6 +232,7 @@ const inventoryViewSchema = z.discriminatedUnion("collection", [ /** Caller-selected projection of already completed analysis Evidence. */ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ summaryViewSchema, + desktopViewRequestSchema, facetViewSchema, itemViewSchema, pageViewSchema, @@ -351,6 +359,7 @@ const viewResultBase = { parent_evidence_id: prefixedDigestSchema("ev"), parent_operation: z.enum([ "inspect_binary_layout", + "inspect_desktop_application", "analyze_javascript_application", "analyze_function", "inspect_artifact", @@ -421,6 +430,12 @@ export const artifactInventorySummaryViewResultSchema = z.strictObject({ summary: inventorySummarySchema, }); +export const desktopApplicationSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + parent_operation: z.literal("inspect_desktop_application"), + summary: desktopSummarySchema, +}); + /** Exact summary carrier for WASM inspection's optional delivery contract. */ export const wasmArtifactSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, @@ -514,6 +529,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ ...summaryViewResultBase, summary: z.union([ layoutSummarySchema, + desktopSummarySchema, javascriptSummarySchema, inventorySummarySchema, wasmSummarySchema, @@ -584,6 +600,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ item: wasmViewItemSchema, ...viewResultBase, }), + z.strictObject({ + kind: z.literal("desktop-application"), + view: desktopViewRequestSchema, + items: desktopViewItemsSchema, + ...viewResultBase, + source_coverage: desktopCoverageSchema, + parent_operation: z.literal("inspect_desktop_application"), + }), z.strictObject({ kind: z.literal("macho"), view: machoViewRequestSchema, @@ -680,6 +704,10 @@ export interface AnalysisViewParent { /** View payload before the content digest is attached. */ export type UnsignedAnalysisView = + | Omit< + Extract, + "view_digest" + > | Omit< Extract, "view_digest" @@ -810,6 +838,7 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => { /** Named source owners for views that need no extra decoder or accumulator. */ const retainedViewProjectors = new Map([ + ["inspect_desktop_application", projectDesktopApplicationView], ["inspect_wasm_artifact", projectWasmArtifactView], ["inspect_macho", projectMachoView], ["decode_interface_builder", projectInterfaceBuilderView], diff --git a/src/domain/analysisView/desktopApplicationView.ts b/src/domain/analysisView/desktopApplicationView.ts new file mode 100644 index 0000000000..2be6d1dca2 --- /dev/null +++ b/src/domain/analysisView/desktopApplicationView.ts @@ -0,0 +1,352 @@ +import { z } from "zod"; +import { + desktopApplicationSchema, + desktopFileSchema, + desktopOmissionSchema, + desktopCandidateSchema, + desktopComponentSchema, + desktopProbeSchema, + desktopFamilySchema, + desktopCoverageSchema, + type DesktopApplication, +} from "../desktopApplication.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { parseAnalysisViewResult } from "./analysisViewParent.js"; +import { + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; +import type { + AnalysisViewParent, + AnalysisViewRequest, + UnsignedAnalysisView, +} from "./analysisView.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { ok, err, type Result } from "../result.js"; + +// Bound diagnostic context independently of the retained report size. Previews +// are display values; stable file IDs select the original paths and all issues. +const DIAGNOSTIC_PREVIEW_FILES = 2; +const DIAGNOSTIC_PREVIEW_UNITS = 256; + +const hasProbeIssues = (file: { + readonly issues: readonly string[]; +}): boolean => file.issues.length > 0; + +const diagnosticTextPreview = (value: string) => { + let end = Math.min(value.length, DIAGNOSTIC_PREVIEW_UNITS); + const last = value.charCodeAt(end - 1); + if (end < value.length && last >= 0xd800 && last <= 0xdbff) end--; + return { text: value.slice(0, end), truncated: end < value.length }; +}; + +const page = { + kind: z.literal("desktop-application"), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), +}; +export const desktopViewRequestSchema = z.discriminatedUnion("collection", [ + z.strictObject({ + ...page, + collection: z.literal("files"), + paths: z.array(z.string()).optional(), + file_ids: z.array(desktopFileSchema.shape.file_id).optional(), + has_issues: z.boolean().optional(), + fields: z.array(desktopFileSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("candidates"), + families: z.array(desktopFamilySchema).optional(), + fields: z.array(desktopCandidateSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("omissions"), + paths: z.array(z.string()).optional(), + omission_ids: z.array(desktopOmissionSchema.shape.omission_id).optional(), + fields: z.array(desktopOmissionSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("components"), + fields: z.array(desktopComponentSchema.keyof()).optional(), + }), + z.strictObject({ + ...page, + collection: z.literal("next_probes"), + fields: z.array(desktopProbeSchema.keyof()).optional(), + }), +]); +export const desktopViewItemsSchema = z.array( + z.union([ + desktopFileSchema.partial(), + desktopOmissionSchema.partial(), + desktopCandidateSchema.partial(), + desktopComponentSchema.partial(), + desktopProbeSchema.partial(), + ]), +); +export const desktopSummarySchema = z.strictObject({ + root: desktopApplicationSchema.shape.root, + source_coverage: desktopCoverageSchema, + available_counts: z.strictObject({ + files: z.number().int().nonnegative(), + omissions: z.number().int().nonnegative(), + candidates: z.number().int().nonnegative(), + components: z.number().int().nonnegative(), + next_probes: z.number().int().nonnegative(), + }), + probe_issues: z.strictObject({ + file_count: z.number().int().nonnegative(), + issue_count: z.number().int().nonnegative(), + preview: z + .array( + z.strictObject({ + file_id: desktopFileSchema.shape.file_id, + path_preview: z.string(), + read_status: desktopFileSchema.shape.read_status, + issue_count: z.number().int().positive(), + first_issue_preview: z.string(), + text_truncated: z.boolean(), + }), + ) + .max(DIAGNOSTIC_PREVIEW_FILES), + preview_truncated: z + .boolean() + .describe( + "More retained files have probe issues than fit in the preview.", + ), + }), + candidates: z.array( + desktopCandidateSchema.omit({ basis_file_ids: true }).extend({ + basis_file_count: z.number().int().positive(), + basis_path_preview: z.array(z.string()), + }), + ), + components: z.array( + desktopComponentSchema.omit({ basis_file_ids: true }).extend({ + basis_file_count: z.number().int().positive(), + basis_path_preview: z.array(z.string()), + }), + ), + next_probe_groups: z.array( + z.strictObject({ + operation: desktopProbeSchema.shape.operation, + count: z.number().int().positive(), + example: desktopProbeSchema, + }), + ), +}); + +type DesktopCollection = z.infer["collection"]; +type DesktopViewRow = DesktopApplication[DesktopCollection][number]; + +// Only immutable authenticated parent objects are reused. They are not live filesystem caches. +const validatedParents = new WeakSet(); +export const projectDesktopApplicationView = ( + parent: AnalysisViewParent, + view: AnalysisViewRequest, +): Result => { + if (view.kind !== "summary" && view.kind !== "desktop-application") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Desktop directory Evidence supports summary and desktop-application views.", + }, + ]), + ); + const key = + typeof parent.normalizedResult === "object" && + parent.normalizedResult !== null + ? parent.normalizedResult + : undefined; + const reusable = + parent.sealedResult === true && key !== undefined && Object.isFrozen(key); + let report = + reusable && validatedParents.has(key) + ? (key as DesktopApplication) + : undefined; + if (report === undefined) { + const parsed = parseAnalysisViewResult(desktopApplicationSchema, parent); + if (!parsed.ok) return parsed; + report = parsed.value; + if (reusable) { + // The schema has no coercions or transforms. Borrow the validated frozen + // owner value instead of retaining a second unaccounted copy of its files. + report = key as DesktopApplication; + validatedParents.add(key); + } + } + if ( + parent.subject === undefined || + parent.subject === null || + parent.subject.format !== "directory" || + parent.subject.local_path !== report.root.path || + parent.subject.digest.sha256 !== report.root.snapshot_sha256 + ) + return err( + analysisViewInputError([ + { + path: ["source", "subject"], + reason: "invalid_value", + message: "Parent must identify the same examined directory snapshot.", + }, + ]), + ); + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "inspect_desktop_application" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: { path: report.root.path, sha256: report.root.snapshot_sha256 }, + limitations: [...new Set([...parent.limitations, ...report.limitations])], + unknowns: [ + "Runtime behavior and unread bytes remain unknown.", + ...(report.coverage.status === "partial" + ? [ + "Source inspection is partial; an exhausted page does not establish absence.", + ] + : []), + ], + }; + if (view.kind === "summary") { + const byId = new Map(); + const probeIssues: z.infer["probe_issues"] = { + file_count: 0, + issue_count: 0, + preview: [], + preview_truncated: false, + }; + // Reuse the path-index pass; do not allocate another full file selection or + // traverse every issue string just to form this bounded diagnostic summary. + for (const file of report.files) { + byId.set(file.file_id, file.path); + if (!hasProbeIssues(file)) continue; + probeIssues.file_count++; + probeIssues.issue_count += file.issues.length; + if (probeIssues.preview.length === DIAGNOSTIC_PREVIEW_FILES) continue; + const firstIssue = file.issues[0]; + if (firstIssue === undefined) continue; + const path = diagnosticTextPreview(file.path); + const issue = diagnosticTextPreview(firstIssue); + probeIssues.preview.push({ + file_id: file.file_id, + path_preview: path.text, + read_status: file.read_status, + issue_count: file.issues.length, + first_issue_preview: issue.text, + text_truncated: path.truncated || issue.truncated, + }); + } + probeIssues.preview_truncated = + probeIssues.file_count > probeIssues.preview.length; + const summarizeBasis = (row: T) => { + const { basis_file_ids, ...value } = row; + return { + ...value, + basis_file_count: basis_file_ids.length, + basis_path_preview: basis_file_ids + .slice(0, 2) + .map((id) => byId.get(id) ?? id), + }; + }; + const groups = new Map< + string, + { + operation: DesktopApplication["next_probes"][number]["operation"]; + count: number; + example: DesktopApplication["next_probes"][number]; + } + >(); + for (const probe of report.next_probes) { + const group = groups.get(probe.operation); + if (group === undefined) + groups.set(probe.operation, { + operation: probe.operation, + count: 1, + example: probe, + }); + else group.count++; + } + return ok({ + kind: "summary", + view, + ...shared, + summary: { + root: report.root, + source_coverage: report.coverage, + available_counts: { + files: report.files.length, + omissions: report.omissions.length, + candidates: report.candidates.length, + components: report.components.length, + next_probes: report.next_probes.length, + }, + probe_issues: probeIssues, + candidates: report.candidates.map(summarizeBasis), + components: report.components.map(summarizeBasis), + next_probe_groups: [...groups.values()], + }, + coverage: completeWithinViewCoverage( + report.files.length, + report.files.length, + ), + }); + } + let rows: readonly DesktopViewRow[] = report[view.collection]; + if (view.collection === "files" || view.collection === "omissions") { + const paths = view.paths === undefined ? undefined : new Set(view.paths); + const ids = view.collection === "files" ? view.file_ids : view.omission_ids; + const selected = ids === undefined ? undefined : new Set(ids); + const withIssues = + view.collection === "files" ? view.has_issues : undefined; + if ( + paths !== undefined || + selected !== undefined || + withIssues !== undefined + ) + rows = report[view.collection].filter( + (row) => + (paths === undefined || paths.has(row.path)) && + (selected === undefined || + selected.has("file_id" in row ? row.file_id : row.omission_id)) && + (withIssues === undefined || + ("issues" in row && hasProbeIssues(row) === withIssues)), + ); + } + if (view.collection === "candidates" && view.families !== undefined) { + const selected = new Set(view.families); + rows = report.candidates.filter((row) => selected.has(row.family)); + } + const idField = { + files: "file_id", + omissions: "omission_id", + candidates: "candidate_id", + components: "component_id", + next_probes: "probe_id", + }[view.collection]; + const fields = + view.fields === undefined + ? undefined + : new Set([idField, ...view.fields]); + const items = rows + .slice(view.offset, view.offset + view.limit) + .map((row) => + fields === undefined + ? row + : Object.fromEntries( + Object.entries(row).filter(([field]) => fields.has(field)), + ), + ); + return ok({ + kind: "desktop-application", + view, + ...shared, + source_coverage: report.coverage, + items: desktopViewItemsSchema.parse(items), + coverage: pageViewCoverage(view.offset, items.length, rows.length), + }); +}; diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index da27ad1f72..c4e9397410 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -1,6 +1,19 @@ import { z } from "zod"; import { prefixedDigestSchema } from "./../domain/digests.js"; +/** Entry kinds or subtrees deliberately excluded by an artifact traversal. */ +export const artifactTraversalOmissionKindSchema = z.enum([ + "fifo", + "socket", + "block-device", + "character-device", + "unsupported", + "directory-descendants", +]); +export type ArtifactTraversalOmissionKind = z.infer< + typeof artifactTraversalOmissionKindSchema +>; + const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u); const artifactIdSchema = prefixedDigestSchema("art"); const occurrenceIdSchema = prefixedDigestSchema("occ"); diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 8f47fa56ee..8432ce6325 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -11,6 +11,7 @@ export class ArtifactOperationError extends AnalysisError { readonly operation: | "inventory_artifact" | "inspect_artifact" + | "inspect_desktop_application" | "inspect_pe_resources" | "extract_artifact" | "decode_interface_builder" diff --git a/src/domain/desktopApplication.ts b/src/domain/desktopApplication.ts new file mode 100644 index 0000000000..3f3960b1f7 --- /dev/null +++ b/src/domain/desktopApplication.ts @@ -0,0 +1,189 @@ +import { z } from "zod"; +import { digestSchema, prefixedDigestSchema } from "./digests.js"; +import { localPathStringSchema } from "./localPath.js"; +import { artifactTraversalOmissionKindSchema } from "./artifactGraph.js"; + +export const desktopFamilySchema = z.enum([ + "dotnet", + "unity-mono", + "unity-il2cpp", + "unreal", + "godot", + "gamemaker", + "electron", +]); +export const inspectDesktopApplicationInputSchema = z.strictObject({ + path: localPathStringSchema, + max_entries: z.number().int().positive().max(100_000).default(20_000), + max_probe_bytes: z + .number() + .int() + .min(4096) + .max(64 * 1024 * 1024) + .default(8 * 1024 * 1024), +}); +export type InspectDesktopApplicationInput = z.infer< + typeof inspectDesktopApplicationInputSchema +>; + +const byteOffset = z.number().int().nonnegative(); +const fact = ( + kind: Kind, + value: Value, +) => z.strictObject({ kind: z.literal(kind), offset: byteOffset, value }); +const desktopFactSchema = z.discriminatedUnion("kind", [ + fact( + "pe-cli-directory", + z.strictObject({ rva: byteOffset, bytes: byteOffset }), + ), + fact( + "unity-assembly-reference", + z.strictObject({ + name: z.string(), + token: z.string(), + managed_directory: z.string(), + }), + ), + fact( + "native-image-header", + z.union([ + z.strictObject({ format: z.literal("pe"), machine: byteOffset }), + z.strictObject({ + format: z.enum(["elf", "mach-o"]), + signature: z.string(), + }), + ]), + ), + fact( + "il2cpp-metadata-header", + z.strictObject({ magic: z.literal("0xfab11baf"), raw_version: byteOffset }), + ), + fact( + "godot-pck-header", + z.strictObject({ + pack_version: byteOffset, + engine_version: z.array(byteOffset).length(3), + placement: z.enum(["standalone", "embedded-trailer"]), + }), + ), + fact( + "gamemaker-form-header", + z.strictObject({ + container: z.literal("FORM"), + first_chunk: z.literal("GEN8"), + }), + ), + z.strictObject({ + kind: z.literal("il2cpp-library-convention"), + offset: z.null(), + value: z.string(), + }), + z.strictObject({ + kind: z.literal("electron-asar-convention"), + offset: z.null(), + value: z.string(), + }), + z.strictObject({ + kind: z.literal("unreal-container-convention"), + offset: byteOffset.nullable(), + value: z.union([ + z.string(), + z.strictObject({ format: z.literal("IoStore TOC") }), + ]), + }), +]); +export type DesktopFact = z.infer; + +export const desktopFileSchema = z.strictObject({ + file_id: prefixedDigestSchema("dsf"), + path: z.string(), + local_path: localPathStringSchema, + kind: z.enum(["file", "directory", "symlink"]), + bytes: z.number().int().nonnegative().nullable(), + identity: z + .strictObject({ + device: z.string(), + inode: z.string(), + size: z.string(), + mode: z.string(), + mtime_ns: z.string(), + ctime_ns: z.string(), + }) + .nullable(), + read_status: z.enum(["not-selected", "examined", "partial", "unavailable"]), + ranges: z.array( + z.strictObject({ + offset: z.number().int().nonnegative(), + bytes: z.number().int().nonnegative(), + sha256: digestSchema, + }), + ), + facts: z.array(desktopFactSchema), + issues: z.array(z.string()), +}); +export type DesktopFile = z.infer; +export const desktopOmissionSchema = z.strictObject({ + omission_id: prefixedDigestSchema("dso"), + path: z.string(), + kind: artifactTraversalOmissionKindSchema, +}); +export type DesktopOmission = z.infer; +export const desktopCandidateSchema = z.strictObject({ + candidate_id: prefixedDigestSchema("dsc"), + family: desktopFamilySchema, + confidence: z.enum(["derived", "inferred"]), + basis_file_ids: z.array(prefixedDigestSchema("dsf")).min(1), + rationale: z.string(), +}); +export const desktopComponentSchema = z.strictObject({ + component_id: prefixedDigestSchema("dsm"), + name: z.string(), + category: z.enum(["mod-loader", "protection"]), + basis_file_ids: z.array(prefixedDigestSchema("dsf")).min(1), + confidence: z.literal("inferred"), + runtime_presence: z.literal("unknown"), +}); +export const desktopProbeSchema = z.strictObject({ + probe_id: prefixedDigestSchema("dsp"), + operation: z.enum([ + "inspect_managed_artifact", + "open_binary", + "analyze_javascript_application", + ]), + path: localPathStringSchema, + file_id: prefixedDigestSchema("dsf"), + rationale: z.string(), + confidence: z.literal("inferred"), +}); +export const desktopCoverageSchema = z.strictObject({ + status: z.enum(["complete-within-scan", "partial"]), + entries_examined: z.number().int().nonnegative(), + files_probed: z.number().int().nonnegative(), + bytes_read: z.number().int().nonnegative(), + symlinks_not_followed: z.number().int().nonnegative(), + special_entries_omitted: z.number().int().nonnegative(), + limits: z.strictObject({ + max_entries: + inspectDesktopApplicationInputSchema.shape.max_entries.removeDefault(), + max_probe_bytes: + inspectDesktopApplicationInputSchema.shape.max_probe_bytes.removeDefault(), + }), +}); +export const desktopApplicationSchema = z.strictObject({ + root: z.strictObject({ + path: localPathStringSchema, + canonical_path: localPathStringSchema, + namespace_id: prefixedDigestSchema("dsr"), + identity: z.strictObject({ device: z.string(), inode: z.string() }), + snapshot_sha256: digestSchema, + identity_scope: z.literal("examined-metadata-and-read-ranges"), + }), + files: z.array(desktopFileSchema), + omissions: z.array(desktopOmissionSchema), + candidates: z.array(desktopCandidateSchema), + components: z.array(desktopComponentSchema), + next_probes: z.array(desktopProbeSchema), + coverage: desktopCoverageSchema, + limitations: z.array(z.string()), +}); +export type DesktopApplication = z.infer; diff --git a/src/domain/desktopApplicationEvidence.ts b/src/domain/desktopApplicationEvidence.ts new file mode 100644 index 0000000000..e823753697 --- /dev/null +++ b/src/domain/desktopApplicationEvidence.ts @@ -0,0 +1,33 @@ +import { + createEvidence, + type Evidence, + type EvidenceProvider, +} from "./evidence.js"; +import type { + DesktopApplication, + InspectDesktopApplicationInput, +} from "./desktopApplication.js"; + +/** One Evidence interpretation for complete and interrupted directory observations. */ +export const createDesktopApplicationEvidence = ( + input: InspectDesktopApplicationInput, + report: DesktopApplication, + provider: EvidenceProvider, +): Evidence => + createEvidence( + { + path: report.root.path, + format: "directory", + sha256: report.root.snapshot_sha256, + }, + provider, + { + operation: "inspect_desktop_application", + parameters: input, + result: report, + rawResult: null, + confidence: "observed", + limitations: report.limitations, + locations: [{ kind: "artifact-path", path: input.path }], + }, + ); diff --git a/src/domain/desktopApplicationProjection.ts b/src/domain/desktopApplicationProjection.ts new file mode 100644 index 0000000000..42e4d228c1 --- /dev/null +++ b/src/domain/desktopApplicationProjection.ts @@ -0,0 +1,159 @@ +import { digestCanonicalValue } from "./canonicalDigest.js"; +import type { DesktopApplication, DesktopFile } from "./desktopApplication.js"; + +/** Interpret only the facts admitted by the directory reader, never target bytes. */ +export const projectDesktopApplication = ( + files: readonly DesktopFile[], +): Pick => { + const candidates: DesktopApplication["candidates"] = []; + const components: DesktopApplication["components"] = []; + const probes = new Map(); + const has = (file: DesktopFile, kind: string) => + file.facts.some((fact) => fact.kind === kind); + const candidate = ( + family: DesktopApplication["candidates"][number]["family"], + basis: readonly DesktopFile[], + rationale: string, + confidence: "derived" | "inferred" = "derived", + ) => { + if (basis.length === 0) return; + const semantic = { + family, + basis_file_ids: [...new Set(basis.map((file) => file.file_id))].sort(), + rationale, + confidence, + }; + candidates.push({ + candidate_id: `dsc_${digestCanonicalValue(semantic, "Desktop runtime candidate")}`, + ...semantic, + }); + }; + const probe = ( + operation: DesktopApplication["next_probes"][number]["operation"], + file: DesktopFile, + rationale: string, + ) => { + const semantic = { + operation, + path: file.local_path, + file_id: file.file_id, + rationale, + confidence: "inferred" as const, + }; + const probe_id = `dsp_${digestCanonicalValue(semantic, "Desktop next probe")}`; + probes.set(probe_id, { probe_id, ...semantic }); + }; + const managed = files.filter((file) => has(file, "pe-cli-directory")); + candidate( + "dotnet", + managed, + "PE optional headers declare nonempty CLI directories; CLR execution and metadata validity remain unverified.", + ); + for (const file of managed) + probe( + "inspect_managed_artifact", + file, + "Inspect the declared managed metadata and references.", + ); + const mono = managed.filter((file) => has(file, "unity-assembly-reference")); + candidate( + "unity-mono", + mono, + "Managed assemblies in a Unity Data/Managed layout reference UnityEngine assemblies.", + ); + const metadata = files.filter((file) => has(file, "il2cpp-metadata-header")); + const il2cpp = files.filter( + (file) => + has(file, "il2cpp-library-convention") && + has(file, "native-image-header"), + ); + if (metadata.length > 0 && il2cpp.length > 0) { + candidate( + "unity-il2cpp", + [...metadata, ...il2cpp], + "IL2CPP metadata headers and conventionally named native libraries coexist; their build correspondence is unverified.", + "inferred", + ); + for (const file of il2cpp) + probe( + "open_binary", + file, + "Inspect this native IL2CPP candidate; metadata-to-address correspondence remains unknown.", + ); + } + candidate( + "godot", + files.filter((file) => has(file, "godot-pck-header")), + "A standalone or trailer-located Godot pack header was observed; pack contents and encryption are unexamined.", + ); + candidate( + "gamemaker", + files.filter((file) => has(file, "gamemaker-form-header")), + "A FORM container begins with a GEN8 chunk; its game resources remain unexamined.", + ); + candidate( + "unreal", + files.filter((file) => has(file, "unreal-container-convention")), + "Unreal Content/Paks paths or an IoStore TOC header suggest an Unreal package layout.", + "inferred", + ); + const electron = files.filter((file) => + has(file, "electron-asar-convention"), + ); + candidate( + "electron", + electron, + "An app.asar in a resources directory suggests an Electron application; archive validity and execution are unverified.", + "inferred", + ); + for (const file of electron) { + probe( + "open_binary", + file, + "Select this ASAR target with open_binary, then call inspect_artifact to inventory it before selecting JavaScript sources.", + ); + probe( + "analyze_javascript_application", + file, + "Analyze the candidate application archive with the static JavaScript workflow.", + ); + } + const componentMarkers = [ + { + expression: /(?:^|\/)bepinex(?:\/|$)/iu, + name: "BepInEx", + category: "mod-loader" as const, + }, + { + expression: /(?:^|\/)melonloader(?:\/|$)/iu, + name: "MelonLoader", + category: "mod-loader" as const, + }, + { + expression: /(?:^|\/)easyanticheat(?:_eos)?(?:\/|$)/iu, + name: "Easy Anti-Cheat", + category: "protection" as const, + }, + { + expression: /(?:^|\/)battleye(?:\/|$)/iu, + name: "BattlEye", + category: "protection" as const, + }, + ]; + for (const marker of componentMarkers) { + const basis = files.filter((file) => marker.expression.test(file.path)); + if (basis.length === 0) continue; + const semantic = { + name: marker.name, + category: marker.category, + basis_file_ids: basis.map((file) => file.file_id), + confidence: "inferred" as const, + runtime_presence: "unknown" as const, + }; + components.push({ + component_id: `dsm_${digestCanonicalValue(semantic, "Desktop component marker")}`, + ...semantic, + }); + } + return { candidates, components, next_probes: [...probes.values()] }; +}; diff --git a/src/domain/nativeConvention.ts b/src/domain/nativeConvention.ts index 8e6d5b6245..b5724b1dd3 100644 --- a/src/domain/nativeConvention.ts +++ b/src/domain/nativeConvention.ts @@ -16,6 +16,12 @@ const REACT_NATIVE_SEGMENTS = new Set([ ]); const FLUTTER_SEGMENTS = new Set(["libflutter.so", "flutter.framework"]); const UNITY_SEGMENTS = new Set(["libunity.so", "unityframework.framework"]); +const DESKTOP_IL2CPP_LIBRARIES = new Set([ + "gameassembly.dll", + "gameassembly.so", + "gameassembly.dylib", + "libil2cpp.so", +]); /** * A runtime family named by one filename or directory segment. @@ -38,3 +44,40 @@ export const isSwiftRuntimeLibrary = (path: string): boolean => path .split("/") .some((segment) => /^libswift.+\.dylib$/u.test(foldSegment(segment))); + +/** Parse desktop layout conventions once while preserving original path segments. */ +export const desktopPathConventions = (path: string) => { + const segments = path.split("/"); + const folded = segments.map(foldSegment); + let unityManagedDirectory: string | null = null; + for (let index = 0; index + 2 < folded.length; index++) { + const segment = folded[index]; + const namedDataDirectory = + segment !== undefined && segment.length > 5 && segment.endsWith("_data"); + const bundleDataDirectory = + segment === "data" && + folded[index - 1] === "resources" && + folded[index - 2] === "contents"; + if ( + (namedDataDirectory || bundleDataDirectory) && + folded[index + 1] === "managed" + ) + unityManagedDirectory = segments.slice(0, index + 2).join("/"); + } + const name = folded.at(-1) ?? ""; + return { + unityManagedDirectory, + il2cppLibrary: DESKTOP_IL2CPP_LIBRARIES.has(name), + electronArchive: + folded.at(-2) === "resources" && + (name === "app.asar" || name === "default_app.asar"), + unrealContainer: + (name.endsWith(".pak") || name.endsWith(".utoc")) && + folded.some( + (segment, index) => + segment === "content" && + folded[index + 1] === "paks" && + index + 2 < folded.length, + ), + }; +}; diff --git a/src/dotnet/ManagedMetadataInventoryRows.ts b/src/dotnet/ManagedMetadataInventoryRows.ts index 3eb54cf50b..38d2df94b5 100644 --- a/src/dotnet/ManagedMetadataInventoryRows.ts +++ b/src/dotnet/ManagedMetadataInventoryRows.ts @@ -150,12 +150,12 @@ export const readAssembly = ( }; }; -export const readAssemblyReference = ( +/** One owner for the shared AssemblyRef prefix and column indexes. */ +const assemblyReferenceHeader = ( bytes: Buffer, layout: ManagedMetadataLayout, row: number, - heapExtent: number, -): AssemblyReference => { +) => { const cursor = metadataRowCursor(bytes, layout, 35, row); const version = [ cursor.readUInt16(), @@ -163,19 +163,40 @@ export const readAssemblyReference = ( cursor.readUInt16(), cursor.readUInt16(), ].join("."); - const flags = cursor.readUInt32(); - const keyOrToken = readMetadataBlob( - bytes, - layout, - cursor.readIndex(layout.blobIndexSize), - heapExtent, - ); - const name = readMetadataString( - bytes, - layout, - cursor.readIndex(layout.stringIndexSize), - heapExtent, - ); + return { + cursor, + version, + flags: cursor.readUInt32(), + keyIndex: cursor.readIndex(layout.blobIndexSize), + nameIndex: cursor.readIndex(layout.stringIndexSize), + }; +}; + +/** Name/token facts do not require decoding or hashing unrelated key/hash payloads. */ +export const readAssemblyReferenceName = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + row: number, + heapExtent: number, +): Pick => { + const { nameIndex } = assemblyReferenceHeader(bytes, layout, row); + return { + name: readMetadataString(bytes, layout, nameIndex, heapExtent), + token: metadataToken(35, row), + row_offset: metadataRowOffset(layout, 35, row), + }; +}; + +export const readAssemblyReference = ( + bytes: Buffer, + layout: ManagedMetadataLayout, + row: number, + heapExtent: number, +): AssemblyReference => { + const { cursor, version, flags, keyIndex, nameIndex } = + assemblyReferenceHeader(bytes, layout, row); + const keyOrToken = readMetadataBlob(bytes, layout, keyIndex, heapExtent); + const name = readMetadataString(bytes, layout, nameIndex, heapExtent); const culture = readMetadataString( bytes, layout, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 5b2d4a7f01..d5c355d3b0 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -21,6 +21,8 @@ import { createSqliteDatabaseService } from "../composition/sqlite.js"; import { registerSqliteTools } from "./registerSqliteTools.js"; import { registerRecordedCrashTools } from "./registerRecordedCrashTools.js"; import { createRecordedCrashService } from "../composition/binaryDiagnostics.js"; +import { createDesktopApplicationService } from "../composition/desktop.js"; +import { registerDesktopApplicationTool } from "./registerDesktopApplicationTool.js"; import { createGoBinaryService } from "../composition/go.js"; import type { GoBinaryService } from "../application/go/GoBinaryService.js"; import { createElectronFuseService } from "../composition/electronFuses.js"; @@ -306,6 +308,7 @@ export const createServer = ( options.webSourceLocation ?? createWebSourceLocationService(environment); const webModuleTrace = options.webModuleTrace ?? createWebModuleTraceService(environment); + const desktopApplication = createDesktopApplicationService(); const goBinary = options.goBinary ?? createGoBinaryService(); const rustBinary = options.rustBinary ?? createRustBinaryService(); const peResources = createPeResourcesService(); @@ -389,6 +392,7 @@ export const createServer = ( recordedCrash.close(), webSourceLocation.close(), webModuleTrace.close(), + desktopApplication.close(), goBinary.close(), rustBinary.close(), peResources.close(), @@ -457,6 +461,14 @@ export const createServer = ( electronFuses, webNetworkCapture, }; + registerDesktopApplicationTool( + server, + desktopApplication, + toolLogger, + recordEvidence, + analysisAdmission, + evidenceById, + ); registerBinaryAnalysisTools(toolContext); registerReverseEngineeringTools( server, diff --git a/src/server/registerDesktopApplicationTool.ts b/src/server/registerDesktopApplicationTool.ts new file mode 100644 index 0000000000..e3bd5e3438 --- /dev/null +++ b/src/server/registerDesktopApplicationTool.ts @@ -0,0 +1,55 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { DesktopApplicationService } from "../application/artifacts/DesktopApplicationService.js"; +import type { Logger } from "pino"; +import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { + requireAnalysisViewRetention, + summarizeRetainedAnalysis, +} from "../application/analysisView/AnalysisViewService.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { err } from "../domain/result.js"; + +/** Bind the named contract and refuse unretained summary delivery before I/O. */ +export const registerDesktopApplicationTool = ( + server: EvidenceMcpServer, + service: DesktopApplicationService, + logger: Logger, + recordEvidence: EvidenceWriter["recordEvidence"] | undefined, + withAdmittedAnalysis: WithAdmittedAnalysis, + evidenceById: EvidenceLookup | undefined, +): void => { + const contract = toolContract("inspect_desktop_application"); + const retention = { recordEvidence, evidenceById }; + const handler = createEvidenceToolHandler( + server, + async (raw, options) => { + const parsed = contract.inputSchema.safeParse(raw); + if (!parsed.success) + return err( + analysisInputErrorFromIssues(contract.name, parsed.error.issues, raw), + ); + const { detail, ...input } = parsed.data; + if (detail === "summary") { + const ready = requireAnalysisViewRetention(contract.name, retention); + if (!ready.ok) return ready; + } + const result = await service.inspect(input, options); + return result.ok && detail === "summary" + ? summarizeRetainedAnalysis(result.value, retention) + : result; + }, + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/boundary/cli/desktopApplicationCli.test.ts b/tests/boundary/cli/desktopApplicationCli.test.ts new file mode 100644 index 0000000000..c0f4a6e3e8 --- /dev/null +++ b/tests/boundary/cli/desktopApplicationCli.test.ts @@ -0,0 +1,111 @@ +import { readEvidenceBundle } from "../../../src/application/EvidenceBundleFiles.js"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createDesktopApplicationService } from "../../../src/composition/desktop.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; + +cliTest( + "shares directory facts and exact retained-field semantics with the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-desktop-cli-"); + const installation = join(root, "installation"); + await mkdir(join(installation, "resources"), { recursive: true }); + await writeFile( + join(installation, "resources/app.asar"), + "path convention", + ); + const refused = await cli.run({ + arguments: [ + "inspect-desktop-application", + join(root, "must-not-be-read"), + "--detail", + "summary", + "--json", + ], + }); + expect(refused.exitCode).not.toBe(0); + expect(refused.json).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { + operation: "inspect_desktop_application", + issues: [{ path: ["evidence_output"] }], + }, + }); + const response = await cli.run({ + arguments: ["inspect-desktop-application", installation, "--json"], + }); + expect(response.exitCode).toBe(0); + const evidence = parseEvidence(response.json); + const service = createDesktopApplicationService(); + try { + const source = await service.inspect({ path: installation }); + if (!source.ok) throw source.error; + expect(evidence).toEqual(source.value); + } finally { + await service.close(); + } + const query = join(root, "view.json"); + await writeFile( + query, + JSON.stringify({ + source: { kind: "inline", evidence }, + view: { + kind: "desktop-application", + collection: "candidates", + families: ["electron"], + fields: ["family"], + limit: 1, + }, + }), + ); + const page = await cli.run({ + arguments: ["inspect-analysis-view", query, "--json"], + }); + expect(page.exitCode).toBe(0); + expect(parseEvidence(page.json).normalized_result).toMatchObject({ + items: [{ candidate_id: expect.any(String), family: "electron" }], + coverage: { total: 1, exhausted: true }, + }); + const bundlePath = join(root, "complete-evidence.json"); + const compact = await cli.run({ + arguments: [ + "inspect-desktop-application", + installation, + "--detail", + "summary", + "--evidence_output", + bundlePath, + "--json", + ], + }); + expect(compact.exitCode).toBe(0); + expect(compact.json).toMatchObject({ + evidence_bundle: { path: bundlePath, bytes: expect.any(Number) }, + summary: { + normalized_result: { + kind: "summary", + parent_evidence_id: evidence.evidence_id, + }, + }, + }); + const saved = await readEvidenceBundle(bundlePath); + if (!saved.ok) throw saved.error; + expect(saved.value.records).toEqual([evidence]); + const overwrite = await cli.run({ + arguments: [ + "inspect-desktop-application", + installation, + "--detail", + "summary", + "--evidence_output", + bundlePath, + "--json", + ], + }); + expect(overwrite.exitCode).not.toBe(0); + }, +); diff --git a/tests/boundary/filesystem/artifactInventorySnapshot.test.ts b/tests/boundary/filesystem/artifactInventorySnapshot.test.ts index daf49215dd..a5c470cec3 100644 --- a/tests/boundary/filesystem/artifactInventorySnapshot.test.ts +++ b/tests/boundary/filesystem/artifactInventorySnapshot.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it, onTestFinished, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { translateArtifactFailure } from "../../../src/artifacts/ArtifactProvider.js"; +import { translateArtifactFailure } from "../../../src/artifacts/ArtifactOperationFailure.js"; import { ArtifactReaderFailure, type ArtifactEntry, diff --git a/tests/boundary/filesystem/desktopApplication.test.ts b/tests/boundary/filesystem/desktopApplication.test.ts new file mode 100644 index 0000000000..929a9ca474 --- /dev/null +++ b/tests/boundary/filesystem/desktopApplication.test.ts @@ -0,0 +1,1172 @@ +import { + mkdir, + writeFile, + symlink, + open, + rename, + realpath, + unlink, +} from "node:fs/promises"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { createServer as createNetServer } from "node:net"; +import { expect, it, onTestFinished, vi } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createDesktopApplicationService } from "../../../src/composition/desktop.js"; +import { desktopApplicationSchema } from "../../../src/domain/desktopApplication.js"; +import { createDesktopApplicationEvidence } from "../../../src/domain/desktopApplicationEvidence.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js"; +import { + buildManagedPeFixture, + buildNativePeFixture, +} from "../../../src/dotnet/ManagedPe.fixture.js"; +import { readManagedMetadataLayout } from "../../../src/dotnet/ManagedMetadataLayout.js"; +import { readAssemblyReference } from "../../../src/dotnet/ManagedMetadataInventoryRows.js"; +import { + CPU, + FILE_TYPE, + fatImage, + machoImage, +} from "../../../src/artifacts/apple/MachoImage.fixture.js"; +import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; +import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; +import { DesktopApplicationReader } from "../../../src/artifacts/DesktopApplicationReader.js"; +import { DesktopApplicationService } from "../../../src/application/artifacts/DesktopApplicationService.js"; + +const inspect = async (path: string, extra: Record = {}) => { + const service = createDesktopApplicationService(); + try { + const result = await service.inspect({ path, ...extra }); + if (!result.ok) throw result.error; + return { + evidence: parseEvidence(result.value), + report: desktopApplicationSchema.parse(result.value.normalized_result), + }; + } finally { + await service.close(); + } +}; +const put = async (root: string, path: string, bytes: Buffer | string) => { + const destination = join(root, path); + await mkdir(join(destination, ".."), { recursive: true }); + await writeFile(destination, bytes); +}; + +it("joins real PE/CLI reference facts and independent engine markers without picking a winner", async () => { + const root = await createTestTempDirectory("rea-desktop-mixed-"); + await put( + root, + "Game_Data/Managed/Assembly-CSharp.dll", + buildManagedPeFixture({ references: ["UnityEngine.CoreModule"] }), + ); + await put(root, "GameAssembly.dll", buildNativePeFixture()); + const metadata = Buffer.alloc(8); + metadata.writeUInt32LE(0xfab11baf); + metadata.writeUInt32LE(31, 4); + await put( + root, + "Game_Data/il2cpp_data/Metadata/global-metadata.dat", + metadata, + ); + const godot = Buffer.alloc(20); + godot.write("GDPC"); + godot.writeUInt32LE(3, 4); + godot.writeUInt32LE(4, 8); + await put(root, "game.pck", godot); + const form = Buffer.alloc(16); + form.write("FORM"); + form.writeUInt32LE(8, 4); + form.write("GEN8", 8); + await put(root, "data.win", form); + await put( + root, + "resources/app.asar", + "not an archive; this is only a path convention", + ); + await put(root, "Content/Paks/game.pak", "path convention only"); + await mkdir(join(root, "BepInEx")); + await mkdir(join(root, "EasyAntiCheat")); + const { report, evidence } = await inspect(root); + expect(report.candidates.map((row) => row.family).sort()).toEqual( + [ + "dotnet", + "electron", + "gamemaker", + "godot", + "unity-il2cpp", + "unity-mono", + "unreal", + ].sort(), + ); + expect( + report.components.map((row) => [row.name, row.runtime_presence]), + ).toEqual([ + ["BepInEx", "unknown"], + ["Easy Anti-Cheat", "unknown"], + ]); + expect(report.coverage.status).toBe("complete-within-scan"); + const files = new Map(report.files.map((file) => [file.file_id, file])); + for (const row of report.candidates) + for (const id of row.basis_file_ids) expect(files.has(id)).toBe(true); + for (const probe of report.next_probes) + expect(probe.path).toBe( + join(await realpath(root), files.get(probe.file_id)!.path), + ); + const summary = inspectAnalysisView({ + source: { kind: "inline", evidence }, + view: { kind: "summary" }, + }); + if (!summary.ok) throw summary.error; + expect(JSON.stringify(summary.value)).not.toContain('"files":['); + const selected = inspectAnalysisView({ + source: { kind: "inline", evidence }, + view: { + kind: "desktop-application", + collection: "files", + paths: ["Game_Data/Managed/Assembly-CSharp.dll"], + fields: ["path", "facts"], + limit: 1, + }, + }); + if (!selected.ok) throw selected.error; + expect(selected.value.evidence_links).toEqual([evidence.evidence_id]); + expect(selected.value.normalized_result).toMatchObject({ + items: [ + { + file_id: expect.any(String), + path: "Game_Data/Managed/Assembly-CSharp.dll", + facts: expect.arrayContaining([ + { + kind: "unity-assembly-reference", + offset: expect.any(Number), + value: { + name: "UnityEngine.CoreModule", + token: expect.any(String), + managed_directory: "Game_Data/Managed", + }, + }, + ]), + }, + ], + coverage: { total: 1, exhausted: true }, + }); +}); + +it("reads sparse embedded PCK ranges without hashing or retaining a large executable", async () => { + const root = await createTestTempDirectory("rea-desktop-sparse-"); + const path = join(root, "game.exe"); + const file = await open(path, "w"); + const header = buildNativePeFixture(); + const size = 1024 * 1024 * 1024; + const pck = Buffer.alloc(20); + pck.write("GDPC"); + pck.writeUInt32LE(3, 4); + pck.writeUInt32LE(4, 8); + const trailer = Buffer.alloc(12); + trailer.writeBigUInt64LE(BigInt(pck.length)); + trailer.write("GDPC", 8); + await file.write(header, 0, header.length, 0); + await file.write(pck, 0, pck.length, size - 32); + await file.write(trailer, 0, 12, size - 12); + await file.close(); + const { report } = await inspect(root); + expect(report.candidates.map((row) => row.family)).toContain("godot"); + expect(report.coverage.bytes_read).toBeLessThan(4096); + expect(report.files[0]?.bytes).toBe(size); + expect(report.files[0]?.facts).toContainEqual( + expect.objectContaining({ kind: "godot-pck-header", offset: size - 32 }), + ); +}); + +it("does not promote filenames, arbitrary pak files or symlink targets into byte facts", async () => { + const root = await createTestTempDirectory("rea-desktop-unknown-"); + await put(root, "GameAssembly.dll", "x"); + await put(root, "global-metadata.dat", ""); + await put(root, "game.pck", "GDPC"); + await put(root, "resources.pak", "not Unreal"); + await symlink("/outside/nonexistent", join(root, "foreign")); + const { report } = await inspect(root); + expect(report.candidates).toEqual([]); + expect(report.coverage.symlinks_not_followed).toBe(1); + expect( + report.files.find((file) => file.path === "foreign")?.read_status, + ).toBe("not-selected"); +}); + +it("retains bounded coverage and stable pages without claiming absence after a traversal limit", async () => { + const root = await createTestTempDirectory("rea-desktop-limit-"); + for (let i = 0; i < 4; i++) await put(root, `${i}.txt`, "ordinary data"); + const { report, evidence } = await inspect(root, { max_entries: 2 }); + expect(report.coverage).toMatchObject({ + status: "partial", + entries_examined: 2, + }); + expect(report.files).toHaveLength(2); + const result = inspectAnalysisView({ + source: { kind: "inline", evidence }, + view: { + kind: "desktop-application", + collection: "files", + paths: [], + fields: ["path"], + limit: 1, + }, + }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + items: [], + source_coverage: { status: "partial" }, + coverage: { total: 0, exhausted: true }, + unknowns: expect.arrayContaining([expect.stringContaining("partial")]), + }); +}); + +it("preserves caller cancellation and refuses a replaced selected entry", async () => { + const root = await createTestTempDirectory("rea-desktop-identity-"); + await put(root, "entry", "original"); + const reader = new DirectoryArtifactReader(root); + const values = []; + for await (const row of reader.entries()) values.push(row); + const entry = values[0]!; + await expect( + reader.inspectFile(entry, async () => { + await rename(join(root, "entry"), join(root, "old")); + await put(root, "entry", "replacement"); + return "untrusted"; + }), + ).rejects.toMatchObject({ reason: "integrity" }); + await reader.close(); + const service = createDesktopApplicationService(); + const reason = new Error("caller cancellation"); + const result = await service.inspect( + { path: root }, + { signal: AbortSignal.abort(reason) }, + ); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + }); + await service.close(); +}); + +it("retains an entry whose close failed and observes cancellation after final close", async () => { + const root = await createTestTempDirectory("rea-desktop-close-"); + await put(root, "entry", "bytes"); + const reader = new DirectoryArtifactReader(root); + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + let restore: (() => void) | undefined; + await expect( + reader.inspectFile(entries[0]!, async (file) => { + const close = file.close.bind(file); + file.close = () => Promise.reject(new Error("close denied")); + restore = () => { + file.close = close; + }; + return "observed"; + }), + ).rejects.toBeInstanceOf(ArtifactReaderFailure); + await expect(reader.close()).rejects.toMatchObject({ + cleanup: expect.any(Object), + }); + restore?.(); + await reader.close(); + const cancelled = new DirectoryArtifactReader(root); + const controller = new AbortController(); + const reason = new Error("late cancellation"); + await expect( + cancelled.inspectFile( + entries[0]!, + async (file) => { + const close = file.close.bind(file); + file.close = async () => { + await close(); + controller.abort(reason); + }; + return "completed bytes"; + }, + controller.signal, + ), + ).rejects.toMatchObject({ reason: "cancelled", cause: reason }); + await cancelled.close(); +}); + +it.each(["Game_Data/Managed", "Contents/Resources/Data/Managed"])( + "requires a Unity reference in %s and reports a metadata budget refusal as partial", + async (managedDirectory) => { + const root = await createTestTempDirectory("rea-desktop-mono-boundary-"); + await put( + root, + `${managedDirectory}/ordinary.dll`, + buildManagedPeFixture({ references: ["System.Runtime"] }), + ); + const ordinary = await inspect(root); + expect(ordinary.report.candidates.map((row) => row.family)).toEqual([ + "dotnet", + ]); + await put( + root, + `${managedDirectory}/unity.dll`, + buildManagedPeFixture({ + references: ["UnityEngine.CoreModule", "System.Runtime"], + additionalEmptyTypes: 5000, + }), + ); + const bounded = await inspect(root, { max_probe_bytes: 4096 }); + expect(bounded.report.coverage.status).toBe("partial"); + expect(bounded.report.coverage.bytes_read).toBeLessThanOrEqual(4096); + expect(bounded.report.candidates.map((row) => row.family)).not.toContain( + "unity-mono", + ); + expect( + bounded.report.files.some((file) => + file.issues.some((issue) => issue.includes("budget")), + ), + ).toBe(true); + }, +); + +it("retains complete observations through cancellation during directory finalization", async () => { + const root = await createTestTempDirectory("rea-desktop-finalize-cancel-"); + await put(root, "notes.txt", "target content"); + const controller = new AbortController(); + const reason = new Error("cancel during final close"); + const service = new DesktopApplicationService( + new DesktopApplicationReader((path) => { + const reader = new DirectoryArtifactReader(path); + const close = reader.close.bind(reader); + reader.close = async () => { + await close(); + controller.abort(reason); + }; + return reader; + }), + ); + const result = await service.inspect( + { path: root }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toMatchObject({ reason: "cancelled", cause: reason }); + const retained = parseEvidence(result.error.partialObservation); + expect( + desktopApplicationSchema.parse(retained.normalized_result).files, + ).toHaveLength(1); + await service.close(); +}); + +it("keeps a completed directory report and exact cleanup authority until retry confirms release", async () => { + const root = await createTestTempDirectory("rea-desktop-finalize-retry-"); + await put(root, "notes.txt", "target content"); + let denied = true; + const service = new DesktopApplicationService( + new DesktopApplicationReader((path) => { + const reader = new DirectoryArtifactReader(path); + const close = reader.close.bind(reader); + reader.close = async () => { + if (denied) throw new Error("owned close denied"); + await close(); + }; + return reader; + }), + ); + const result = await service.inspect({ path: root }); + if (result.ok) throw new Error("Expected incomplete cleanup"); + expect(result.error.cleanupIncomplete).toBe(true); + expect( + desktopApplicationSchema.parse( + parseEvidence(result.error.partialObservation).normalized_result, + ).files, + ).toHaveLength(1); + await expect(service.close()).rejects.toBeInstanceOf(ArtifactReaderFailure); + expect(result.error.cleanupIncomplete).toBe(true); + denied = false; + await service.close(); + expect(result.error.cleanupIncomplete).toBe(false); +}); + +it("bounds simultaneous directory scans without cancelling the admitted caller", async () => { + const root = await createTestTempDirectory("rea-desktop-capacity-"); + await put(root, "notes.txt", "observed"); + let release!: () => void; + let entered!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + const service = new DesktopApplicationService( + new DesktopApplicationReader((path) => { + const reader = new DirectoryArtifactReader(path); + const entries = reader.entries.bind(reader); + reader.entries = async function* (...args: Parameters) { + entered(); + await barrier; + yield* entries(...args); + }; + return reader; + }), + ); + const first = service.inspect({ path: root }); + try { + await started; + const second = await service.inspect({ path: root }); + expect(second.ok).toBe(false); + if (!second.ok) + expect(second.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "capacity", + retryable: true, + }); + } finally { + release(); + } + const result = await first; + if (!result.ok) throw result.error; + expect( + desktopApplicationSchema.parse(result.value.normalized_result).files, + ).toHaveLength(1); + await service.close(); +}); + +it("keeps directory-only marker identities separate across installation roots", async () => { + const root = await createTestTempDirectory("rea-desktop-namespaces-"); + const left = join(root, "left"); + const right = join(root, "right"); + await mkdir(join(left, "BepInEx"), { recursive: true }); + await mkdir(join(right, "BepInEx"), { recursive: true }); + const a = await inspect(left); + const b = await inspect(right); + expect(a.report.files[0]?.path).toBe(b.report.files[0]?.path); + expect(a.report.files[0]?.identity).toBeNull(); + expect(a.report.files[0]?.file_id).not.toBe(b.report.files[0]?.file_id); + expect(a.report.components[0]?.component_id).not.toBe( + b.report.components[0]?.component_id, + ); + expect(a.report.root.snapshot_sha256).not.toBe(b.report.root.snapshot_sha256); +}); + +it.skipIf(process.platform === "win32").each(["path", "kind"] as const)( + "commits omitted special-entry %s changes and exposes selected omission facts", + async (change) => { + const root = await createTestTempDirectory("rea-desktop-omissions-"); + const original = join(root, "special"); + execFileSync("mkfifo", [original], { timeout: 10_000 }); + const before = await inspect(root); + let selected = "special"; + if (change === "path") { + selected = "renamed"; + await rename(original, join(root, selected)); + } else { + await unlink(original); + const server = createNetServer(); + onTestFinished(async () => { + if (!server.listening) return; + await new Promise((resolve, reject) => + server.close((cause) => (cause ? reject(cause) : resolve())), + ); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(original, resolve); + }); + } + const after = await inspect(root); + expect(after.report.root.snapshot_sha256).not.toBe( + before.report.root.snapshot_sha256, + ); + expect(before.report.omissions).toEqual([ + { + omission_id: expect.stringMatching(/^dso_[a-f0-9]{64}$/u), + path: "special", + kind: "fifo", + }, + ]); + expect(after.report.omissions[0]?.omission_id).not.toBe( + before.report.omissions[0]?.omission_id, + ); + expect(after.report.coverage).toMatchObject({ + status: "partial", + entries_examined: 1, + special_entries_omitted: 1, + files_probed: 0, + bytes_read: 0, + }); + const page = inspectAnalysisView({ + source: { kind: "inline", evidence: after.evidence }, + view: { + kind: "desktop-application", + collection: "omissions", + paths: [selected], + fields: ["kind"], + offset: 0, + limit: 1, + }, + }); + if (!page.ok) throw page.error; + expect(page.value.evidence_links).toEqual([after.evidence.evidence_id]); + expect(page.value.normalized_result).toHaveProperty("items", [ + { + omission_id: after.report.omissions[0]?.omission_id, + kind: change === "path" ? "fifo" : "socket", + }, + ]); + expect(page.value.normalized_result).toMatchObject({ + coverage: { total: 1, exhausted: true }, + source_coverage: { status: "partial" }, + }); + }, +); + +it("bounds omitted-entry retention before allocating rows or serializing identities", async () => { + const root = await createTestTempDirectory("rea-desktop-omission-capacity-"); + let examined = 0; + // Simulate bounded parsed paths without creating hundreds of deep FIFO trees. + const prefix = Array.from({ length: 15 }, () => "d".repeat(255)).join("/"); + const service = new DesktopApplicationService( + new DesktopApplicationReader((path) => { + const reader = new DirectoryArtifactReader(path); + reader.entries = async function* (_signal, options) { + if (typeof options === "function") + throw new Error("Expected traversal options"); + for (let index = 0; index < 400; index++) { + examined++; + options?.onOmission?.({ + path: `${prefix}/${index}/entry`, + kind: "fifo", + }); + } + }; + return reader; + }), + ); + onTestFinished(() => service.close()); + const result = await service.inspect({ path: root }); + if (!result.ok) throw result.error; + const report = desktopApplicationSchema.parse(result.value.normalized_result); + expect(examined).toBeLessThan(400); + expect(report.omissions).toHaveLength(examined - 1); + expect(report.coverage).toMatchObject({ + status: "partial", + entries_examined: examined, + special_entries_omitted: examined, + }); + expect(report.limitations).toContainEqual( + expect.stringContaining("representation budget"), + ); +}); + +it("preserves the selected root alias and directs follow-ups to the admitted physical root", async () => { + const root = await createTestTempDirectory("rea-desktop-root-alias-"); + const admitted = join(root, "admitted"); + const replacement = join(root, "replacement"); + const alias = join(root, "selected"); + await put(admitted, "resources/app.asar", "observed marker"); + await mkdir(replacement); + await symlink( + admitted, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + const service = new DesktopApplicationService( + new DesktopApplicationReader((path) => { + const reader = new DirectoryArtifactReader(path); + const entries = reader.entries.bind(reader); + reader.entries = async function* (...args: Parameters) { + await unlink(alias); + await symlink( + replacement, + alias, + process.platform === "win32" ? "junction" : "dir", + ); + yield* entries(...args); + }; + return reader; + }), + ); + try { + const result = await service.inspect({ path: alias }); + if (!result.ok) throw result.error; + const report = desktopApplicationSchema.parse( + result.value.normalized_result, + ); + expect(report.root.path).toBe(alias); + expect(report.root.canonical_path).toBe(await realpath(admitted)); + expect(report.candidates.map((candidate) => candidate.family)).toEqual([ + "electron", + ]); + expect(report.next_probes[0]?.path).toBe( + join(await realpath(admitted), "resources", "app.asar"), + ); + } finally { + await service.close(); + } +}); + +it.each([false, true])( + "retains verified header facts and exact release authority when file close fails (late cancellation: %s)", + async (cancelDuringClose) => { + const root = await createTestTempDirectory("rea-desktop-probe-close-"); + const header = Buffer.alloc(20); + header.write("GDPC"); + header.writeUInt32LE(2, 4); + header.writeUInt32LE(4, 8); + await put(root, "game.pck", header); + const controller = new AbortController(); + const reason = new Error("cancel accepted while file close failed"); + let restore: (() => void) | undefined; + const service = new DesktopApplicationService( + new DesktopApplicationReader((path) => { + const reader = new DirectoryArtifactReader(path); + const inspectFile = reader.inspectFile.bind(reader); + reader.inspectFile = (entry, inspect, signal) => + inspectFile( + entry, + async (file) => { + const close = file.close.bind(file); + restore = () => { + file.close = close; + }; + file.close = async () => { + if (cancelDuringClose) controller.abort(reason); + throw new Error("descriptor close denied"); + }; + return inspect(file); + }, + signal, + ); + return reader; + }), + ); + try { + const result = await service.inspect( + { path: root }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected incomplete descriptor cleanup"); + expect(result.error.cleanupIncomplete).toBe(true); + if (cancelDuringClose) + expect(result.error).toMatchObject({ + reason: "cancelled", + cause: reason, + }); + const report = desktopApplicationSchema.parse( + parseEvidence(result.error.partialObservation).normalized_result, + ); + expect(report.coverage.status).toBe("partial"); + const successfulRead = await inspect(root); + expect(report.files[0]?.file_id).toBe( + successfulRead.report.files[0]?.file_id, + ); + expect(report.files[0]?.ranges).toEqual( + successfulRead.report.files[0]?.ranges, + ); + expect(report.root.snapshot_sha256).toBe( + successfulRead.report.root.snapshot_sha256, + ); + expect(report.files[0]).toMatchObject({ + read_status: "examined", + ranges: [{ offset: 0, bytes: 20, sha256: expect.any(String) }], + facts: [{ kind: "godot-pck-header", offset: 0 }], + }); + expect(report.candidates.map((candidate) => candidate.family)).toEqual([ + "godot", + ]); + await expect(service.close()).rejects.toMatchObject({ + cleanup: expect.any(Object), + }); + expect(result.error.cleanupIncomplete).toBe(true); + restore?.(); + await service.close(); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + restore?.(); + await service.close(); + } + }, +); + +it.each([ + "Game_Data/Managed", + "Contents/Resources/Data/Managed", + "Nested/Game.app/Contents/Resources/Data/Managed", +])( + "reads AssemblyRef names in %s independently of unrelated key/hash payload validity", + async (managedDirectory) => { + const root = await createTestTempDirectory("rea-desktop-reference-fields-"); + const image = buildManagedPeFixture({ + references: ["System.Runtime", "UnityEngine.CoreModule"], + }); + const metadataOffset = image.indexOf(Buffer.from("BSJB")); + const layout = readManagedMetadataLayout( + image, + metadataOffset, + image.length - metadataOffset, + ); + const reference = layout.table(35)!; + // ECMA-335 AssemblyRef: 8 version bytes, 4 flag bytes, then PublicKeyOrToken. + // Keep its name valid while making that unrelated blob index invalid. + if (layout.blobIndexSize === 2) + image.writeUInt16LE(0xffff, reference.offset + 12); + else image.writeUInt32LE(0xffff_ffff, reference.offset + 12); + expect(() => + readAssemblyReference(image, layout, 1, layout.blob.size), + ).toThrow(); + await put(root, `${managedDirectory}/Assembly-CSharp.dll`, image); + const { report } = await inspect(root); + expect(report.candidates.map((candidate) => candidate.family)).toEqual([ + "dotnet", + "unity-mono", + ]); + expect( + report.files.find((file) => file.kind === "file")?.facts, + ).toContainEqual({ + kind: "unity-assembly-reference", + offset: expect.any(Number), + value: { + name: "UnityEngine.CoreModule", + token: "0x23000002", + managed_directory: managedDirectory, + }, + }); + }, +); + +it.each([ + "Data/Managed", + "Other/Resources/Data/Managed", + "Contents/Resource/Data/Managed", +])("does not infer Unity from an unrelated layout %s", async (directory) => { + const root = await createTestTempDirectory("rea-desktop-unity-layout-"); + await put( + root, + `${directory}/Assembly-CSharp.dll`, + buildManagedPeFixture({ references: ["UnityEngine.CoreModule"] }), + ); + const { report } = await inspect(root); + expect(report.candidates.map((candidate) => candidate.family)).toEqual([ + "dotnet", + ]); +}); + +it.each([ + { container: 8, chunk: 0, bytes: 16, valid: true }, + { container: 16, chunk: 8, bytes: 32, valid: true }, + { container: 0, chunk: 0, bytes: 16, valid: false }, + { container: 7, chunk: 0, bytes: 16, valid: false }, + { container: 8, chunk: 1, bytes: 17, valid: false }, + { container: 12, chunk: 8, bytes: 24, valid: false }, + { container: 17, chunk: 8, bytes: 24, valid: false }, + { container: 16, chunk: 9, bytes: 25, valid: false }, + { container: 8, chunk: 0xffff_ffff, bytes: 16, valid: false }, +])( + "requires GEN8 within FORM and the file: $container/$chunk/$bytes (valid=$valid)", + async ({ container, chunk, bytes, valid }) => { + const root = await createTestTempDirectory("rea-desktop-form-extents-"); + const form = Buffer.alloc(bytes); + form.write("FORM"); + form.writeUInt32LE(container, 4); + form.write("GEN8", 8); + form.writeUInt32LE(chunk, 12); + await put(root, "data.win", form); + const { report } = await inspect(root); + expect(report.candidates.map((candidate) => candidate.family)).toEqual( + valid ? ["gamemaker"] : [], + ); + expect( + report.files + .find((file) => file.path === "data.win") + ?.facts.some((fact) => fact.kind === "gamemaker-form-header"), + ).toBe(valid); + expect(report.coverage.status).toBe("complete-within-scan"); + }, +); + +it("compares exact signature bytes without masking high bits in engine headers or trailers", async () => { + const root = await createTestTempDirectory("rea-desktop-exact-signatures-"); + const pck = Buffer.alloc(20); + pck.write("GDPC"); + const form = Buffer.alloc(16); + form.write("FORM"); + form.writeUInt32LE(8, 4); + form.write("GEN8", 8); + const toc = Buffer.from("-==-".repeat(4)); + for (const [path, source, offset] of [ + ["invalid.pck", pck, 0], + ["bad-form/data.win", form, 0], + ["bad-chunk/data.win", form, 8], + ["invalid.utoc", toc, 0], + ] as const) { + const bytes = Buffer.from(source); + bytes[offset] = bytes[offset]! | 0x80; + await put(root, path, bytes); + } + const elf = Buffer.alloc(64); + elf.set([0x7f, 0x45, 0x4c, 0x46]); + const trailer = Buffer.alloc(12); + trailer.writeBigUInt64LE(BigInt(pck.length)); + trailer.write("GDPC", 8); + const badTrailer = Buffer.from(trailer); + badTrailer[8] = badTrailer[8]! | 0x80; + await put(root, "bad-trailer.exe", Buffer.concat([elf, pck, badTrailer])); + const badHeader = Buffer.from(pck); + badHeader[0] = badHeader[0]! | 0x80; + await put(root, "bad-header.exe", Buffer.concat([elf, badHeader, trailer])); + const { report } = await inspect(root); + expect(report.candidates).toEqual([]); + expect(report.files.flatMap((file) => file.facts)).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "godot-pck-header" }), + ]), + ); +}); + +it("yields between reference-name batches so accepted cancellation precedes inspection completion", async () => { + const root = await createTestTempDirectory("rea-desktop-reference-cancel-"); + await put( + root, + "Game_Data/Managed/Assembly-CSharp.dll", + buildManagedPeFixture({ + references: Array.from({ length: 2048 }, (_, index) => `Library${index}`), + }), + ); + const controller = new AbortController(); + const reason = new Error("cancel while references are being examined"); + let completed = false; + let completedWhenCancelled: boolean | undefined; + let scheduled = false; + const service = new DesktopApplicationService( + new DesktopApplicationReader((path) => { + const reader = new DirectoryArtifactReader(path); + const inspectFile = reader.inspectFile.bind(reader); + reader.inspectFile = (entry, inspection, signal) => + inspectFile( + entry, + async (file) => { + const observed = new Proxy(file, { + get(target, property, receiver) { + if (property !== "read") + return Reflect.get(target, property, receiver); + return async (...args: unknown[]) => { + const result: unknown = await Reflect.apply( + target.read, + target, + args, + ); + const buffer = args[0]; + if ( + !scheduled && + Buffer.isBuffer(buffer) && + buffer.subarray(0, 4).equals(Buffer.from("BSJB")) + ) { + scheduled = true; + // Let the first batch start, then cancel on the following turn. + setImmediate(() => + setImmediate(() => { + completedWhenCancelled = completed; + controller.abort(reason); + }), + ); + } + return result; + }; + }, + }); + try { + return await inspection(observed); + } finally { + completed = true; + } + }, + signal, + ); + return reader; + }), + ); + try { + const result = await service.inspect( + { path: root }, + { signal: controller.signal }, + ); + if (result.ok) + throw new Error("Expected cancellation while examining references"); + expect(scheduled).toBe(true); + expect(completedWhenCancelled).toBe(false); + expect(result.error).toMatchObject({ reason: "cancelled", cause: reason }); + } finally { + await service.close(); + } +}); + +it.each([ + { + format: "ELF", + path: "GameAssembly.so", + image: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), + }, + { + format: "thin Mach-O", + path: "Contents/Frameworks/GameAssembly.dylib", + image: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1]), + }, + ...[false, true].map((littleEndian) => ({ + format: `FAT64 ${littleEndian ? "little" : "big"}-endian`, + path: "Contents/Frameworks/GameAssembly.dylib", + image: Buffer.from( + fatImage( + [ + { + cpu: CPU.arm64, + bytes: machoImage({ cpu: CPU.arm64, fileType: FILE_TYPE.dylib }), + }, + ], + true, + littleEndian, + ), + ), + })), +])( + "joins IL2CPP metadata with $format library $path", + async ({ path, image }) => { + const root = await createTestTempDirectory("rea-desktop-unix-il2cpp-"); + await put(root, path, image); + const metadata = Buffer.alloc(8); + metadata.writeUInt32LE(0xfab11baf); + metadata.writeUInt32LE(31, 4); + await put( + root, + "Game_Data/il2cpp_data/Metadata/global-metadata.dat", + metadata, + ); + const { report } = await inspect(root); + // Native probes read a fixed prefix and, where present, the existing PCK + // trailer; the FAT architecture table and slice payload are not traversed. + expect(report.coverage.bytes_read).toBeLessThanOrEqual( + 64 + 12 + metadata.length, + ); + expect( + report.files.find((file) => file.path === path)?.ranges[0], + ).toMatchObject({ + offset: 0, + bytes: Math.min(image.length, 64), + }); + expect(report.candidates.map((candidate) => candidate.family)).toEqual([ + "unity-il2cpp", + ]); + expect(report.next_probes).toEqual([ + expect.objectContaining({ + operation: "open_binary", + path: join(await realpath(root), path), + file_id: expect.any(String), + }), + ]); + }, +); + +it("bounds diagnostic previews while preserving complete caller-provided issue facts", async () => { + const root = await createTestTempDirectory("rea-desktop-diagnostic-preview-"); + const malformed = Buffer.alloc(64); + malformed.write("MZ"); + malformed.writeUInt32LE(0xffffffff, 0x3c); + await put(root, "broken.exe", malformed); + const { report, evidence } = await inspect(root); + const source = structuredClone(report); + const row = source.files.find((file) => file.path === "broken.exe"); + if (row === undefined) throw new Error("Missing diagnostic fixture"); + const longReason = "A" + "😀".repeat(1000); + row.issues = [longReason, "second probe reason"]; + const callerEvidence = createDesktopApplicationEvidence( + { + path: root, + max_entries: 20_000, + max_probe_bytes: 8 * 1024 * 1024, + }, + source, + evidence.provider, + ); + const summarized = inspectAnalysisView({ + source: { kind: "inline", evidence: callerEvidence }, + view: { kind: "summary" }, + }); + if (!summarized.ok) throw summarized.error; + expect(summarized.value.normalized_result).toMatchObject({ + summary: { + probe_issues: { + file_count: 1, + issue_count: 2, + preview_truncated: false, + preview: [ + { + file_id: row.file_id, + path_preview: "broken.exe", + read_status: "partial", + issue_count: 2, + first_issue_preview: "A" + "😀".repeat(127), + text_truncated: true, + }, + ], + }, + }, + }); + for (const ids of [undefined, []]) { + const selected = inspectAnalysisView({ + source: { kind: "inline", evidence: callerEvidence }, + view: { + kind: "desktop-application", + collection: "files", + has_issues: true, + ...(ids === undefined ? {} : { file_ids: ids }), + fields: ["issues"], + }, + }); + if (!selected.ok) throw selected.error; + expect(selected.value.normalized_result).toMatchObject({ + items: + ids === undefined + ? [ + { + file_id: row.file_id, + issues: [longReason, "second probe reason"], + }, + ] + : [], + source_coverage: { status: "partial" }, + }); + expect(selected.value.evidence_links).toEqual([callerEvidence.evidence_id]); + } + expect(row.issues[0]).toBe(longReason); +}); + +it("keeps snapshot identity stable across limits that preserve source observations", async () => { + const root = await createTestTempDirectory("rea-desktop-snapshot-policy-"); + await put(root, "GameAssembly.dll", buildNativePeFixture()); + const metadata = Buffer.alloc(8); + metadata.writeUInt32LE(0xfab11baf); + metadata.writeUInt32LE(31, 4); + await put(root, "global-metadata.dat", metadata); + await mkdir(join(root, "BepInEx")); + const first = await inspect(root, { max_entries: 10, max_probe_bytes: 4096 }); + const second = await inspect(root, { + max_entries: 20, + max_probe_bytes: 8192, + }); + expect(first.report.files).toEqual(second.report.files); + expect(first.report.omissions).toEqual(second.report.omissions); + expect(first.report.coverage.status).toBe("complete-within-scan"); + expect(second.report.coverage.status).toBe("complete-within-scan"); + expect(first.report.root.snapshot_sha256).toBe( + second.report.root.snapshot_sha256, + ); + expect(first.evidence.subject?.digest.sha256).toBe( + second.evidence.subject?.digest.sha256, + ); + expect(first.evidence.evidence_id).not.toBe(second.evidence.evidence_id); + expect(first.evidence.parameters).toMatchObject({ + max_entries: 10, + max_probe_bytes: 4096, + }); + expect(second.evidence.parameters).toMatchObject({ + max_entries: 20, + max_probe_bytes: 8192, + }); + expect(first.report.coverage.limits).toEqual({ + max_entries: 10, + max_probe_bytes: 4096, + }); + expect(second.report.coverage.limits).toEqual({ + max_entries: 20, + max_probe_bytes: 8192, + }); + const subset = await inspect(root, { max_entries: 1, max_probe_bytes: 8192 }); + expect(subset.report.coverage.status).toBe("partial"); + expect(subset.report.root.snapshot_sha256).not.toBe( + first.report.root.snapshot_sha256, + ); +}); + +it("changes snapshot identity when probe limits change retained read ranges", async () => { + const root = await createTestTempDirectory("rea-desktop-snapshot-ranges-"); + for (let index = 0; index < 20; index++) + await put( + root, + `probe${String(index).padStart(2, "0")}.exe`, + buildNativePeFixture(), + ); + const partial = await inspect(root, { max_probe_bytes: 4096 }); + const complete = await inspect(root, { max_probe_bytes: 8192 }); + expect(partial.report.coverage.status).toBe("partial"); + expect(complete.report.coverage.status).toBe("complete-within-scan"); + expect(partial.report.files.map((file) => file.file_id)).toEqual( + complete.report.files.map((file) => file.file_id), + ); + expect(partial.report.files.map((file) => file.ranges)).not.toEqual( + complete.report.files.map((file) => file.ranges), + ); + expect(partial.report.root.snapshot_sha256).not.toBe( + complete.report.root.snapshot_sha256, + ); +}); + +it("rejects expanded managed facts before serializing an over-budget value", async () => { + const root = await createTestTempDirectory("rea-desktop-fact-capacity-"); + const name = `UnityEngine.${"\u0001".repeat(600_000)}`; + await put( + root, + "Game_Data/Managed/Assembly-CSharp.dll", + buildManagedPeFixture({ references: [name] }), + ); + const serialized = vi.spyOn(JSON, "stringify"); + onTestFinished(() => serialized.mockRestore()); + const observed = await inspect(root); + const materializedFacts = serialized.mock.calls.filter( + ([value]) => + value !== null && + typeof value === "object" && + Reflect.get(value, "name") === name, + ).length; + serialized.mockRestore(); + const file = observed.report.files.find((row) => row.path.endsWith(".dll")); + expect(file?.facts.map((fact) => fact.kind)).toEqual([ + "native-image-header", + "pe-cli-directory", + ]); + expect(file?.issues).toContain( + "Directory facts reached the retained metadata budget", + ); + expect(observed.report.coverage.status).toBe("partial"); + expect( + observed.report.candidates.map((candidate) => candidate.family), + ).not.toContain("unity-mono"); + expect(materializedFacts).toBe(0); +}); + +it("preserves admitted Unicode and escaped AssemblyRef names in complete Evidence", async () => { + const root = await createTestTempDirectory("rea-desktop-fact-text-"); + const name = `UnityEngine.${'雪🧪"\\\n\u0001'.repeat(1024)}`; + await put( + root, + "Game_Data/Managed/Assembly-CSharp.dll", + buildManagedPeFixture({ references: [name] }), + ); + const { report } = await inspect(root); + const file = report.files.find((row) => row.path.endsWith(".dll")); + expect(file?.issues).toEqual([]); + expect(file?.facts).toContainEqual({ + kind: "unity-assembly-reference", + offset: expect.any(Number), + value: { + name, + token: "0x23000001", + managed_directory: "Game_Data/Managed", + }, + }); + expect(report.coverage.status).toBe("complete-within-scan"); + expect(report.candidates.map((candidate) => candidate.family)).toContain( + "unity-mono", + ); +}); diff --git a/tests/boundary/mcp/desktopApplicationMcp.test.ts b/tests/boundary/mcp/desktopApplicationMcp.test.ts new file mode 100644 index 0000000000..34bd6756ac --- /dev/null +++ b/tests/boundary/mcp/desktopApplicationMcp.test.ts @@ -0,0 +1,394 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { createPackageWithOptions } from "@electron/asar"; +import { expect, it, onTestFinished } from "vitest"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { desktopApplicationSchema } from "../../../src/domain/desktopApplication.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { buildNativePeFixture } from "../../../src/dotnet/ManagedPe.fixture.js"; +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; + +it.each([false, true])( + "follows the ASAR selection probe with a previously active target: %s", + async (previous) => { + const root = await createTestTempDirectory("rea-desktop-asar-probe-"); + const source = join(root, "source"); + const installation = join(root, "installation"); + const resources = join(installation, "resources"); + await mkdir(source); + await mkdir(resources, { recursive: true }); + await writeFile( + join(source, "selected.js"), + "export const selected = true;\n", + ); + const archive = join(resources, "app.asar"); + await createPackageWithOptions(source, archive, {}); + const session = createTestBinarySession(new ArtifactProvider(process.env)); + const harness = await createApplicationMcpHarness({ session }); + onTestFinished(harness.close); + if (previous) { + const other = join(root, "previous"); + await mkdir(other); + await writeFile( + join(other, "previous.js"), + "export const previous = true;\n", + ); + const path = join(root, "previous.asar"); + await createPackageWithOptions(other, path, {}); + const opened = await harness.client.callTool({ + name: "open_binary", + arguments: { path }, + }); + expect(opened.isError).not.toBe(true); + } + const result = await harness.client.callTool({ + name: "inspect_desktop_application", + arguments: { path: installation, detail: "complete" }, + }); + expect(result.isError).not.toBe(true); + const report = desktopApplicationSchema.parse( + parseEvidence(result.structuredContent).normalized_result, + ); + const archiveFile = report.files.find( + (row) => row.path === "resources/app.asar", + ); + if (archiveFile === undefined) throw new Error("Missing selected archive"); + const probe = report.next_probes.find( + (row) => + row.file_id === archiveFile.file_id && + row.operation !== "analyze_javascript_application", + ); + if (probe === undefined) throw new Error("Missing archive selection probe"); + expect(probe.operation).toBe("open_binary"); + const selected = await harness.client.callTool({ + name: probe.operation, + arguments: { path: probe.path }, + }); + expect(selected.isError).not.toBe(true); + const inspected = await harness.client.callTool({ + name: "inspect_artifact", + arguments: {}, + }); + expect(inspected.isError).not.toBe(true); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(inspected.structuredContent).normalized_result, + ); + const inventory = artifactInventoryResultSchema.parse( + inspection.substeps[0]?.evidence.normalized_result, + ); + expect(inventory.occurrences.map((row) => row.logical_path)).toContain( + "selected.js", + ); + expect(inventory.occurrences.map((row) => row.logical_path)).not.toContain( + "previous.js", + ); + }, +); + +it("advertises exact schemas and retains a compact directory answer with typed selected pages", async () => { + const root = await createTestTempDirectory("rea-desktop-mcp-"); + await mkdir(join(root, "resources")); + await writeFile(join(root, "resources/app.asar"), "path marker only"); + const session = createTestBinarySession(() => { + throw new Error("Directory inspection must not acquire a deep provider"); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "desktop-directory-test", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_desktop_application", + ); + const views = await advertisedMcpToolSchemas(client, "inspect_analysis_view"); + expect(schemas.ajv.validate(schemas.inputSchema, { path: root })).toBe(true); + expect( + schemas.ajv.validate(schemas.inputSchema, { path: root, execute: true }), + ).toBe(false); + const result = await client.callTool({ + name: "inspect_desktop_application", + arguments: { path: root }, + }); + expect(result.isError).not.toBe(true); + expect( + schemas.ajv.validate(schemas.outputSchema, result.structuredContent), + JSON.stringify(schemas.ajv.errors), + ).toBe(true); + const summary = parseEvidence(result.structuredContent); + const view = analysisViewResultSchema.parse(summary.normalized_result); + expect(view.kind).toBe("summary"); + const retained = session.evidenceById(view.parent_evidence_id); + expect(retained).toBeDefined(); + const full = desktopApplicationSchema.parse(retained?.normalized_result); + expect(full.candidates.map((candidate) => candidate.family)).toEqual([ + "electron", + ]); + const archive = full.files.find((file) => file.path === "resources/app.asar"); + if (archive === undefined) + throw new Error("Missing retained ASAR convention"); + const selected = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: view.parent_evidence_id, + }, + view: { + kind: "desktop-application", + collection: "files", + file_ids: [archive.file_id], + fields: ["path", "read_status"], + limit: 1, + }, + }, + }); + expect(selected.isError).not.toBe(true); + expect( + views.ajv.validate(views.outputSchema, selected.structuredContent), + JSON.stringify(views.ajv.errors), + ).toBe(true); + const page = parseEvidence(selected.structuredContent); + expect(page.evidence_links).toEqual([view.parent_evidence_id]); + expect(page.normalized_result).toMatchObject({ + items: [ + { + file_id: expect.any(String), + path: "resources/app.asar", + read_status: "not-selected", + }, + ], + source_coverage: { status: "complete-within-scan" }, + coverage: { total: 1, exhausted: true }, + }); + const complete = await client.callTool({ + name: "inspect_desktop_application", + arguments: { path: root, detail: "complete" }, + }); + expect(complete.isError).not.toBe(true); + expect(parseEvidence(complete.structuredContent).evidence_id).toBe( + view.parent_evidence_id, + ); +}); + +it("surfaces unavailable probes and filters their complete retained diagnostics through MCP", async () => { + const session = createTestBinarySession(() => { + throw new Error("Directory inspection must not acquire a deep provider"); + }); + const harness = await createApplicationMcpHarness({ session }); + onTestFinished(harness.close); + const client = harness.client; + const schemas = await advertisedMcpToolSchemas( + client, + "inspect_desktop_application", + ); + const views = await advertisedMcpToolSchemas(client, "inspect_analysis_view"); + const partialRoot = await createTestTempDirectory( + "rea-desktop-probe-issues-mcp-", + ); + await mkdir(join(partialRoot, "clean")); + for (let index = 0; index < 20; index++) + await writeFile( + join(partialRoot, `probe${String(index).padStart(2, "0")}.exe`), + buildNativePeFixture(), + ); + const partial = await client.callTool({ + name: "inspect_desktop_application", + arguments: { path: partialRoot, max_probe_bytes: 4096 }, + }); + expect(partial.isError).not.toBe(true); + expect( + schemas.ajv.validate(schemas.outputSchema, partial.structuredContent), + ).toBe(true); + const partialEvidence = parseEvidence(partial.structuredContent); + const partialView = analysisViewResultSchema.parse( + partialEvidence.normalized_result, + ); + expect(partialView).toMatchObject({ + kind: "summary", + summary: { + probe_issues: { + file_count: expect.any(Number), + issue_count: expect.any(Number), + preview_truncated: true, + }, + }, + }); + const partialSource = session.evidenceById(partialView.parent_evidence_id); + const partialReport = desktopApplicationSchema.parse( + partialSource?.normalized_result, + ); + const failed = partialReport.files.filter((file) => file.issues.length > 0); + expect(failed.length).toBeGreaterThan(2); + expect( + failed.every((file) => + file.issues.some((reason) => /budget/iu.test(reason)), + ), + ).toBe(true); + expect(partialView).toMatchObject({ + summary: { + source_coverage: { status: "partial", bytes_read: expect.any(Number) }, + probe_issues: { + file_count: failed.length, + issue_count: failed.reduce((sum, file) => sum + file.issues.length, 0), + preview: failed.slice(0, 2).map((file) => ({ + file_id: file.file_id, + path_preview: file.path, + read_status: file.read_status, + issue_count: file.issues.length, + first_issue_preview: file.issues[0], + text_truncated: false, + })), + }, + }, + }); + for (const [hasIssues, offset, paths, total] of [ + [true, 0, failed.slice(0, 2).map((file) => file.path), failed.length], + [true, failed.length - 1, [failed.at(-1)!.path], failed.length], + [false, 0, ["clean"], 1], + ] as const) { + const args = { + source: { + kind: "retained-evidence", + evidence_id: partialView.parent_evidence_id, + }, + view: { + kind: "desktop-application", + collection: "files", + has_issues: hasIssues, + ...(hasIssues ? {} : { paths: ["clean"] }), + fields: ["path", "read_status", "issues"], + offset, + limit: 2, + }, + }; + expect(views.ajv.validate(views.inputSchema, args)).toBe(true); + const response = await client.callTool({ + name: "inspect_analysis_view", + arguments: args, + }); + expect(response.isError).not.toBe(true); + expect( + views.ajv.validate(views.outputSchema, response.structuredContent), + ).toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.evidence_links).toEqual([partialView.parent_evidence_id]); + expect(evidence.normalized_result).toMatchObject({ + items: paths.map((path) => { + const file = partialReport.files.find((row) => row.path === path)!; + return { + file_id: file.file_id, + path, + read_status: file.read_status, + issues: file.issues, + }; + }), + source_coverage: { status: "partial" }, + coverage: { total, exhausted: offset + paths.length === total }, + }); + } +}); + +it.skipIf(process.platform === "win32")( + "retains special-entry facts and advertises batched omission selections through MCP", + async () => { + const root = await createTestTempDirectory("rea-desktop-omissions-mcp-"); + execFileSync("mkfifo", [join(root, "b"), join(root, "a")], { + timeout: 10_000, + }); + const harness = await createApplicationMcpHarness(); + onTestFinished(harness.close); + const schemas = await advertisedMcpToolSchemas( + harness.client, + "inspect_analysis_view", + ); + const inspected = await harness.client.callTool({ + name: "inspect_desktop_application", + arguments: { path: root }, + }); + expect(inspected.isError).not.toBe(true); + const summary = analysisViewResultSchema.parse( + parseEvidence(inspected.structuredContent).normalized_result, + ); + expect(summary).toMatchObject({ + summary: { available_counts: { omissions: 2 } }, + }); + const source = { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }; + const listed = await harness.client.callTool({ + name: "inspect_analysis_view", + arguments: { + source, + view: { + kind: "desktop-application", + collection: "omissions", + limit: 2, + }, + }, + }); + expect(listed.isError).not.toBe(true); + expect( + schemas.ajv.validate(schemas.outputSchema, listed.structuredContent), + ).toBe(true); + const page = analysisViewResultSchema.parse( + parseEvidence(listed.structuredContent).normalized_result, + ); + if (page.kind !== "desktop-application") throw new Error("Expected rows"); + const ids = page.items.map((row) => { + if (!("omission_id" in row) || typeof row.omission_id !== "string") + throw new Error("Missing stable omission identity"); + return row.omission_id; + }); + expect(page.items).toMatchObject([ + { path: "a", kind: "fifo" }, + { path: "b", kind: "fifo" }, + ]); + for (const selection of [ids, []]) { + const args = { + source, + view: { + kind: "desktop-application", + collection: "omissions", + omission_ids: selection, + fields: ["kind"], + limit: 2, + }, + }; + expect(schemas.ajv.validate(schemas.inputSchema, args)).toBe(true); + const selected = await harness.client.callTool({ + name: "inspect_analysis_view", + arguments: args, + }); + expect(selected.isError).not.toBe(true); + expect( + schemas.ajv.validate(schemas.outputSchema, selected.structuredContent), + ).toBe(true); + const evidence = parseEvidence(selected.structuredContent); + expect(evidence.evidence_links).toEqual([summary.parent_evidence_id]); + expect(evidence.normalized_result).toHaveProperty( + "items", + selection.map((omission_id) => ({ omission_id, kind: "fifo" })), + ); + expect(evidence.normalized_result).toMatchObject({ + source_coverage: { status: "partial" }, + }); + } + }, +);