diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 118b56af9c..6a8caa4cbb 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -233,6 +233,12 @@ explicit path or endpoint and do not need it. build-id or ABI-tag fields. A missing table is observed absence of those declarations, not proof the image never carried notes. See the [ELF notes guide](https://github.com/morluto/rea/blob/main/docs/elf-notes.md). +- Erlang BEAM IFF container: `inspect_erlang_beam` with the explicit local + `.beam` file. This built-in target-free inspection requires no Erlang + installation. Preserve chunk names, atom order, export/import/lambda tables, + and the Code header. Missing optional tables stay null. Bytecode is not + executed. See the + [Erlang BEAM guide](https://github.com/morluto/rea/blob/main/docs/erlang-beam.md). - Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema 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 09da10a3d4..bbe49e7a76 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -121,6 +121,16 @@ absence of those declarations, not proof the image never carried notes in another container. See [ELF note tables](https://github.com/morluto/rea/blob/main/docs/elf-notes.md). +## Erlang BEAM files + +Use `inspect_erlang_beam` for an explicit local `FOR1`/`BEAM` file when the +question concerns IFF chunks, atom names, exports, imports, lambdas, or the +Code header. No `open_binary`, Erlang installation, or bytecode execution is +required. The CLI equivalent is `inspect-erlang-beam PATH`. Preserve artifact +SHA-256, chunk file ranges, 1-based atom indices, and original atom spellings. +Missing optional tables stay null. See +[Erlang BEAM files](https://github.com/morluto/rea/blob/main/docs/erlang-beam.md). + ## SQLite snapshots Use `inspect_sqlite_database` with an explicit local database snapshot for its diff --git a/README.md b/README.md index 70aefe6255..465330a742 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ Additional tools and host support depend on the target: | Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](docs/pe-certificates.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](docs/elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, imports, lambdas and the Code header | Built-in static BEAM inspection; [Erlang BEAM](docs/erlang-beam.md) | | WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` | | EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) | | SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 4640b8e66b..a6ea20af62 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -34,6 +34,7 @@ export default defineConfig({ { text: "Rust dependencies", link: "/rust-binaries" }, { text: "PE certificates", link: "/pe-certificates" }, { text: "ELF notes", link: "/elf-notes" }, + { text: "Erlang BEAM", link: "/erlang-beam" }, { text: "JavaScript and Electron", link: "/javascript-artifact-reconstruction", diff --git a/docs/cli.md b/docs/cli.md index b4774ee725..4e21adf477 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -177,6 +177,16 @@ The workflow reports note owners, types, descriptor hashes, and GNU build-id or ABI-tag fields when those descriptors match. A missing note table is observed absence of those declarations. See [ELF note tables](elf-notes.md). +Inspect an Erlang BEAM file without executing bytecode: + +```bash +rea inspect-erlang-beam /absolute/path/to/app.beam --json +``` + +The workflow reports IFF chunk names, atom tables, exports, imports, lambdas, +and the Code chunk header. Missing optional tables stay null. See +[Erlang BEAM files](erlang-beam.md). + Configure [Hopper or Ghidra](installation.md#hopper), the [IDA adapter](ida-provider.md), or [Kuna](kuna-provider.md) before analyzing a native target. Substitute diff --git a/docs/erlang-beam.md b/docs/erlang-beam.md new file mode 100644 index 0000000000..686bd3ff62 --- /dev/null +++ b/docs/erlang-beam.md @@ -0,0 +1,46 @@ +# Erlang BEAM files + +`inspect_erlang_beam` / `inspect-erlang-beam` answers whether one selected +local file is a `FOR1`/`BEAM` IFF container, which chunks that form stores, and +which atom, export, import, lambda, and Code-header fields those chunks carry. +It reads the file without executing bytecode and does not require an Erlang +installation or BEAM emulator. + +```bash +rea inspect-erlang-beam /absolute/path/to/app.beam --json +``` + +The CLI resolves relative paths against the caller's working directory. MCP +requires an absolute filesystem path and preserves that exact selection: + +```json +{ + "name": "inspect_erlang_beam", + "arguments": { "path": "/artifacts/app.beam" } +} +``` + +## Observations + +The inline Evidence result contains the artifact path, SHA-256, and byte length. +`chunks` lists every IFF chunk name, file range, and payload SHA-256. + +When `AtU8` or `Atom` is present, `atoms` is the 1-based name table and +`module` is atom 1. Both the pre-OTP 28 table and the OTP 28+ compact table +(negated count, compact-encoded name lengths) are read. `exports`, `imports`, and `lambdas` resolve atom indices to +those names when the table is present; unresolved indices keep `function` or +`module` null. + +`code` reports the Code chunk instruction-set, opcode maximum, label count, +function count, and the instruction stream's file range. Missing optional +tables are null, not empty-everywhere. + +## Limits and failures + +The selected file is limited to 64 MiB by default (512 MiB maximum). At most +64 chunks and 4096 atoms are examined by default, and at most 65536 export, +import, and lambda rows together. Malformed `FOR1`/`BEAM` headers, truncated +chunks, and `AtU8` names that are not valid UTF-8 fail without partial success. + +This workflow does not execute bytecode, inflate `LitT` literals, or decode +`Attr`/`CInf` external-term payloads. diff --git a/docs/index.md b/docs/index.md index f9fc109618..9f7c9edce4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,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), [PE certificates](pe-certificates.md), [ELF notes](elf-notes.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | +| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [PE certificates](pe-certificates.md), [ELF notes](elf-notes.md), [Erlang BEAM](erlang-beam.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) | | Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) | diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 6deef30673..80bf3ad136 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -147,6 +147,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) | | تثبيتات سطح المكتب | عائلات وقت التشغيل، والملفات الداعمة، وعلامات المكونات، والخطوات التالية | فحص محدود للملفات والترويسات;[تطبيقات سطح المكتب](../desktop-applications.md) | | JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](../javascript-application-workflows.md) | diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index 3441eab8e4..dad2206608 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -147,6 +147,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.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) | diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index 3b30d720a1..d9e326e815 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -151,6 +151,7 @@ npx rea-agents@latest setup | Δυαδικά αρχεία Rust | Ενσωματωμένα ονόματα, εκδόσεις, πηγές και σχέσεις εξάρτησης των crates από το cargo-auditable | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· [δυαδικά αρχεία Rust](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Αρχείο WASM | Έλεγχο εγκυρότητας, ενότητες, ακριβείς μορφές εισαγωγών/εξαγωγών σε WAT και αποκωδικοποιημένο WAT | WABT 1.0.42 που παρέχει ο χρήστης· `inspect_wasm_artifact` | | Bytecode EVM | Selectors επιλογής συνάρτησης, μετατοπίσεις σε bytes, εκτιμώμενα ορίσματα και δυνατότητα μεταβολής της κατάστασης | Τοπική είσοδος σε ακατέργαστα bytes ή δεκαεξαδική μορφή σε Linux x64· [οδηγός EVM χωρίς εκτέλεση](../evm-bytecode.md) | | Βάσεις δεδομένων SQLite | Πίνακες, στήλες, ευρετήρια, όψεις, triggers και επιλεγμένες γραμμές κανονικών πινάκων | Τοπικό στιγμιότυπο βάσης δεδομένων/WAL· Node.js 24.x >=24.15 ή 26+ με εγγενή όρια SQLite | diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index 81a0955756..43c10d8205 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -147,6 +147,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.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) | diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index bab6e0bb0a..1260185aea 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -139,6 +139,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.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) | diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index 9ef6da0fb1..7cae9ac284 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -151,6 +151,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.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) | diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index e1d0d5f533..62e4bf5d86 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -154,6 +154,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.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) | diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index 9b1621a20b..a6f4737a1a 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -151,6 +151,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.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) | diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index 0927cb629f..fa875c89f3 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -147,6 +147,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) | | デスクトップのインストール先 | ランタイム候補、根拠ファイル、コンポーネントの目印、次の調査手順 | 上限付きのディレクトリ・ヘッダー検査;[デスクトップアプリ](../desktop-applications.md) | | JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](../javascript-application-workflows.md) | diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index c07f7b7aa5..e614098205 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -155,6 +155,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니 | Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.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 한도 적용) | diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index bf3267e86e..429d22da10 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -153,6 +153,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.19), 24.x (>=24.11), അല്ലെ | Rust binaries | ഉൾച്ചേർത്ത cargo-auditable crate പേരുകൾ, versions, sources, dependency edges | Built-in static ELF/PE/thin Mach-O പരിശോധന; [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | WASM artifact | Validation, sections, കൃത്യമായ import/export WAT രൂപങ്ങൾ, decoded WAT | Caller നൽകുന്ന WABT 1.0.42; `inspect_wasm_artifact` | | 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+ | diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index 99bda0bbc3..5b1d00ba9c 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -147,6 +147,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.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) | diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index 0b5ed9d94c..ae05b40045 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -147,6 +147,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.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) | diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index 00f0ddbbaf..4f10f1ec29 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -147,6 +147,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) | | Установленные настольные приложения | Возможные среды выполнения, файлы с доказательствами, компоненты и дальнейшие шаги | Ограниченная проверка каталогов и заголовков;[Настольные приложения](../desktop-applications.md) | | JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](../javascript-application-workflows.md) | diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index c268e4fd11..7503a79579 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -147,6 +147,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) | | ไดเรกทอรีติดตั้งแอปเดสก์ท็อป | รันไทม์ที่เป็นไปได้ ไฟล์หลักฐาน ตัวบ่งชี้ส่วนประกอบ และขั้นตอนถัดไป | ตรวจสอบไดเรกทอรีและส่วนหัวโดยจำกัดขอบเขต;[แอปเดสก์ท็อป](../desktop-applications.md) | | JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](../javascript-application-workflows.md) | diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 0494323202..ceea02a0eb 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -149,6 +149,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç | Rust ikili dosyaları | Gömülü cargo-auditable crate adları, sürümleri, kaynakları ve bağımlılık ilişkileri | Yerleşik statik ELF/PE/thin Mach-O incelemesi; [Rust ikili dosyaları](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | WASM artefaktı | Doğrulama, bölümler, tam import/export WAT biçimleri ve decode edilmiş WAT | Çağıranın sağladığı WABT 1.0.42; `inspect_wasm_artifact` | | 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+ | diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 7ab55b1376..40e3199768 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -147,6 +147,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) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) | | Встановлені настільні програми | Можливі середовища виконання, файли доказів, компоненти та наступні кроки | Обмежена перевірка каталогів і заголовків;[Настільні програми](../desktop-applications.md) | | JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](../javascript-application-workflows.md) | diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index 3ae8ec85a1..a0bbdc52be 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -147,6 +147,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.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) | diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index 0cc9f4903a..008efeb943 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -149,6 +149,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` | | EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) | diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 007de80875..7538878a24 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -149,6 +149,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` | | EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | +| Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) | diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index fc79e165a4..9ad4682393 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -38,6 +38,7 @@ export const toolFamilyCatalog = (sources) => { ...sources.il2cppContracts.IL2CPP_TOOL_CONTRACTS, ...sources.modelContracts.MODEL_TOOL_CONTRACTS, ...sources.rustContracts.RUST_TOOL_CONTRACTS, + ...sources.erlangContracts.ERLANG_BEAM_TOOL_CONTRACTS, ...sources.wasmContracts.WASM_TOOL_CONTRACTS, ...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, ...sources.captureContracts.CAPTURE_TOOL_CONTRACTS, @@ -316,6 +317,10 @@ export const providerCatalog = (sources) => { identity: sources.rustProvider.RUST_BINARY_PROVIDER_IDENTITY, contracts: sources.rustContracts.RUST_TOOL_CONTRACTS, }, + { + identity: sources.erlangProvider.ERLANG_BEAM_PROVIDER_IDENTITY, + contracts: sources.erlangContracts.ERLANG_BEAM_TOOL_CONTRACTS, + }, { identity: sources.electronFuseProvider.ELECTRON_FUSE_PROVIDER_IDENTITY, contracts: fuseContracts, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 569ffd2b80..e0230f9f03 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -77,6 +77,8 @@ const SOURCE_PATHS = { il2cppProvider: "dist/il2cpp/Il2cppMetadataProvider.js", rustContracts: "dist/contracts/rust/rustToolContracts.js", rustProvider: "dist/rust/RustBinaryProvider.js", + erlangContracts: "dist/contracts/erlang/beamToolContracts.js", + erlangProvider: "dist/erlang/BeamProvider.js", artifactContracts: "dist/contracts/artifactToolContracts.js", desktopContracts: "dist/contracts/desktopApplicationToolContracts.js", managedContracts: "dist/contracts/managed/managedToolContracts.js", diff --git a/scripts/lib/test-projects.mjs b/scripts/lib/test-projects.mjs index a2accc2657..885df459c5 100644 --- a/scripts/lib/test-projects.mjs +++ b/scripts/lib/test-projects.mjs @@ -17,7 +17,7 @@ export const SOURCE_TEST_PROJECTS = [ group: "source", include: [ "src/*.test.ts", - "src/{android,apktool,jeb,adb,artifacts,browser,config,dotnet,electron,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", + "src/{android,apktool,jeb,adb,artifacts,browser,config,dotnet,electron,erlang,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", ], }, { diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index f24b6fd2d7..6a337dcb7e 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -418,6 +418,7 @@ const workflowAvailabilityFor = ({ name === "inspect_model_artifact" || name === "inspect_gpu_code_objects" || name === "inspect_rust_binary" || + name === "inspect_erlang_beam" || name === "inspect_electron_fuses" ) return { reason: "available", remediation: null }; diff --git a/src/application/erlang/BeamPort.ts b/src/application/erlang/BeamPort.ts new file mode 100644 index 0000000000..bec5e2667b --- /dev/null +++ b/src/application/erlang/BeamPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + InspectErlangBeamInput, + ErlangBeam, +} from "../../domain/erlang/beam.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and BEAM IFF decoding, independent of a disassembler session. */ +export interface BeamPort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectErlangBeamInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/erlang/BeamService.test.ts b/src/application/erlang/BeamService.test.ts new file mode 100644 index 0000000000..12d3a8bcd4 --- /dev/null +++ b/src/application/erlang/BeamService.test.ts @@ -0,0 +1,92 @@ +import { expect, it } from "vitest"; +import { BeamService } from "./BeamService.js"; +import { ERLANG_BEAM_PROVIDER_IDENTITY } from "../../erlang/BeamProvider.js"; +import type { ErlangBeam } from "../../domain/erlang/beam.js"; +import { ok } from "../../domain/result.js"; +import { + AnalysisCancelledError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; + +const identity = ERLANG_BEAM_PROVIDER_IDENTITY; +const report: ErlangBeam = { + artifact: { + path: "/artifacts/app.beam", + sha256: "a".repeat(64), + bytes: 64, + }, + form: { location: { offset: 0, bytes: 12 }, size: 4 }, + chunks: [], + atoms: null, + atom_encoding: null, + exports: null, + imports: null, + lambdas: null, + code: null, + module: null, + coverage: { status: "complete", examined_chunks: 0, atoms: 0 }, + limitations: ["No atom table was present."], +}; + +it("rejects a relative path and extra arguments before reading", async () => { + let calls = 0; + const service = new BeamService({ + identity, + inspect: () => { + calls += 1; + return Promise.resolve(ok(report)); + }, + }); + const relative = await service.inspect({ path: "app.beam" }); + const extra = await service.inspect({ + path: report.artifact.path, + extra: true, + }); + const nul = await service.inspect({ path: "/artifacts/app\0.beam" }); + expect(relative.ok).toBe(false); + expect(extra.ok).toBe(false); + expect(nul.ok).toBe(false); + if (!relative.ok) + expect(relative.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [{ path: ["path"] }], + }); + if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError); + if (!nul.ok) + expect(nul.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [{ path: ["path"] }], + }); + expect(calls).toBe(0); +}); + +it("records the selected BEAM form", async () => { + const result = await new BeamService({ + identity, + inspect: () => Promise.resolve(ok(report)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + confidence: "observed", + subject: { format: "file" }, + normalized_result: report, + }); +}); + +it("discards an observation if the caller cancels while the provider completes", async () => { + const controller = new AbortController(); + const service = new BeamService({ + identity, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(report)); + }, + }); + const result = await service.inspect( + { path: report.artifact.path }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError); +}); diff --git a/src/application/erlang/BeamService.ts b/src/application/erlang/BeamService.ts new file mode 100644 index 0000000000..19ab7fcd9d --- /dev/null +++ b/src/application/erlang/BeamService.ts @@ -0,0 +1,111 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { BeamPort } from "./BeamPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + inspectErlangBeamInputSchema, + erlangBeamSchema, + type ErlangBeam, +} from "../../domain/erlang/beam.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; + +const OPERATION = "inspect_erlang_beam"; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class BeamService { + constructor(readonly provider: BeamPort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const input = inspectErlangBeamInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + const result = await this.provider.inspect(input.data, options); + if (!result.ok) return result; + const report = erlangBeamSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + !validReport( + report.data, + input.data.max_chunks, + input.data.max_atoms, + input.data.max_file_bytes, + ) + ) + return err( + new AnalysisOutputError( + OPERATION, + "BEAM provider returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: "file", + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: [ + { kind: "artifact-path" as const, path: value.artifact.path }, + { + kind: "file-offset-range" as const, + start: value.form.location.offset, + end: value.form.location.offset + value.form.location.bytes, + }, + ], + }, + ), + options?.signal, + ); + } +} + +const validReport = ( + report: ErlangBeam, + maxChunks: number, + maxAtoms: number, + maxBytes: number, +): boolean => { + const inside = ({ offset, bytes }: { offset: number; bytes: number }) => + offset <= report.artifact.bytes - bytes; + if ( + report.artifact.bytes > maxBytes || + report.coverage.examined_chunks > maxChunks || + report.chunks.length > maxChunks || + report.coverage.atoms > maxAtoms || + (report.atoms !== null && report.atoms.length > maxAtoms) + ) + return false; + if (!inside(report.form.location)) return false; + if (report.code !== null && !inside(report.code.instructions)) return false; + return report.chunks.every( + (chunk) => inside(chunk.location) && inside(chunk.payload.location), + ); +}; diff --git a/src/cli.ts b/src/cli.ts index c0869e5027..0eabbc4664 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { registerGoCommands } from "./cli/goCommands.js"; import { registerModelCommands } from "./cli/modelCommands.js"; import { registerIl2cppCommands } from "./cli/il2cppCommands.js"; import { registerRustCommands } from "./cli/rustCommands.js"; +import { registerErlangCommands } from "./cli/erlangCommands.js"; import { registerSqliteCommands } from "./cli/sqliteCommands.js"; import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js"; import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js"; @@ -139,6 +140,7 @@ export const createCli = ( registerModelCommands(cli, logger); registerGpuCommands(cli, logger); registerRustCommands(cli, logger); + registerErlangCommands(cli, logger); registerSqliteCommands(cli, logger, environment); registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); registerManagedCommands(cli, logger, analysis.runProviderAnalysis); diff --git a/src/cli/erlangCommands.ts b/src/cli/erlangCommands.ts new file mode 100644 index 0000000000..dbdc21823d --- /dev/null +++ b/src/cli/erlangCommands.ts @@ -0,0 +1,55 @@ +import { z } from "incur"; +import { createBeamService } from "../composition/erlang.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { inspectErlangBeamInputSchema } from "../domain/erlang/beam.js"; +import { localPathStringSchema } from "../domain/localPath.js"; + +/** Resolve caller paths and inspect BEAM IFF tables through the shared workflow. */ +export const registerErlangCommands = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.inspectErlangBeam, { + description: + "Inspect Erlang BEAM atoms, exports, imports and the Code header without executing bytecode", + args: z.object({ + path: localPathStringSchema.describe("Local BEAM file"), + }), + options: z.object({ + maxFileBytes: inspectErlangBeamInputSchema.shape.max_file_bytes, + maxChunks: inspectErlangBeamInputSchema.shape.max_chunks, + maxAtoms: inspectErlangBeamInputSchema.shape.max_atoms, + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const service = createBeamService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectErlangBeam, + async () => + finishAnalysisCommand( + () => + service.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + max_chunks: options.maxChunks, + max_atoms: options.maxAtoms, + }, + { signal }, + ), + service, + "rea-erlang-beam", + "inspect_erlang_beam", + signal, + ), + ); + }), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index c00f0393dc..7e3cb64d2c 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -65,6 +65,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectModelArtifact: "inspect-model-artifact", inspectGpuCodeObjects: "inspect-gpu-code-objects", inspectRustBinary: "inspect-rust-binary", + inspectErlangBeam: "inspect-erlang-beam", inspectPeResources: "inspect-pe-resources", inspectPeCertificates: "inspect-pe-certificates", inspectNativeExports: "inspect-native-exports", diff --git a/src/composition/erlang.ts b/src/composition/erlang.ts new file mode 100644 index 0000000000..3727ac2a9f --- /dev/null +++ b/src/composition/erlang.ts @@ -0,0 +1,6 @@ +import { BeamService } from "../application/erlang/BeamService.js"; +import { BeamProvider } from "../erlang/BeamProvider.js"; + +/** Compose bundled BEAM inspection without an Erlang installation or any child process. */ +export const createBeamService = (): BeamService => + new BeamService(new BeamProvider()); diff --git a/src/contracts/erlang/beamToolContracts.ts b/src/contracts/erlang/beamToolContracts.ts new file mode 100644 index 0000000000..c3f3627235 --- /dev/null +++ b/src/contracts/erlang/beamToolContracts.ts @@ -0,0 +1,26 @@ +import { + erlangBeamSchema, + inspectErlangBeamInputSchema, +} from "../../domain/erlang/beam.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; + +/** Static BEAM IFF inspection is independent of active native-analysis providers. */ +export const ERLANG_BEAM_TOOL_CONTRACTS = [ + { + name: "inspect_erlang_beam", + ...toolContractMetadata("inspect_erlang_beam"), + kind: "artifact-provider", + description: + "Inspect one explicit local Erlang BEAM IFF container without executing bytecode or installing Erlang. Returns FOR1 form bounds, chunk names, payload SHA-256, Atom/AtU8 names, export/import/lambda tables with resolved atom names when present, and the Code chunk instruction-set header. Missing optional tables stay null rather than empty-everywhere. Reads a stable regular file; malformed FOR1/BEAM headers, truncated chunks and exhausted budgets return no partial success. Portable on supported Node hosts; no BEAM emulator is required.", + inputSchema: inspectErlangBeamInputSchema, + outputSchema: evidenceResultOf(erlangBeamSchema), + examples: [ + { + title: "Inspect BEAM atoms, exports and the Code header", + input: { path: "/artifacts/app.beam" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 1e2f1be484..a1426b7aa6 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -8,6 +8,7 @@ import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js"; import { MODEL_TOOL_CONTRACTS } from "./model/modelToolContracts.js"; import { IL2CPP_TOOL_CONTRACTS } from "./il2cpp/il2cppToolContracts.js"; import { RUST_TOOL_CONTRACTS } from "./rust/rustToolContracts.js"; +import { ERLANG_BEAM_TOOL_CONTRACTS } from "./erlang/beamToolContracts.js"; import { SQLITE_TOOL_CONTRACTS } from "./sqlite/sqliteToolContracts.js"; import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; @@ -54,6 +55,7 @@ export const TOOL_CONTRACTS = [ ...MODEL_TOOL_CONTRACTS, ...GPU_TOOL_CONTRACTS, ...RUST_TOOL_CONTRACTS, + ...ERLANG_BEAM_TOOL_CONTRACTS, ...WASM_TOOL_CONTRACTS, ...SQLITE_TOOL_CONTRACTS, ...CAPTURE_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 9fb9bd7078..31f2c13c3f 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -35,6 +35,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { inspect_go_binary: "reads embedded metadata without executing the target", inspect_rust_binary: "reads an embedded dependency list without executing the target", + inspect_erlang_beam: "states that it does not execute bytecode", inspect_sqlite_database: "uses a trusted inspector worker without executing application code or caller SQL", inspect_binary_layout: diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 6c1bf529a1..7dea6081da 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -131,6 +131,7 @@ export const TOOL_EFFECTS: Readonly> = { inspect_model_artifact: evidence, inspect_gpu_code_objects: evidence, inspect_rust_binary: evidence, + inspect_erlang_beam: evidence, inspect_sqlite_database: effects({ mutatesSession: true, writesFilesystem: true, diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 59ac2d6adf..1e71945d1b 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -28,6 +28,7 @@ export class ArtifactOperationError extends AnalysisError { | "inspect_pe_resources" | "inspect_pe_certificates" | "inspect_elf_notes" + | "inspect_erlang_beam" | "extract_artifact" | "decode_interface_builder" | "inspect_asset_catalog" diff --git a/src/domain/erlang/beam.test.ts b/src/domain/erlang/beam.test.ts new file mode 100644 index 0000000000..d7d978279e --- /dev/null +++ b/src/domain/erlang/beam.test.ts @@ -0,0 +1,76 @@ +import { expect, it } from "vitest"; +import { inspectErlangBeamInputSchema, erlangBeamSchema } from "./beam.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; + +const jsonSchema = (schema: { + readonly "~standard": { + readonly jsonSchema: { + readonly input: (options: { + readonly target: "draft-2020-12"; + }) => unknown; + }; + }; +}): Record => + schema["~standard"].jsonSchema.input({ + target: "draft-2020-12", + }) as Record; + +it("applies the shared absolute local-path policy to the public input", () => { + expect( + inspectErlangBeamInputSchema.safeParse({ path: "app.beam" }).success, + ).toBe(false); + expect(inspectErlangBeamInputSchema.safeParse({ path: "" }).success).toBe( + false, + ); + expect( + inspectErlangBeamInputSchema.safeParse({ path: "/artifacts/app\0.beam" }) + .success, + ).toBe(false); + expect( + inspectErlangBeamInputSchema.safeParse({ path: "/artifacts/app.beam" }) + .success, + ).toBe(true); + + const shared = jsonSchema(absoluteLocalPathStringSchema); + const advertised = jsonSchema(inspectErlangBeamInputSchema); + const path = ( + advertised.properties as Record> + ).path; + const { + $schema: _schema, + description: _sharedDescription, + ...sharedPolicy + } = shared; + const { description: _pathDescription, ...pathPolicy } = path ?? {}; + expect(pathPolicy).toEqual(sharedPolicy); + expect(path?.description).toBe("Absolute filesystem path to a BEAM file"); +}); + +it("rejects extra caller arguments and admits a BEAM form with no tables", () => { + expect( + inspectErlangBeamInputSchema.safeParse({ + path: "/artifacts/app.beam", + extra: true, + }).success, + ).toBe(false); + const report = erlangBeamSchema.parse({ + artifact: { + path: "/artifacts/app.beam", + sha256: "a".repeat(64), + bytes: 16, + }, + form: { location: { offset: 0, bytes: 12 }, size: 4 }, + chunks: [], + atoms: null, + atom_encoding: null, + exports: null, + imports: null, + lambdas: null, + code: null, + module: null, + coverage: { status: "complete", examined_chunks: 0, atoms: 0 }, + limitations: ["No atom table was present."], + }); + expect(report.atoms).toBeNull(); + expect(report.module).toBeNull(); +}); diff --git a/src/domain/erlang/beam.ts b/src/domain/erlang/beam.ts new file mode 100644 index 0000000000..f5344e53dd --- /dev/null +++ b/src/domain/erlang/beam.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; +import { publicInputStrictObject } from "../publicInput.js"; + +const uint = z.number().int().min(0).max(0xffffffff); +const range = z.strictObject({ offset: uint, bytes: uint }); + +/** Hard ceiling on the caller-selected input read budget. */ +export const MAX_INSPECT_ERLANG_BEAM_FILE_BYTES = 536870912; +/** Hard ceiling on IFF chunks in one BEAM form. */ +export const MAX_INSPECT_ERLANG_BEAM_CHUNKS = 256; +/** Hard ceiling on decoded atom-table entries. */ +export const MAX_INSPECT_ERLANG_BEAM_ATOMS = 65536; +/** Hard ceiling on export, import, and lambda rows combined. */ +export const MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS = 65536; + +/** Complete bounded inspection of one Erlang BEAM IFF container. */ +export const inspectErlangBeamInputSchema = publicInputStrictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to a BEAM file", + ), + max_file_bytes: z + .number() + .int() + .min(16) + .max(MAX_INSPECT_ERLANG_BEAM_FILE_BYTES) + .default(67108864) + .describe("Maximum stable artifact bytes to read (default 64 MiB)"), + max_chunks: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_ERLANG_BEAM_CHUNKS) + .default(64) + .describe( + "Maximum IFF chunks to examine; exceeding the budget fails without partial success", + ), + max_atoms: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_ERLANG_BEAM_ATOMS) + .default(4096) + .describe( + "Maximum atom-table entries to decode; exceeding the budget fails without partial success", + ), +}); + +const atomSchema = z.strictObject({ + index: uint, + name: z.string(), +}); + +const exportSchema = z.strictObject({ + function_index: uint, + function: z.string().nullable(), + arity: uint, + label: uint, +}); + +const importSchema = z.strictObject({ + module_index: uint, + module: z.string().nullable(), + function_index: uint, + function: z.string().nullable(), + arity: uint, +}); + +const lambdaSchema = z.strictObject({ + function_index: uint, + function: z.string().nullable(), + arity: uint, + label: uint, + index: uint, + free_vars: uint, + old_unique: uint, +}); + +const chunkSchema = z.strictObject({ + index: uint, + name: z.string().length(4), + location: range, + payload: z.strictObject({ + location: range, + bytes: uint, + sha256: digestSchema, + }), +}); + +/** Observed BEAM IFF form; missing optional tables stay null rather than empty-everywhere. */ +export const erlangBeamSchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string().min(1), + sha256: digestSchema, + bytes: uint, + }), + form: z.strictObject({ + location: range, + size: uint, + }), + chunks: z.array(chunkSchema), + atoms: z.array(atomSchema).nullable(), + atom_encoding: z.enum(["latin1", "utf8"]).nullable(), + exports: z.array(exportSchema).nullable(), + imports: z.array(importSchema).nullable(), + lambdas: z.array(lambdaSchema).nullable(), + code: z + .strictObject({ + instruction_set: uint, + opcode_max: uint, + labels: uint, + functions: uint, + extra_header_bytes: uint, + instructions: range, + }) + .nullable(), + module: z.string().nullable(), + coverage: z.strictObject({ + status: z.literal("complete"), + examined_chunks: uint, + atoms: uint, + }), + limitations: z.array(z.string()), +}); + +export type ErlangBeam = z.infer; +export type InspectErlangBeamInput = z.infer< + typeof inspectErlangBeamInputSchema +>; diff --git a/src/erlang/Beam.fixture.ts b/src/erlang/Beam.fixture.ts new file mode 100644 index 0000000000..da70e33661 --- /dev/null +++ b/src/erlang/Beam.fixture.ts @@ -0,0 +1,115 @@ +const concat = (...parts: Buffer[]): Buffer => Buffer.concat(parts); + +const u32 = (value: number): Buffer => { + const bytes = Buffer.alloc(4); + bytes.writeUInt32BE(value); + return bytes; +}; + +const pad4 = (bytes: Buffer): Buffer => { + const pad = (4 - (bytes.length % 4)) % 4; + return pad === 0 ? bytes : concat(bytes, Buffer.alloc(pad)); +}; + +const chunk = (name: string, payload: Buffer): Buffer => + concat(Buffer.from(name, "latin1"), u32(payload.length), pad4(payload)); + +const atoms = (names: readonly string[], utf8 = true): Buffer => { + const body = concat( + u32(names.length), + ...names.map((name) => { + const encoded = Buffer.from(name, utf8 ? "utf8" : "latin1"); + return concat(Buffer.from([encoded.length]), encoded); + }), + ); + return chunk(utf8 ? "AtU8" : "Atom", body); +}; + +const table = (name: string, rows: readonly (readonly number[])[]): Buffer => { + const payload = concat( + u32(rows.length), + ...rows.flatMap((row) => row.map(u32)), + ); + return chunk(name, payload); +}; + +/** Hand-authored BEAM IFF with AtU8, Code, ExpT, ImpT and FunT chunks. */ +export const erlangBeamFixture = (module = "beam_fixture"): Buffer => { + const atomNames = [ + module, + "module_info", + "start", + "erlang", + "get_module_info", + ]; + const codeHeader = concat( + u32(16), + u32(0), + u32(169), + u32(4), + u32(2), + Buffer.from("code"), + ); + const chunks = concat( + atoms(atomNames), + chunk("Code", codeHeader), + table("ExpT", [ + [3, 0, 2], + [2, 0, 1], + [2, 1, 3], + ]), + table("ImpT", [[4, 5, 1]]), + table("FunT", [[3, 1, 4, 0, 0, 42]]), + ); + const form = concat(Buffer.from("BEAM", "latin1"), chunks); + return concat(Buffer.from("FOR1", "latin1"), u32(form.length), form); +}; + +/** BEAM form that declares table counts and does not store row bodies. */ +export const erlangBeamDeclaredRowsFixture = ( + counts: readonly (readonly [string, number])[], +): Buffer => { + const chunks = concat( + ...counts.map(([name, count]) => chunk(name, u32(count))), + ); + const form = concat(Buffer.from("BEAM", "latin1"), chunks); + return concat(Buffer.from("FOR1", "latin1"), u32(form.length), form); +}; + +/** BEAM form whose atom table exceeds a caller-selected atom budget. */ +export const erlangBeamManyAtomsFixture = (count: number): Buffer => { + const names = Array.from( + { length: count }, + (_, index) => `a${String(index)}`, + ); + const chunks = atoms(names); + const form = concat(Buffer.from("BEAM", "latin1"), chunks); + return concat(Buffer.from("FOR1", "latin1"), u32(form.length), form); +}; + +/** + * Compiler-produced BEAM: `erlc +deterministic` from Erlang/OTP 29 (compiler + * 10.0.2), which writes the OTP 28+ compact AtU8 layout. Source: + * + * -module(rea_beam_fixture). + * -export([hello/1]). + * hello(Name) -> lists:reverse(Name). + */ +export const erlangBeamOtp29Fixture = (): Buffer => + Buffer.from( + [ + "Rk9SMQAAAixCRUFNQXRVOAAAAE3////5CBByZWFfYmVhbV9maXh0dXJlUGhlbGxv", + "UGxpc3RzcHJldmVyc2WwbW9kdWxlX2luZm9gZXJsYW5n8GdldF9tb2R1bGVfaW5m", + "bwAAAENvZGUAAABFAAAAEAAAAAAAAAC1AAAABwAAAAMBEJkQAhIiEAEgThAAATCZ", + "AAISUgABQEASA04QEAFQmQACElIQAWBAAxNAEgNOICADAAAAU3RyVAAAAABJbXBU", + "AAAAKAAAAAMAAAADAAAABAAAAAEAAAAGAAAABwAAAAEAAAAGAAAABwAAAAJFeHBU", + "AAAAKAAAAAMAAAAFAAAAAQAAAAYAAAAFAAAAAAAAAAQAAAACAAAAAQAAAAJNZXRh", + "AAAALYNsAAAAAWgCdxBlbmFibGVkX2ZlYXR1cmVzbAAAAAF3Cm1heWJlX2V4cHJq", + "agAAAExvY1QAAAAEAAAAAEF0dHIAAAAng2wAAAABaAJ3A3ZzbmwAAAABbhAAVsr2", + "2jNBkgUGfacAt0GWcGpqAENJbmYAAAAbg2wAAAABaAJ3B3ZlcnNpb25rAAYxMC4w", + "LjJqAERiZ2kAAABCg2gDdw1kZWJ1Z19pbmZvX3YxdxFlcmxfYWJzdHJhY3RfY29k", + "ZWgCdwRub25lbAAAAAF3DWRldGVybWluaXN0aWNqAABMaW5lAAAAFQAAAAAAAAAA", + "AAAAAwAAAAEAAAAAMQAAAFR5cGUAAAAKAAAABAAAAAEf/wAA", + ].join(""), + "base64", + ); diff --git a/src/erlang/BeamParser.test.ts b/src/erlang/BeamParser.test.ts new file mode 100644 index 0000000000..64695b30a0 --- /dev/null +++ b/src/erlang/BeamParser.test.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { + MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS, + inspectErlangBeamInputSchema, +} from "../domain/erlang/beam.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + erlangBeamDeclaredRowsFixture, + erlangBeamFixture, + erlangBeamManyAtomsFixture, + erlangBeamOtp29Fixture, +} from "./Beam.fixture.js"; +import { parseErlangBeam } from "./BeamParser.js"; + +const defaults = inspectErlangBeamInputSchema.parse({ + path: "/artifacts/app.beam", +}); + +const parse = (bytes: Buffer, input = defaults) => + parseErlangBeam( + bytes, + { + path: input.path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }, + input, + ); + +it("reads atoms, exports, imports, lambdas and the Code header", () => { + const report = parse(erlangBeamFixture("demo")); + expect(report.atom_encoding).toBe("utf8"); + expect(report.module).toBe("demo"); + expect(report.atoms?.map((atom) => atom.name)).toEqual([ + "demo", + "module_info", + "start", + "erlang", + "get_module_info", + ]); + expect(report.exports).toEqual([ + { function_index: 3, function: "start", arity: 0, label: 2 }, + { function_index: 2, function: "module_info", arity: 0, label: 1 }, + { function_index: 2, function: "module_info", arity: 1, label: 3 }, + ]); + expect(report.imports).toEqual([ + { + module_index: 4, + module: "erlang", + function_index: 5, + function: "get_module_info", + arity: 1, + }, + ]); + expect(report.lambdas?.[0]).toMatchObject({ + function: "start", + arity: 1, + label: 4, + old_unique: 42, + }); + expect(report.code).toMatchObject({ + instruction_set: 0, + opcode_max: 169, + labels: 4, + functions: 2, + extra_header_bytes: 0, + instructions: { bytes: 4 }, + }); + expect(report.chunks.map((chunk) => chunk.name)).toEqual([ + "AtU8", + "Code", + "ExpT", + "ImpT", + "FunT", + ]); +}); + +// Expected values come from beam_lib:chunks/2 on the same file under OTP 29. +it("reads a compiler-produced OTP 29 BEAM with the compact atom table", () => { + const report = parse(erlangBeamOtp29Fixture()); + expect(report.module).toBe("rea_beam_fixture"); + expect(report.atoms?.map((atom) => atom.name)).toEqual([ + "rea_beam_fixture", + "hello", + "lists", + "reverse", + "module_info", + "erlang", + "get_module_info", + ]); + expect(report.exports).toEqual([ + { function_index: 5, function: "module_info", arity: 1, label: 6 }, + { function_index: 5, function: "module_info", arity: 0, label: 4 }, + { function_index: 2, function: "hello", arity: 1, label: 2 }, + ]); + expect( + report.imports?.map((row) => [row.module, row.function, row.arity]), + ).toEqual([ + ["lists", "reverse", 1], + ["erlang", "get_module_info", 1], + ["erlang", "get_module_info", 2], + ]); + expect(report.code).toMatchObject({ + instruction_set: 0, + opcode_max: 181, + labels: 7, + functions: 3, + extra_header_bytes: 0, + instructions: { bytes: 49 }, + }); +}); + +it("fails a truncated FOR1 container without partial success", () => { + expect(() => parse(Buffer.from("FOR1"))).toThrow(ArtifactReaderFailure); +}); + +it("fails a FOR1 form that is not BEAM", () => { + const bytes = Buffer.alloc(16); + bytes.write("FOR1"); + bytes.writeUInt32BE(8, 4); + bytes.write("WAVE", 8); + expect(() => parse(bytes)).toThrow(ArtifactReaderFailure); +}); + +const singleChunkBeam = (name: string, payload: Buffer): Buffer => { + const header = Buffer.alloc(8); + header.write(name, 0, "latin1"); + header.writeUInt32BE(payload.length, 4); + const pad = (4 - (payload.length % 4)) % 4; + const form = Buffer.concat([ + Buffer.from("BEAM"), + header, + payload, + Buffer.alloc(pad), + ]); + const size = Buffer.alloc(4); + size.writeUInt32BE(form.length); + return Buffer.concat([Buffer.from("FOR1"), size, form]); +}; + +it("rejects a short remainder inside the BEAM form", () => { + const bytes = Buffer.alloc(16); + bytes.write("FOR1"); + bytes.writeUInt32BE(8, 4); + bytes.write("BEAM", 8); + expect(() => parse(bytes)).toThrow(/BEAM chunk header is truncated/); +}); + +it("rejects an AtU8 atom name that is not UTF-8", () => { + expect(() => + parse(singleChunkBeam("AtU8", Buffer.from([0, 0, 0, 1, 1, 0xff]))), + ).toThrow(/BEAM atom name is not valid UTF-8/); +}); + +it("keeps a latin1 atom byte unchanged", () => { + const report = parse( + singleChunkBeam("Atom", Buffer.from([0, 0, 0, 1, 1, 0xff])), + ); + expect(report.atom_encoding).toBe("latin1"); + expect(report.module).toBe("ÿ"); +}); + +it("rejects combined table rows before allocating them", () => { + const below = Math.floor(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS / 2); + const overflow = MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS - below * 2 + 1; + expect(() => + parse( + erlangBeamDeclaredRowsFixture([ + ["ExpT", below], + ["ImpT", below], + ["FunT", overflow], + ]), + ), + ).toThrow( + expect.objectContaining({ + reason: "limit", + boundary: "capacity", + message: expect.stringContaining("row budget"), + }), + ); + const count = Buffer.alloc(4); + count.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1); + expect(() => parse(singleChunkBeam("ExpT", count))).toThrow(/row budget/); + count.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS); + expect(() => parse(singleChunkBeam("ExpT", count))).toThrow(/truncated/); +}); + +it("fails when the atom budget is exhausted", () => { + const bytes = erlangBeamManyAtomsFixture(3); + expect(() => + parse( + bytes, + inspectErlangBeamInputSchema.parse({ + path: defaults.path, + max_atoms: 1, + }), + ), + ).toThrow(ArtifactReaderFailure); +}); diff --git a/src/erlang/BeamParser.ts b/src/erlang/BeamParser.ts new file mode 100644 index 0000000000..82a401fa5c --- /dev/null +++ b/src/erlang/BeamParser.ts @@ -0,0 +1,279 @@ +import { isUtf8 } from "node:buffer"; +import { createHash } from "node:crypto"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS, + type ErlangBeam, + type InspectErlangBeamInput, +} from "../domain/erlang/beam.js"; + +const fail = (message: string): never => { + throw new ArtifactReaderFailure("format", message); +}; + +const sha256 = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +const LIMITATIONS = [ + "This workflow does not execute BEAM bytecode, inflate LitT literals, or decode Attr/CInf external-term payloads.", + "Atom names are structural table entries; they do not authenticate the originating compiler or module checksum.", +] as const; + +const requireRange = (bytes: Buffer, offset: number, length: number): void => { + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(length) || + offset < 0 || + length < 0 || + offset > bytes.length - length + ) + fail( + `BEAM range ${String(offset)} + ${String(length)} leaves the artifact.`, + ); +}; + +const u32 = (bytes: Buffer, offset: number): number => { + requireRange(bytes, offset, 4); + return bytes.readUInt32BE(offset); +}; + +const chunkName = (bytes: Buffer, offset: number): string => { + requireRange(bytes, offset, 4); + return bytes.subarray(offset, offset + 4).toString("latin1"); +}; + +const atomAt = ( + atoms: readonly { index: number; name: string }[] | null, + index: number, +): string | null => { + if (atoms === null || index < 1) return null; + return atoms[index - 1]?.name ?? null; +}; + +/** Reads an atom length: one byte, or OTP 28+ compact `u` tag when compact. */ +const atomLength = ( + payload: Buffer, + at: number, + compact: boolean, +): [length: number, next: number] => { + if (at >= payload.length) fail("BEAM atom table is truncated."); + const first = payload[at]!; + if (!compact) return [first, at + 1]; + if ((first & 0x07) !== 0) fail("BEAM atom length is not a u tag."); + if ((first & 0x08) === 0) return [first >> 4, at + 1]; + if ((first & 0x10) !== 0) fail("BEAM atom length encoding is unsupported."); + if (at + 1 >= payload.length) fail("BEAM atom table is truncated."); + return [((first & 0xe0) << 3) | payload[at + 1]!, at + 2]; +}; + +const parseAtoms = ( + payload: Buffer, + maxAtoms: number, + encoding: "latin1" | "utf8", +): ErlangBeam["atoms"] => { + if (payload.length < 4) fail("BEAM atom table is truncated."); + // OTP 28+ writes a negated count and compact-encoded name lengths. + const declared = payload.readInt32BE(0); + const compact = declared < 0; + const count = Math.abs(declared); + if (count > maxAtoms) + throw new ArtifactReaderFailure( + "limit", + `BEAM atom table exceeds the ${String(maxAtoms)}-atom budget.`, + { boundary: "capacity" }, + ); + const atoms: NonNullable = []; + let at = 4; + for (let index = 1; index <= count; index += 1) { + const [length, next] = atomLength(payload, at, compact); + at = next; + requireRange(payload, at, length); + const nameBytes = payload.subarray(at, at + length); + if (encoding === "utf8" && !isUtf8(nameBytes)) + fail("BEAM atom name is not valid UTF-8."); + atoms.push({ + index, + name: nameBytes.toString(encoding), + }); + at += length; + } + if (at > payload.length) fail("BEAM atom table is truncated."); + return atoms; +}; + +const parseRows = ( + payload: Buffer, + width: number, + label: string, +): number[][] => { + if (payload.length < 4) fail(`BEAM ${label} table is truncated.`); + const count = u32(payload, 0); + const need = 4 + count * width * 4; + if (payload.length < need) fail(`BEAM ${label} table is truncated.`); + const rows: number[][] = []; + for (let index = 0; index < count; index += 1) { + const row: number[] = []; + const start = 4 + index * width * 4; + for (let field = 0; field < width; field += 1) + row.push(u32(payload, start + field * 4)); + rows.push(row); + } + return rows; +}; + +const parseCode = ( + payload: Buffer, + chunkOffset: number, +): NonNullable => { + if (payload.length < 20) fail("BEAM Code chunk is truncated."); + const infoSize = u32(payload, 0); + if (infoSize < 16 || infoSize > payload.length - 4) + fail("BEAM Code header size is invalid."); + const instructionsOffset = 4 + infoSize; + return { + instruction_set: u32(payload, 4), + opcode_max: u32(payload, 8), + labels: u32(payload, 12), + functions: u32(payload, 16), + extra_header_bytes: infoSize - 16, + instructions: { + offset: chunkOffset + 8 + instructionsOffset, + bytes: payload.length - instructionsOffset, + }, + }; +}; + +/** Decode one FOR1/BEAM IFF container into bounded chunk and table facts. */ +export const parseErlangBeam = ( + bytes: Buffer, + artifact: ErlangBeam["artifact"], + input: InspectErlangBeamInput, +): ErlangBeam => { + requireRange(bytes, 0, 12); + if (bytes.subarray(0, 4).toString("latin1") !== "FOR1") + fail("Expected a FOR1 IFF container."); + const formSize = u32(bytes, 4); + if (formSize < 4) fail("BEAM form size is shorter than the BEAM tag."); + requireRange(bytes, 8, formSize); + if (bytes.subarray(8, 12).toString("latin1") !== "BEAM") + fail("Expected a BEAM form type."); + const formEnd = 8 + formSize; + if ( + formEnd < bytes.length && + bytes.subarray(formEnd).some((byte) => byte !== 0) + ) + fail("BEAM form does not consume the selected file."); + const chunks: ErlangBeam["chunks"] = []; + const payloads = new Map(); + let at = 12; + while (at < formEnd) { + if (formEnd - at < 8) fail("BEAM chunk header is truncated."); + if (chunks.length >= input.max_chunks) + throw new ArtifactReaderFailure( + "limit", + `BEAM form exceeds the ${String(input.max_chunks)}-chunk budget.`, + { boundary: "capacity" }, + ); + const name = chunkName(bytes, at); + const size = u32(bytes, at + 4); + const payloadOffset = at + 8; + requireRange(bytes, payloadOffset, size); + const padded = payloadOffset + size + ((4 - (size % 4)) % 4); + if (padded > formEnd) fail("BEAM chunk padding exceeds the form."); + const padding = bytes.subarray(payloadOffset + size, padded); + if (padding.some((byte) => byte !== 0)) + fail("BEAM chunk padding is non-zero."); + const payload = bytes.subarray(payloadOffset, payloadOffset + size); + chunks.push({ + index: chunks.length, + name, + location: { offset: at, bytes: padded - at }, + payload: { + location: { offset: payloadOffset, bytes: size }, + bytes: size, + sha256: sha256(payload), + }, + }); + if (!payloads.has(name)) payloads.set(name, { payload, offset: at }); + at = padded; + } + const atomChunk = payloads.get("AtU8") ?? payloads.get("Atom"); + const atomEncoding = payloads.has("AtU8") + ? "utf8" + : payloads.has("Atom") + ? "latin1" + : null; + const atoms = + atomChunk === undefined || atomEncoding === null + ? null + : parseAtoms(atomChunk.payload, input.max_atoms, atomEncoding); + const declaredRows = (name: string): number => { + const payload = payloads.get(name)?.payload; + return payload !== undefined && payload.length >= 4 ? u32(payload, 0) : 0; + }; + if ( + declaredRows("ExpT") + declaredRows("ImpT") + declaredRows("FunT") > + MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + ) + throw new ArtifactReaderFailure( + "limit", + `BEAM tables exceed the ${String( + MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS, + )}-row budget.`, + { boundary: "capacity" }, + ); + const exports = + payloads.get("ExpT") === undefined + ? null + : parseRows(payloads.get("ExpT")!.payload, 3, "export").map((row) => ({ + function_index: row[0]!, + function: atomAt(atoms, row[0]!), + arity: row[1]!, + label: row[2]!, + })); + const imports = + payloads.get("ImpT") === undefined + ? null + : parseRows(payloads.get("ImpT")!.payload, 3, "import").map((row) => ({ + module_index: row[0]!, + module: atomAt(atoms, row[0]!), + function_index: row[1]!, + function: atomAt(atoms, row[1]!), + arity: row[2]!, + })); + const lambdas = + payloads.get("FunT") === undefined + ? null + : parseRows(payloads.get("FunT")!.payload, 6, "lambda").map((row) => ({ + function_index: row[0]!, + function: atomAt(atoms, row[0]!), + arity: row[1]!, + label: row[2]!, + index: row[3]!, + free_vars: row[4]!, + old_unique: row[5]!, + })); + const codeChunk = payloads.get("Code"); + const code = + codeChunk === undefined + ? null + : parseCode(codeChunk.payload, codeChunk.offset); + return { + artifact, + form: { location: { offset: 0, bytes: formEnd }, size: formSize }, + chunks, + atoms, + atom_encoding: atomEncoding, + exports, + imports, + lambdas, + code, + module: atoms?.[0]?.name ?? null, + coverage: { + status: "complete", + examined_chunks: chunks.length, + atoms: atoms?.length ?? 0, + }, + limitations: [...LIMITATIONS], + }; +}; diff --git a/src/erlang/BeamProvider.ts b/src/erlang/BeamProvider.ts new file mode 100644 index 0000000000..c7a1fe15f5 --- /dev/null +++ b/src/erlang/BeamProvider.ts @@ -0,0 +1,211 @@ +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + ArtifactFileCleanupFailure, + bindArtifactFileCleanup, + artifactFileCancellationCause, +} from "../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import type { BeamPort } from "../application/erlang/BeamPort.js"; +import type { ExecutionOptions } from "../application/AnalysisProvider.js"; +import { ArtifactOperationError } from "../domain/artifactOperationError.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { + MAX_INSPECT_ERLANG_BEAM_ATOMS, + MAX_INSPECT_ERLANG_BEAM_CHUNKS, + MAX_INSPECT_ERLANG_BEAM_FILE_BYTES, + MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS, + type InspectErlangBeamInput, +} from "../domain/erlang/beam.js"; +import { err, ok } from "../domain/result.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { parseErlangBeam } from "./BeamParser.js"; + +/** Bundled format reader identity; module names are separate observed facts. */ +export const ERLANG_BEAM_PROVIDER_IDENTITY = { + id: "rea-erlang-beam", + name: "REA Erlang BEAM reader", + version: "1", +} as const; + +const OPERATION = "inspect_erlang_beam"; + +/** Portable parser over one bounded, identity-checked regular file. */ +export class BeamProvider implements BeamPort { + readonly identity = ERLANG_BEAM_PROVIDER_IDENTITY; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + throw cause; + const error = new ProviderCleanupError( + ERLANG_BEAM_PROVIDER_IDENTITY.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: OPERATION }, + ); + bindArtifactFileCleanup(cause, error); + throw error; + } + } + + async inspect(input: InspectErlangBeamInput, options?: ExecutionOptions) { + try { + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + return ok( + parseErlangBeam( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + ), + ); + }); + } catch (cause) { + return err(beamFailure(cause, input, options?.signal)); + } + } +} + +const inputBudgetRemediation = ( + boundary: "file-size" | "capacity", + input: InspectErlangBeamInput, + message: string, +): string => { + if (boundary === "file-size") + return input.max_file_bytes >= MAX_INSPECT_ERLANG_BEAM_FILE_BYTES + ? `The file exceeds the ${String(MAX_INSPECT_ERLANG_BEAM_FILE_BYTES)}-byte input ceiling this operation accepts; select a smaller artifact.` + : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; + if (message.includes("atom")) + return input.max_atoms >= MAX_INSPECT_ERLANG_BEAM_ATOMS + ? `The file exceeds the ${String(MAX_INSPECT_ERLANG_BEAM_ATOMS)}-atom ceiling this operation accepts; select a smaller artifact.` + : `Raise max_atoms above ${String(input.max_atoms)} or select a smaller artifact; the limit is the caller-selected atom budget.`; + if (message.includes("row budget")) + return `The file exceeds the ${String(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS)}-row ceiling this operation accepts; select a smaller artifact.`; + return input.max_chunks >= MAX_INSPECT_ERLANG_BEAM_CHUNKS + ? `The file exceeds the ${String(MAX_INSPECT_ERLANG_BEAM_CHUNKS)}-chunk ceiling this operation accepts; select a smaller artifact.` + : `Raise max_chunks above ${String(input.max_chunks)} or select a smaller artifact; the limit is the caller-selected chunk budget.`; +}; + +const beamFailurePrimary = (cause: unknown, input: InspectErlangBeamInput) => { + if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { + cause, + }, + ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if (cause instanceof ArtifactReaderFailure) { + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + OPERATION, + cause.boundary ?? "memory", + cause.message, + { + max_file_bytes: input.max_file_bytes, + max_chunks: input.max_chunks, + max_atoms: input.max_atoms, + }, + { + cause, + ...(cause.boundary === undefined || cause.boundary === "memory" + ? {} + : { + retryable: false, + remediationAction: inputBudgetRemediation( + cause.boundary, + input, + cause.message, + ), + }), + }, + ); + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ]); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }); + if ( + ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes( + String(cause.code), + ) + ) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected BEAM file could not be read (${String(cause.code)}): ${input.path}.`, + }, + ]); + } + return new ArtifactOperationError( + OPERATION, + cause instanceof ArtifactReaderFailure ? cause.reason : "io", + undefined, + cause instanceof Error ? cause.message : "BEAM inspection failed.", + { cause }, + ); +}; + +const beamFailure = ( + cause: unknown, + input: InspectErlangBeamInput, + signal: AbortSignal | undefined, +) => { + const primary = + cause instanceof ArtifactFileCleanupFailure && + cause.fileCleanupFailure.outcome.kind === "failed" + ? beamFailurePrimary(cause.fileCleanupFailure.outcome.cause, input) + : cause instanceof ArtifactFileCleanupFailure && signal?.aborted + ? new AnalysisCancelledError(OPERATION, { cause: signal.reason }) + : beamFailurePrimary(cause, input); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + ERLANG_BEAM_PROVIDER_IDENTITY.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation: OPERATION, cause }, + ); + const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + bindArtifactFileCleanup(cause, error); + return error; +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index ca912ac282..902fe56aa9 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -52,6 +52,9 @@ import { registerIl2cppTools } from "./registerIl2cppTools.js"; import { createRustBinaryService } from "../composition/rust.js"; import type { RustBinaryService } from "../application/rust/RustBinaryService.js"; import { registerRustTools } from "./registerRustTools.js"; +import { createBeamService } from "../composition/erlang.js"; +import type { BeamService } from "../application/erlang/BeamService.js"; +import { registerErlangTools } from "./registerErlangTools.js"; import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js"; import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js"; import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js"; @@ -193,6 +196,7 @@ export interface CreateServerOptions { readonly goBinary?: GoBinaryService; readonly il2cppMetadata?: Il2cppMetadataService; readonly rustBinary?: RustBinaryService; + readonly erlangBeam?: BeamService; readonly electronFuses?: ElectronFuseService; readonly recordedCrash?: RecordedCrashService; readonly firmwareAnalysis?: FirmwareAnalysisPort; @@ -356,6 +360,7 @@ export const createServer = ( const desktopApplication = createDesktopApplicationService(); const goBinary = options.goBinary ?? createGoBinaryService(); const rustBinary = options.rustBinary ?? createRustBinaryService(); + const erlangBeam = options.erlangBeam ?? createBeamService(); const modelArtifact = createModelArtifactService(); const gpuCodeObjects = createGpuCodeObjectsService(); const il2cppMetadata = @@ -461,6 +466,7 @@ export const createServer = ( desktopApplication.close(), goBinary.close(), rustBinary.close(), + erlangBeam.close(), il2cppMetadata.close(), modelArtifact.close(), gpuCodeObjects.close(), @@ -529,6 +535,7 @@ export const createServer = ( artifactResources, goBinary, rustBinary, + erlangBeam, il2cppMetadata, peResources, nativeExports, @@ -763,6 +770,7 @@ const registerConfiguredAnalysisTools = ( withAdmittedAnalysis, goBinary, rustBinary, + erlangBeam, il2cppMetadata, peResources, nativeExports, @@ -862,6 +870,13 @@ const registerConfiguredAnalysisTools = ( withAdmittedAnalysis, evidenceById, ); + registerErlangTools( + server, + erlangBeam, + toolLogger, + recordEvidence, + withAdmittedAnalysis, + ); registerWasmTools( server, wasmArtifact, @@ -969,6 +984,7 @@ const createSessionRecorders = ( interface ServerToolContext extends ReturnType { readonly goBinary: GoBinaryService; readonly rustBinary: RustBinaryService; + readonly erlangBeam: BeamService; readonly il2cppMetadata: Il2cppMetadataService; readonly peResources: PeResourcesService; readonly nativeExports: NativeExportsService; diff --git a/src/server/registerErlangTools.ts b/src/server/registerErlangTools.ts new file mode 100644 index 0000000000..53c20c9f95 --- /dev/null +++ b/src/server/registerErlangTools.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { BeamService } from "../application/erlang/BeamService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind the exact BEAM contract to its shared workflow and owning session Evidence ledger. */ +export const registerErlangTools = ( + server: EvidenceMcpServer, + service: BeamService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_erlang_beam"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/erlangBeamCli.test.ts b/tests/acceptance/erlangBeamCli.test.ts new file mode 100644 index 0000000000..883c448d00 --- /dev/null +++ b/tests/acceptance/erlangBeamCli.test.ts @@ -0,0 +1,67 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { erlangBeamFixture } from "../../src/erlang/Beam.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest("reports BEAM exports through the public CLI", async ({ cli }) => { + const root = await createTestTempDirectory("rea-beam-cli-"); + const path = join(root, "app.beam"); + await writeFile(path, erlangBeamFixture("cli_mod")); + const result = await cli.run({ + arguments: ["inspect-erlang-beam", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_erlang_beam").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.module).toBe("cli_mod"); + expect( + evidence.normalized_result.exports?.some( + (entry) => entry.function === "start" && entry.arity === 0, + ), + ).toBe(true); +}); + +cliTest( + "resolves a relative BEAM path against the working directory", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-beam-cli-rel-"); + await writeFile(join(root, "app.beam"), erlangBeamFixture()); + const result = await cli.run({ + arguments: ["inspect-erlang-beam", "app.beam", "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_erlang_beam").outputSchema.parse( + result.json, + ); + expect(evidence.subject?.local_path).toBe(join(root, "app.beam")); + }, +); + +cliTest("rejects an empty BEAM path before reading", async ({ cli }) => { + const root = await createTestTempDirectory("rea-beam-cli-path-"); + const result = await cli.run({ + arguments: ["inspect-erlang-beam", "", "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).not.toBe(0); + expect(result.stdout).not.toContain("sha256"); +}); + +cliTest("reports a truncated BEAM as invalid input", async ({ cli }) => { + const root = await createTestTempDirectory("rea-beam-cli-bad-"); + const path = join(root, "broken.beam"); + await writeFile(path, Buffer.from("FOR1")); + const result = await cli.run({ + arguments: ["inspect-erlang-beam", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_erlang_beam" }, + }); +}); diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index d1d654c7b2..422a502125 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -221,6 +221,7 @@ const pathRequests: readonly [string, Record][] = [ ["inspect_pe_certificates", { path: nul }], ["inspect_native_exports", { path: nul }], ["inspect_elf_notes", { path: nul }], + ["inspect_erlang_beam", { path: nul }], ["inspect_binary_layout", { path: nul }], ["inspect_recorded_crash", { path: nul }], ["inspect_wasm_artifact", { path: nul }], diff --git a/tests/boundary/filesystem/erlangBeam.test.ts b/tests/boundary/filesystem/erlangBeam.test.ts new file mode 100644 index 0000000000..2d1fcec4c0 --- /dev/null +++ b/tests/boundary/filesystem/erlangBeam.test.ts @@ -0,0 +1,81 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createBeamService } from "../../../src/composition/erlang.js"; +import { MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS } from "../../../src/domain/erlang/beam.js"; +import { + erlangBeamDeclaredRowsFixture, + erlangBeamFixture, +} from "../../../src/erlang/Beam.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("Erlang BEAM stable filesystem boundary", () => { + it("reports non-file and malformed BEAM selections as caller input errors", async () => { + const root = await createTestTempDirectory("rea-beam-files-"); + const path = join(root, "app.beam"); + const alias = join(root, "alias.beam"); + const malformed = join(root, "malformed.beam"); + await writeFile(path, erlangBeamFixture()); + await writeFile(malformed, Buffer.from("not a beam")); + await symlink(path, alias); + const service = createBeamService(); + for (const [candidate, reason] of [ + [root, "invalid_format"], + [alias, "invalid_format"], + [malformed, "invalid_format"], + [join(root, "missing.beam"), "invalid_value"], + [join(path, "child.beam"), "invalid_value"], + ] as const) { + const result = await service.inspect({ path: candidate }); + if (result.ok) throw new Error("Expected file acquisition failure"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + operation: "inspect_erlang_beam", + issues: [ + { + path: ["path"], + reason, + message: expect.any(String), + }, + ], + }); + } + }); + + it("returns typed resource and cancellation failures", async () => { + const root = await createTestTempDirectory("rea-beam-budget-"); + const path = join(root, "app.beam"); + await writeFile(path, erlangBeamFixture()); + const service = createBeamService(); + const tooSmall = await service.inspect({ path, max_file_bytes: 16 }); + if (tooSmall.ok) throw new Error("Expected resource budget failure"); + expect(tooSmall.error._tag).toBe("AnalysisResourceConstraintError"); + const cancelled = await service.inspect( + { path }, + { signal: AbortSignal.abort() }, + ); + if (cancelled.ok) throw new Error("Expected cancellation"); + expect(cancelled.error._tag).toBe("AnalysisCancelledError"); + }); + + it("stops combined table rows at the shared budget", async () => { + const root = await createTestTempDirectory("rea-beam-rows-"); + const path = join(root, "rows.beam"); + const below = Math.floor(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS / 2); + await writeFile( + path, + erlangBeamDeclaredRowsFixture([ + ["ExpT", below], + ["ImpT", below], + ["FunT", MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS - below * 2 + 1], + ]), + ); + const result = await createBeamService().inspect({ path }); + if (result.ok) throw new Error("Expected row budget failure"); + expect(result.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "capacity", + remediationAction: `The file exceeds the ${String(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS)}-row ceiling this operation accepts; select a smaller artifact.`, + }); + }); +}); diff --git a/tests/boundary/mcp/erlangBeamMcp.test.ts b/tests/boundary/mcp/erlangBeamMcp.test.ts new file mode 100644 index 0000000000..5c865f5b8b --- /dev/null +++ b/tests/boundary/mcp/erlangBeamMcp.test.ts @@ -0,0 +1,69 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { erlangBeamFixture } from "../../../src/erlang/Beam.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; + +const connect = async (mcp: TestMcp) => { + const session = createTestBinarySession(() => { + throw new Error("BEAM inspection must not start a deep provider"); + }); + const client = await mcp.connect(createServer({ kind: "session", session })); + return { client, session }; +}; + +mcpTest( + "advertises inspect_erlang_beam and reads the module atom", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_erlang_beam", + ); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-beam-mcp-"); + const path = join(root, "app.beam"); + await writeFile(path, erlangBeamFixture("mcp_mod")); + const response = await client.callTool({ + name: "inspect_erlang_beam", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = toolContract("inspect_erlang_beam").outputSchema.parse( + response.structuredContent, + ); + expect(evidence.normalized_result.module).toBe("mcp_mod"); + expect(evidence.normalized_result.atom_encoding).toBe("utf8"); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "requires an absolute path for inspect_erlang_beam", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const response = await client.callTool({ + name: "inspect_erlang_beam", + arguments: { path: "app.beam" }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_erlang_beam" }, + }); + } finally { + await session.close(); + } + }, +);