diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index de9f6b7c12..39f9ae20b2 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -122,6 +122,14 @@ explicit path or endpoint and do not need it. absent metadata does not prove a binary is not Go, and unparsed text records remain explicit. See the [Go binary guide](https://github.com/morluto/rea/blob/main/docs/go-binaries.md). +- Embedded cargo-auditable Rust dependency metadata: `inspect_rust_binary` + with the explicit local ELF, PE, or thin Mach-O file. This built-in + target-free inspection requires no Rust installation or disassembler. Preserve + package order, dependency indices, and the section file range. A missing + `.dep-v0` section is null metadata, not proof the binary has no dependencies. + A rustc commit hash is an inference from a read-only `/rustc/<40-hex>/` path. + See the + [Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.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 3e185a8a2a..960e460945 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -11,6 +11,18 @@ unparsed module-text line. A missing record is an unknown, not proof of a non-Go binary; observed metadata does not authenticate its claims. Function, source and type recovery remain a separate native-analysis question. +## Rust dependency metadata + +Use `inspect_rust_binary` for an explicit local ELF, PE, or thin Mach-O when +the question concerns the cargo-auditable crate list embedded in `.dep-v0` +(Mach-O: `__DATA,.dep-v0`). No `open_binary`, Rust installation, or target +execution is required. The CLI equivalent is `inspect-rust-binary PATH`. +Preserve artifact SHA-256, package order, dependency indices, and the section +file range. A missing section is null metadata, not proof the binary has no +dependencies. An omitted package kind stays null. A rustc commit hash is +inferred from a read-only `/rustc/<40-hex>/` path and does not by itself +identify the compiler. See [Rust binaries](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md). + ## SQLite snapshots Use `inspect_sqlite_database` with an explicit local database snapshot for its diff --git a/README.md b/README.md index 53a86c5fcb..8129813d3d 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ Additional tools and host support depend on the target: | Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://rea.tools/guides/native/) | | Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) | | Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required | +| 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) | | 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 5d7fcc2f34..b4cf2e4ade 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -29,6 +29,7 @@ export default defineConfig({ items: [ { text: "Native binaries", link: "/native-investigation" }, { text: "Go build metadata", link: "/go-binaries" }, + { text: "Rust dependencies", link: "/rust-binaries" }, { text: "JavaScript and Electron", link: "/javascript-artifact-reconstruction", diff --git a/docs/cli.md b/docs/cli.md index c43ec21810..c801bf006d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -83,6 +83,18 @@ The shared CLI/MCP workflow reports compiler version, modules, replacements, build settings, original bytes and SHA-256. See [Go binaries](go-binaries.md) for supported containers and absent or malformed metadata. +Inspect an embedded cargo-auditable Rust dependency list without installing +Rust or configuring a disassembler: + +```bash +rea inspect-rust-binary /absolute/path/to/program --json +``` + +The same workflow reports package names, versions, sources, the root package, +dependency edges, and the section's file range. A missing `.dep-v0` section is +null metadata, not proof that the binary has no dependencies. See +[Rust binaries](rust-binaries.md). + Configure [Hopper or Ghidra](installation.md#hopper), or the [IDA adapter](ida-provider.md), before analyzing a native target. Substitute your target, search text and function name or address in these examples: diff --git a/docs/index.md b/docs/index.md index 732ad9f055..9e0912dd10 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,7 +49,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). | Task | Guides | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.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), [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), [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) | diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 8df2522bc5..db261f80bf 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -135,6 +135,7 @@ npx rea-agents@latest setup | الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](https://rea.tools/ar/guides/native/) | | بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../binary-diagnostics.md) | | شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) | | JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](https://rea.tools/ar/guides/javascript/) | | المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](https://rea.tools/ar/guides/browser/) | diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index 2dbb1d41a2..f8e81658bb 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -135,6 +135,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz | Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](https://rea.tools/de/guides/native/) | | Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../binary-diagnostics.md) | | EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) | | JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](https://rea.tools/de/guides/javascript/) | | Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](https://rea.tools/de/guides/browser/) | diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index d09be25e24..6bc927b187 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -135,6 +135,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h | Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](https://rea.tools/es/guides/native/) | | Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../binary-diagnostics.md) | | Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) | | 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](https://rea.tools/es/guides/javascript/) | | Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](https://rea.tools/es/guides/browser/) | diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index b24e4380c1..96364a10f5 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -127,6 +127,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np | باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/fa/guides/native/) | | ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../binary-diagnostics.md) | | بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](../recorded-crashes.md) | | JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/fa/guides/javascript/) | | وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/fa/guides/browser/) | diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index e6278ac932..09fc9ca580 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -139,6 +139,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les | Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](../binary-diagnostics.md) | | Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` | | Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crashs Linux enregistrés | Notes brutes, registres et signaux de chaque thread enregistré, et correspondances de mappings facultatives | pwntools fourni par l'utilisateur sous Linux x64 ; GDB/pwndbg en option ; [crashs enregistrés](../recorded-crashes.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](https://rea.tools/fr/guides/javascript/) | | Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](https://rea.tools/fr/guides/browser/) | diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index fba947fb2f..0eab5d71d2 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -142,6 +142,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](../binary-diagnostics.md) | | Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` | | Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](../recorded-crashes.md) | | JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/id/guides/javascript/) | | Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/id/guides/browser/) | diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index b7e7c61002..e2027f6601 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -139,6 +139,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument | Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](../binary-diagnostics.md) | | Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` | | Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crash Linux registrati | Note grezze, registri/segnai di ogni thread registrato e candidati mapping opzionali | pwntools fornito dal chiamante; GDB/pwndbg opzionale; [crash registrati](../recorded-crashes.md) | | JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](https://rea.tools/it/guides/javascript/) | | Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](https://rea.tools/it/guides/browser/) | diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index c9d7981ba0..250883ebd2 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -135,6 +135,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm | ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](https://rea.tools/ja/guides/native/) | | オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../binary-diagnostics.md) | | EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) | | JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/ja/guides/javascript/) | | ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/ja/guides/browser/) | diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index 26db0d824a..99f49ee3b4 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -135,6 +135,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니 | 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) | | 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) | | EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) | | JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) | | 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) | diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index 48200087d0..8f2f2ab077 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -135,6 +135,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na | Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](https://rea.tools/pl/guides/native/) | | Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../binary-diagnostics.md) | | Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) | | JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](https://rea.tools/pl/guides/javascript/) | | Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](https://rea.tools/pl/guides/browser/) | diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index 6c3f34e351..4650d4ecac 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -135,6 +135,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer | Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](https://rea.tools/pt-BR/guides/native/) | | Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../binary-diagnostics.md) | | Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) | | JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](https://rea.tools/pt-BR/guides/javascript/) | | Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](https://rea.tools/pt-BR/guides/browser/) | diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index 744e26f6c2..68ebf1a83f 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -135,6 +135,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так | Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](https://rea.tools/ru/guides/native/) | | Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) | | Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) | | JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](https://rea.tools/ru/guides/javascript/) | | Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](https://rea.tools/ru/guides/browser/) | diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 734b28c866..60818d2b63 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -135,6 +135,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26 | ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](https://rea.tools/th/guides/native/) | | โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../binary-diagnostics.md) | | ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) | | JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](https://rea.tools/th/guides/javascript/) | | เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](https://rea.tools/th/guides/browser/) | diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 5c77d80b08..2ed3892517 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -135,6 +135,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та | Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](https://rea.tools/uk/guides/native/) | | Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../binary-diagnostics.md) | | Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) | | JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](https://rea.tools/uk/guides/javascript/) | | Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](https://rea.tools/uk/guides/browser/) | diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index 3d2062da6b..cf0bd6592e 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -135,6 +135,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np | Tệp nhị phân mã máy | Mã giả, hợp ngữ, chuỗi, ký hiệu, lời gọi và tham chiếu | Hopper, Ghidra hoặc IDA; [phân tích mã máy](https://rea.tools/vi/guides/native/) | | Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) | | Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) | +| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) | | JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](https://rea.tools/vi/guides/javascript/) | | Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](https://rea.tools/vi/guides/browser/) | diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index 1ddbbd26ed..c8ad64e6ad 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -139,6 +139,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go | | 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) | | 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) | | JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](https://rea.tools/zh-TW/guides/javascript/) | | 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](https://rea.tools/zh-TW/guides/browser/) | diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 6e70fd3c3b..e5251eb36f 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -139,6 +139,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go | | 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) | | 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) | | JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/zh-CN/guides/javascript/) | | 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](https://rea.tools/zh-CN/guides/browser/) | diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md new file mode 100644 index 0000000000..d7cb7a7501 --- /dev/null +++ b/docs/rust-binaries.md @@ -0,0 +1,67 @@ +# Rust binary dependency metadata + +`inspect_rust_binary` / `inspect-rust-binary` answers which crate names, +versions, sources, and dependency edges are embedded by cargo-auditable in one +selected local executable or library. It reads the file without running it and +requires no Rust installation, active binary target, Hopper, Ghidra, or IDA. + +```bash +rea inspect-rust-binary /absolute/path/to/application --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_rust_binary", + "arguments": { "path": "/artifacts/application" } +} +``` + +## Observations + +The inline Evidence result contains the artifact path, SHA-256, byte length, +container format, architecture, word size, and byte order. When a `.dep-v0` +section is present, `auditable_metadata` contains: + +- `section_name`, `.dep-v0` for ELF and PE or `__DATA,.dep-v0` for thin Mach-O; +- `file_offset` and `section_bytes`, the exact file range of the compressed section; +- `compressed_bytes` and `uncompressed_bytes`; +- `format_version`, the embedded integer, or `null` when the field is omitted; +- `packages`, in the section's original order, with `name`, `version`, `source`, + `kind`, `dependencies`, and `root`; +- `root_package_index`, the single package marked `root`, or `null` when none is marked; +- `dependency_edges`, pairs of package indices in package-then-dependency order; +- `cyclic_dependency_indices`, sorted unique indices that participate in a cycle. + +An omitted `kind` stays `null`. The embedded format treats that omission as +runtime, and that default is not stored in the section. An omitted dependency +list is an empty array. An omitted `root` flag is false. + +`rustc_commit` is present only when a `/rustc/<40-hex>/` path is found in +read-only data. Its `evidence_kind` is `inferred`. The hash is not a claim that +that compiler produced the binary. Several distinct hashes leave a single +commit unknown. + +## Limits and failures + +The selected file is limited to 256 MiB. A dependency section is limited to 1 +MiB compressed. Inflated JSON is limited to the smaller of 8 MiB and 64 times +the compressed size. The complete report is limited to 8 MiB. Empty sections, +truncated or trailing zlib bytes, invalid JSON, unknown fields, dependency +indices outside the package list, more than one root package, and compressed +dependency sections are typed failures. A cycle is reported and is not walked. + +A missing section returns success with `auditable_metadata: null` and a +limitation. Absence of `.dep-v0` does not mean the binary has no dependencies. +Embedded names and versions are file content. They do not authenticate the +packages or the compiler. + +Universal Mach-O containers are unsupported. Select a thin slice first. ELF, +PE, and thin Mach-O are the supported containers. + +Package and dependency counts are checked against the minimum required JSON +representation before graph allocation or schema cloning. Oversized graphs +report the output byte budget; the compiler-path capacity reports +`maximum_rustc_hashes` in hashes, independently of byte limits. diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index d924ef49c3..e480665e39 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -28,6 +28,7 @@ export const toolFamilyCatalog = (sources) => { ...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, ...sources.evmContracts.EVM_TOOL_CONTRACTS, ...sources.goContracts.GO_TOOL_CONTRACTS, + ...sources.rustContracts.RUST_TOOL_CONTRACTS, ...sources.wasmContracts.WASM_TOOL_CONTRACTS, ...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, ...sources.captureContracts.CAPTURE_TOOL_CONTRACTS, @@ -225,6 +226,10 @@ export const providerCatalog = (sources) => { identity: sources.goProvider.GO_BINARY_PROVIDER_IDENTITY, contracts: sources.goContracts.GO_TOOL_CONTRACTS, }, + { + identity: sources.rustProvider.RUST_BINARY_PROVIDER_IDENTITY, + contracts: sources.rustContracts.RUST_TOOL_CONTRACTS, + }, { identity: sources.sqliteDomain.SQLITE_PROVIDER_IDENTITY, contracts: sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index f5488a9e6c..1d402ed4e1 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -59,6 +59,8 @@ const SOURCE_PATHS = { evmProvider: "dist/evm/EvmoleRelease.js", goContracts: "dist/contracts/go/goToolContracts.js", goProvider: "dist/go/GoBinaryProvider.js", + rustContracts: "dist/contracts/rust/rustToolContracts.js", + rustProvider: "dist/rust/RustBinaryProvider.js", artifactContracts: "dist/contracts/artifactToolContracts.js", managedContracts: "dist/contracts/managed/managedToolContracts.js", firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js", diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs index 160dad7c06..a6c74491cd 100644 --- a/scripts/lib/module-boundaries.mjs +++ b/scripts/lib/module-boundaries.mjs @@ -8,6 +8,7 @@ const MIGRATED_PROVIDER_ROOTS = new Set([ "sqlite", "evm", "go", + "rust", "wasm", "captures", "signals", diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index f4584eb295..925ed9ee3d 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -343,7 +343,7 @@ const workflowAvailabilityFor = ({ kind, policy, }: AvailabilityContext): Availability | null => { - if (name === "inspect_go_binary") + if (name === "inspect_go_binary" || name === "inspect_rust_binary") return { reason: "available", remediation: null }; if (name === "inspect_sqlite_database") { const capability = sqliteDatabaseCapability(process.versions.node); diff --git a/src/application/rust/RustBinaryPort.ts b/src/application/rust/RustBinaryPort.ts new file mode 100644 index 0000000000..b0956aea11 --- /dev/null +++ b/src/application/rust/RustBinaryPort.ts @@ -0,0 +1,19 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + InspectRustBinaryInput, + RustBinary, +} from "../../domain/rust/rustBinary.js"; +import type { Result } from "../../domain/result.js"; + +/** Replaceable offline dependency reader with no target-execution authority. */ +export interface RustBinaryPort { + readonly identity: ProviderIdentity; + inspect( + input: InspectRustBinaryInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/rust/RustBinaryService.test.ts b/src/application/rust/RustBinaryService.test.ts new file mode 100644 index 0000000000..7fbee615d0 --- /dev/null +++ b/src/application/rust/RustBinaryService.test.ts @@ -0,0 +1,132 @@ +import { expect, it } from "vitest"; +import { + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import type { RustBinary } from "../../domain/rust/rustBinary.js"; +import { ok } from "../../domain/result.js"; +import { RustBinaryService } from "./RustBinaryService.js"; + +const identity = { + id: "fixture-rust", + name: "Source-owned Rust reader seam", + version: "1", +}; +const report: RustBinary = { + artifact: { path: "/artifacts/program", sha256: "a".repeat(64), bytes: 128 }, + format: "elf", + architecture: "amd64", + bits: 64, + byte_order: "little", + auditable_metadata: { + section_name: ".dep-v0", + file_offset: 16, + section_bytes: 8, + compressed_bytes: 8, + uncompressed_bytes: 20, + format_version: 1, + packages: [ + { + name: "sample", + version: "0.1.0", + source: "local", + kind: null, + dependencies: [], + root: true, + }, + ], + root_package_index: 0, + dependency_edges: [], + cyclic_dependency_indices: [], + }, + rustc_commit: null, + limitations: [ + "No /rustc/<40-hex>/ path was found in read-only data, so a rustc commit hash is unknown.", + ], +}; + +it("rejects a relative path and extra arguments before reading", async () => { + let calls = 0; + const service = new RustBinaryService({ + identity, + inspect: () => { + calls += 1; + return Promise.resolve(ok(report)); + }, + }); + const relative = await service.inspect({ path: "program" }); + const extra = await service.inspect({ + path: report.artifact.path, + detail: "complete", + }); + expect(relative.ok).toBe(false); + expect(extra.ok).toBe(false); + if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError); + if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError); + expect(calls).toBe(0); +}); + +it("records the selected artifact and the dependency section range", async () => { + const result = await new RustBinaryService({ + 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: "elf", architecture: "x86_64" }, + normalized_result: report, + locations: [ + { kind: "artifact-path", path: report.artifact.path }, + { kind: "file-offset-range", start: 16, end: 24 }, + ], + }); + const mismatched: RustBinary = { + ...report, + artifact: { ...report.artifact, path: "/artifacts/other" }, + }; + const invalid = await new RustBinaryService({ + identity, + inspect: () => Promise.resolve(ok(mismatched)), + }).inspect({ path: report.artifact.path }); + expect(invalid.ok).toBe(false); + if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("rejects a cycle claim the package dependencies do not contain", async () => { + if (report.auditable_metadata === null) throw new Error("expected metadata"); + const invalidReport: RustBinary = { + ...report, + auditable_metadata: { + ...report.auditable_metadata, + cyclic_dependency_indices: [0], + }, + }; + const result = await new RustBinaryService({ + identity, + inspect: () => Promise.resolve(ok(invalidReport)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it.each([ + { from_index: 1, to_index: 0 }, + { from_index: 0, to_index: 1 }, +])("rejects provider edges referencing missing packages: %j", async (edge) => { + if (report.auditable_metadata === null) throw new Error("expected metadata"); + const invalidReport: RustBinary = { + ...report, + auditable_metadata: { + ...report.auditable_metadata, + dependency_edges: [edge], + }, + }; + const result = await new RustBinaryService({ + identity, + inspect: () => Promise.resolve(ok(invalidReport)), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); diff --git a/src/application/rust/RustBinaryService.ts b/src/application/rust/RustBinaryService.ts new file mode 100644 index 0000000000..b112b366d5 --- /dev/null +++ b/src/application/rust/RustBinaryService.ts @@ -0,0 +1,113 @@ +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { RustBinaryPort } from "./RustBinaryPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js"; +import { + inspectRustBinaryInputSchema, + rustBinarySchema, +} from "../../domain/rust/rustBinary.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +const OPERATION = "inspect_rust_binary"; + +const architectureForSubject = (architecture: string) => { + switch (architecture) { + case "386": + return "x86"; + case "amd64": + return "x86_64"; + default: + return BINARY_ARCHITECTURES.find( + (supported) => supported === architecture, + ); + } +}; + +/** Shared exact input validation and artifact-bound Evidence for CLI and MCP. */ +export class RustBinaryService { + constructor(readonly provider: RustBinaryPort) {} + + /** Inspect one explicitly selected file without acquiring a binary session. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const input = inspectRustBinaryInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + if (!isAbsolute(input.data.path)) + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem path on this host.", + }, + ]), + ); + const inspected = await this.provider.inspect(input.data, options); + if (!inspected.ok) return inspected; + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const report = rustBinarySchema.safeParse(inspected.value); + if (!report.success || report.data.artifact.path !== input.data.path) + return err( + new AnalysisOutputError( + OPERATION, + "Rust dependency reader returned malformed metadata or changed the selected artifact identity.", + ), + ); + return ok(evidenceFor(this.provider.identity, input.data, report.data)); + } +} + +const evidenceFor = ( + provider: RustBinaryPort["identity"], + parameters: { readonly path: string }, + value: ReturnType, +): Evidence => { + const architecture = architectureForSubject(value.architecture); + const section = value.auditable_metadata; + return createEvidence( + { + path: value.artifact.path, + sha256: value.artifact.sha256, + format: value.format === "macho" ? "mach-o" : value.format, + ...(architecture === undefined ? {} : { architecture }), + }, + provider, + { + operation: OPERATION, + parameters, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: [ + { kind: "artifact-path", path: value.artifact.path }, + ...(section === null + ? [] + : [ + { + kind: "file-offset-range" as const, + start: section.file_offset, + end: section.file_offset + section.section_bytes, + }, + ]), + ], + }, + ); +}; diff --git a/src/cli.ts b/src/cli.ts index d1bdf29124..d5b0092418 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; import { createDirectAnalysis } from "./composition/directAnalysis.js"; import { registerEvmCommands } from "./cli/evmCommands.js"; import { registerGoCommands } from "./cli/goCommands.js"; +import { registerRustCommands } from "./cli/rustCommands.js"; import { registerSqliteCommands } from "./cli/sqliteCommands.js"; import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js"; import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js"; @@ -101,6 +102,7 @@ export const createCli = ( registerSignalCommands(cli, logger, environment); registerEvmCommands(cli, logger, environment); registerGoCommands(cli, logger); + registerRustCommands(cli, logger); registerSqliteCommands(cli, logger, environment); registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); registerManagedCommands(cli, logger, analysis.runProviderAnalysis); diff --git a/src/cli/rustCommands.ts b/src/cli/rustCommands.ts new file mode 100644 index 0000000000..fc3f8ee858 --- /dev/null +++ b/src/cli/rustCommands.ts @@ -0,0 +1,34 @@ +import { z } from "incur"; +import { resolve } from "node:path"; +import { createRustBinaryService } from "../composition/rust.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; + +/** Resolve caller paths and inspect embedded Rust dependencies through the shared workflow. */ +export const registerRustCommands = ( + cli: CliInstance, + logger: Logger, +): void => { + const service = createRustBinaryService(); + cli.command(CLI_COMMANDS.inspectRustBinary, { + description: + "Inspect an embedded cargo-auditable Rust dependency list without target execution", + args: z.object({ + path: z.string().describe("Local ELF, PE or thin Mach-O image"), + }), + run: ({ args }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => { + const result = await service.inspect( + { path: resolve(args.path) }, + { signal }, + ); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index ff208c62ea..70a3a4d378 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -50,6 +50,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectRecordedCrash: "inspect-recorded-crash", inspectBinaryLayout: "inspect-binary-layout", inspectGoBinary: "inspect-go-binary", + inspectRustBinary: "inspect-rust-binary", inspectPeResources: "inspect-pe-resources", inspectAnalysisView: "inspect-analysis-view", listFridaDevices: "list-frida-devices", diff --git a/src/composition/rust.ts b/src/composition/rust.ts new file mode 100644 index 0000000000..5a05eceb3f --- /dev/null +++ b/src/composition/rust.ts @@ -0,0 +1,6 @@ +import { RustBinaryService } from "../application/rust/RustBinaryService.js"; +import { RustBinaryProvider } from "../rust/RustBinaryProvider.js"; + +/** Compose bundled offline inspection without a Rust toolchain or any child process. */ +export const createRustBinaryService = (): RustBinaryService => + new RustBinaryService(new RustBinaryProvider()); diff --git a/src/contracts/rust/rustToolContracts.ts b/src/contracts/rust/rustToolContracts.ts new file mode 100644 index 0000000000..7f827dfbed --- /dev/null +++ b/src/contracts/rust/rustToolContracts.ts @@ -0,0 +1,26 @@ +import { + inspectRustBinaryInputSchema, + rustBinarySchema, +} from "../../domain/rust/rustBinary.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; + +/** Static Rust dependency metadata is independent of active native-analysis providers. */ +export const RUST_TOOL_CONTRACTS = [ + { + name: "inspect_rust_binary", + ...toolContractMetadata("inspect_rust_binary"), + kind: "artifact-provider", + description: + "Inspect the cargo-auditable crate dependency list embedded in an explicit local ELF, PE or thin Mach-O image. Returns the artifact SHA-256, the .dep-v0 section file offset and size, compressed and uncompressed sizes, format version, packages in their original order, the root package index, and dependency edges by index. Reads zlib-compressed JSON without installing Rust, opening a disassembler, executing the target, writing temporary files or accessing the network. A missing section is null metadata with a limitation and is not proof that the binary has no dependencies. A rustc commit hash is reported only as an inference when a /rustc/<40-hex>/ path is present in read-only data. Resource guards bound files to 256 MiB, dependency sections to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the compressed size, and the complete report to 8 MiB. Malformed, unsupported and oversized inputs receive typed failures rather than partial success.", + inputSchema: inspectRustBinaryInputSchema, + outputSchema: evidenceResultOf(rustBinarySchema), + examples: [ + { + title: "Inspect an embedded Rust dependency list", + input: { path: "/artifacts/application" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 2fdac1fbed..39b65b3d2b 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -3,6 +3,7 @@ import { SIGNAL_TOOL_CONTRACTS } from "./signals/signalToolContracts.js"; import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js"; import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js"; +import { RUST_TOOL_CONTRACTS } from "./rust/rustToolContracts.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"; @@ -40,6 +41,7 @@ export const TOOL_CONTRACTS = [ ...ARTIFACT_TOOL_CONTRACTS, ...EVM_TOOL_CONTRACTS, ...GO_TOOL_CONTRACTS, + ...RUST_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 3a4acca99b..adf4d09329 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -19,6 +19,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { resolve_native_call_targets: "states that it does not execute code", inspect_pe_resources: "states that it does not execute the image", inspect_go_binary: "reads embedded metadata without executing the target", + inspect_rust_binary: + "reads an embedded dependency list without executing the target", 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 377905480a..137e548019 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -54,6 +54,7 @@ const sessionEvidence = effects({ mutatesSession: true }); /** Explicit effect audit for every public tool. */ export const TOOL_EFFECTS: Readonly> = { inspect_go_binary: evidence, + inspect_rust_binary: evidence, inspect_sqlite_database: effects({ mutatesSession: true, writesFilesystem: true, diff --git a/src/domain/rust/rustBinary.ts b/src/domain/rust/rustBinary.ts new file mode 100644 index 0000000000..f09cde222c --- /dev/null +++ b/src/domain/rust/rustBinary.ts @@ -0,0 +1,294 @@ +import { z } from "zod"; +import { digestSchema } from "../digests.js"; +import { localPathStringSchema } from "../localPath.js"; + +export const RUST_AUDITABLE_FORMAT_VERSION = 1; + +const index = z.number().int().nonnegative(); +const rustcHash = z.string().regex(/^[0-9a-f]{40}$/u); + +/** One embedded package, with omitted kind and root preserved as null and false. */ +export const rustPackageSchema = z.strictObject({ + name: z.string().min(1), + version: z.string().min(1), + source: z.string().min(1), + kind: z.enum(["runtime", "build"]).nullable(), + dependencies: z.array(index), + root: z.boolean(), +}); + +const dependencyEdgeSchema = z.strictObject({ + from_index: index, + to_index: index, +}); + +/** zlib JSON from one `.dep-v0` section, in the section's package order. */ +export const rustAuditableMetadataSchema = z.strictObject({ + section_name: z.enum([".dep-v0", "__DATA,.dep-v0"]), + file_offset: index, + section_bytes: z.number().int().positive(), + compressed_bytes: z.number().int().positive(), + uncompressed_bytes: z.number().int().positive(), + format_version: z.literal(RUST_AUDITABLE_FORMAT_VERSION).nullable(), + packages: z.array(rustPackageSchema), + root_package_index: index.nullable(), + dependency_edges: z.array(dependencyEdgeSchema), + cyclic_dependency_indices: z.array(index), +}); + +/** Inferred compiler id from read-only `/rustc/<40-hex>/` paths. */ +export const rustcCommitSchema = z.strictObject({ + evidence_kind: z.literal("inferred"), + hashes: z + .array( + z.strictObject({ + hash: rustcHash, + file_offset: index, + }), + ) + .min(1), +}); + +/** One explicit local executable or library, independent of a disassembler target. */ +export const inspectRustBinaryInputSchema = z.strictObject({ + path: localPathStringSchema.describe( + "Absolute filesystem path to the selected executable or library", + ), +}); + +/** Bounded cargo-auditable observation. A missing section is null, not an empty dependency list. */ +export const rustBinarySchema = z + .strictObject({ + artifact: z.strictObject({ + path: localPathStringSchema, + sha256: digestSchema, + bytes: index, + }), + format: z.enum(["elf", "pe", "macho"]), + architecture: z.string(), + bits: z.union([z.literal(32), z.literal(64)]), + byte_order: z.enum(["little", "big"]), + auditable_metadata: rustAuditableMetadataSchema.nullable(), + rustc_commit: rustcCommitSchema.nullable(), + limitations: z.array(z.string()), + }) + .superRefine((report, context) => { + const metadata = report.auditable_metadata; + if (metadata !== null) { + checkSection(report.artifact.bytes, metadata, context); + checkPackages(metadata, context); + } + checkRustc(report.artifact.bytes, report.rustc_commit, context); + }); + +export type RustPackage = z.infer; +export type RustAuditableMetadata = z.infer; +export type RustBinary = z.infer; +export type InspectRustBinaryInput = z.infer< + typeof inspectRustBinaryInputSchema +>; + +const issue = ( + context: z.RefinementCtx, + path: (string | number)[], + message: string, +): void => { + context.addIssue({ code: "custom", path, message }); +}; + +const inside = (fileBytes: number, offset: number, size: number): boolean => + offset <= fileBytes && size <= fileBytes - offset; + +const checkSection = ( + fileBytes: number, + metadata: RustAuditableMetadata, + context: z.RefinementCtx, +): void => { + if ( + !inside(fileBytes, metadata.file_offset, metadata.section_bytes) || + metadata.compressed_bytes !== metadata.section_bytes + ) + issue( + context, + ["auditable_metadata", "file_offset"], + "Dependency section range is outside the selected artifact or disagrees with its compressed size.", + ); +}; + +const checkPackages = ( + metadata: RustAuditableMetadata, + context: z.RefinementCtx, +): void => { + const roots: number[] = []; + const edgesByPackage = new Map< + number, + z.infer[] + >(); + for (const [position, edge] of metadata.dependency_edges.entries()) { + if ( + edge.from_index >= metadata.packages.length || + edge.to_index >= metadata.packages.length + ) { + issue( + context, + ["auditable_metadata", "dependency_edges", position], + "Dependency edge index is outside the package list.", + ); + continue; + } + const grouped = edgesByPackage.get(edge.from_index); + if (grouped === undefined) edgesByPackage.set(edge.from_index, [edge]); + else grouped.push(edge); + } + for (const [packageIndex, pkg] of metadata.packages.entries()) { + if (pkg.root) roots.push(packageIndex); + const edges = edgesByPackage.get(packageIndex) ?? []; + if ( + edges.length !== pkg.dependencies.length || + edges.some((edge, index) => edge.to_index !== pkg.dependencies[index]) + ) + issue( + context, + ["auditable_metadata", "dependency_edges"], + "Dependency edges do not match package dependency indices.", + ); + if (pkg.dependencies.some((target) => target >= metadata.packages.length)) + issue( + context, + ["auditable_metadata", "packages", packageIndex, "dependencies"], + "Dependency index is outside the package list.", + ); + } + const root = metadata.root_package_index; + if ( + root === null ? roots.length !== 0 : roots.length !== 1 || roots[0] !== root + ) + issue( + context, + ["auditable_metadata", "root_package_index"], + "Root package index does not match packages marked root.", + ); + const cycles = cyclicDependencyIndices(metadata.packages); + if ( + cycles.length !== metadata.cyclic_dependency_indices.length || + cycles.some( + (index, position) => + metadata.cyclic_dependency_indices[position] !== index, + ) + ) + issue( + context, + ["auditable_metadata", "cyclic_dependency_indices"], + "Cyclic dependency indices do not match the cycles in package dependencies.", + ); +}; + +/** Package indices that participate in a dependency cycle, in ascending order. */ +export const cyclicDependencyIndices = ( + packages: readonly { readonly dependencies: readonly number[] }[], +): number[] => { + const edges = packages.map((pkg) => pkg.dependencies); + const order = dependencyPostorder(edges); + const reversed = reversedDependencyEdges(edges); + const seen = new Uint8Array(edges.length); + const cyclic = new Set(); + for (let cursor = order.length - 1; cursor >= 0; cursor -= 1) { + const start = order[cursor]; + if (start === undefined || seen[start] === 1) continue; + const component = dependencyComponent(start, reversed, seen); + if (dependencyComponentInCycle(component, edges)) + for (const index of component) cyclic.add(index); + } + return [...cyclic].sort((left, right) => left - right); +}; + +interface DependencyWalkFrame { + readonly index: number; + edge: number; +} + +const dependencyPostorder = ( + edges: readonly (readonly number[])[], +): number[] => { + const seen = new Uint8Array(edges.length); + const order: number[] = []; + for (let start = 0; start < edges.length; start += 1) { + if (seen[start] === 1) continue; + const stack: DependencyWalkFrame[] = [{ index: start, edge: 0 }]; + seen[start] = 1; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (frame === undefined) break; + const next = edges[frame.index]?.[frame.edge]; + if (next === undefined) { + stack.pop(); + order.push(frame.index); + continue; + } + frame.edge += 1; + if (seen[next] === 0) { + seen[next] = 1; + stack.push({ index: next, edge: 0 }); + } + } + } + return order; +}; + +const reversedDependencyEdges = ( + edges: readonly (readonly number[])[], +): number[][] => { + const reversed = Array.from({ length: edges.length }, () => [] as number[]); + for (let from = 0; from < edges.length; from += 1) + for (const to of edges[from] ?? []) reversed[to]?.push(from); + return reversed; +}; + +const dependencyComponent = ( + start: number, + reversed: readonly (readonly number[])[], + seen: Uint8Array, +): number[] => { + const component: number[] = []; + const stack = [start]; + seen[start] = 1; + while (stack.length > 0) { + const index = stack.pop(); + if (index === undefined) break; + component.push(index); + for (const next of reversed[index] ?? []) { + if (seen[next] === 0) { + seen[next] = 1; + stack.push(next); + } + } + } + return component; +}; + +const dependencyComponentInCycle = ( + component: readonly number[], + edges: readonly (readonly number[])[], +): boolean => { + if (component.length > 1) return true; + const only = component[0]; + return only !== undefined && (edges[only]?.includes(only) ?? false); +}; + +const checkRustc = ( + fileBytes: number, + commit: RustBinary["rustc_commit"], + context: z.RefinementCtx, +): void => { + if (commit === null) return; + const seen = new Set(); + for (const [position, entry] of commit.hashes.entries()) { + if (seen.has(entry.hash) || !inside(fileBytes, entry.file_offset, 48)) + issue( + context, + ["rustc_commit", "hashes", position], + "rustc path hash is duplicated or its file offset is outside the artifact.", + ); + seen.add(entry.hash); + } +}; diff --git a/src/go/GoBinaryContainer.ts b/src/go/GoBinaryContainer.ts index 56fcb2b741..96b4cb1469 100644 --- a/src/go/GoBinaryContainer.ts +++ b/src/go/GoBinaryContainer.ts @@ -34,6 +34,16 @@ export interface GoZeroFillMapping { readonly size: bigint; } +/** One named file section. Invalid declared ranges stay visible with `file_backed: false`. */ +export interface GoImageSection { + readonly name: string; + readonly offset: number; + readonly size: number; + readonly file_backed: boolean; + readonly read_only: boolean; + readonly compressed: boolean; +} + /** Native container facts and its producer-defined build-info search area. */ export interface GoBinaryContainer { readonly format: "elf" | "pe" | "macho"; @@ -42,6 +52,7 @@ export interface GoBinaryContainer { readonly byte_order: "little" | "big"; readonly mappings: readonly GoFileMapping[]; readonly zero_fills: readonly GoZeroFillMapping[]; + readonly sections: readonly GoImageSection[]; readonly search: GoFileMapping | null; } @@ -110,6 +121,32 @@ export class GoBinaryReader { } } +/** Admit a declared file span, or mark it unbacked when it leaves the file. */ +export const declaredFileRange = ( + fileBytes: number, + offset: bigint, + size: bigint, +): { + readonly offset: number; + readonly size: number; + readonly file_backed: boolean; +} => { + if ( + offset < 0n || + size < 0n || + offset > BigInt(Number.MAX_SAFE_INTEGER) || + size > BigInt(Number.MAX_SAFE_INTEGER) || + offset > BigInt(fileBytes) || + size > BigInt(fileBytes) - offset + ) + return { offset: 0, size: 0, file_backed: false }; + return { + offset: Number(offset), + size: Number(size), + file_backed: size > 0n, + }; +}; + /** Map a virtual span without losing precision or selecting an ambiguous alias. */ export const mappedFileOffset = ( mappings: readonly GoFileMapping[], diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index 4a845c1973..e94a80ab65 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -55,7 +55,7 @@ export interface GoBinaryImage { /** Inspect bounded executable bytes using native mappings, without loading or executing code. */ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { - const container = readContainer(bytes); + const container = readGoBinaryContainer(bytes); const { format, architecture, bits, byte_order } = container; const search = container.search; const header = findHeader(bytes, search); @@ -184,7 +184,8 @@ interface LocatedString { readonly location: GoStringLocation; } -const readContainer = (bytes: Buffer): GoBinaryContainer => { +/** Identify an ELF, PE, or thin Mach-O image and its named file sections. */ +export const readGoBinaryContainer = (bytes: Buffer): GoBinaryContainer => { if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return readGoElfImage(bytes); if (bytes.subarray(0, 2).equals(Buffer.from("MZ"))) diff --git a/src/go/GoElfImage.ts b/src/go/GoElfImage.ts index fae00c40cd..de43881141 100644 --- a/src/go/GoElfImage.ts +++ b/src/go/GoElfImage.ts @@ -1,9 +1,11 @@ import { + declaredFileRange, GoBinaryFormatFailure, GoBinaryReader, mappedFileOffset, type GoBinaryContainer, type GoFileMapping, + type GoImageSection, type GoZeroFillMapping, } from "./GoBinaryContainer.js"; import { readElfFixedHeader } from "../domain/elfFixedHeader.js"; @@ -100,6 +102,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { ); const mappings: GoFileMapping[] = []; const zeroFills: GoZeroFillMapping[] = []; + const sections: GoImageSection[] = []; let fallback: GoFileMapping | null = null; for (let index = 0; index < phCount; index++) { const offset = phStart + index * phWidth; @@ -158,6 +161,15 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { "malformed", "ELF section name offset is outside its string table", ); + recordElfSection(sections, { + bytes, + names, + nameOffset, + reader, + bits, + wide, + header: offset, + }); // Comparing the complete fixed name avoids quadratic scans through malicious string tables. const expectedName = Buffer.from(".go.buildinfo\0"); if ( @@ -213,10 +225,105 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { byte_order: reader.little ? "little" : "big", mappings, zero_fills: zeroFills, + sections, search: search ?? fallback, }; }; +const recordElfSection = ( + sections: GoImageSection[], + input: { + readonly bytes: Buffer; + readonly names: { readonly offset: number; readonly size: number }; + readonly nameOffset: number; + readonly reader: GoBinaryReader; + readonly bits: 32 | 64; + readonly wide: boolean; + readonly header: number; + }, +): void => { + const decoded = elfSectionName(input.bytes, input.names, input.nameOffset); + const header = { + reader: input.reader, + bits: input.bits, + wide: input.wide, + header: input.header, + }; + switch (decoded.status) { + case "named": + sections.push(elfImageSection({ ...header, name: decoded.name })); + return; + case "truncated": { + // Names past this window stay out of .dep-v0 matching. Read-only + // bytes can still hold the only compiler path. + const section = elfImageSection({ ...header, name: "" }); + if (section.read_only) sections.push(section); + return; + } + case "unnamed": + return; + default: { + const unreachable: never = decoded; + return unreachable; + } + } +}; + +const elfSectionName = ( + bytes: Buffer, + names: { readonly offset: number; readonly size: number }, + nameOffset: number, +): + | { readonly status: "named"; readonly name: string } + | { readonly status: "unnamed" } + | { readonly status: "truncated" } => { + const start = names.offset + nameOffset; + const limit = Math.min(start + 64, names.offset + names.size); + const nul = bytes.subarray(start, limit).indexOf(0); + if (nul === 0) return { status: "unnamed" }; + if (nul < 0) return { status: "truncated" }; + return { + status: "named", + name: bytes.toString("latin1", start, start + nul), + }; +}; + +const elfImageSection = ({ + reader, + bits, + wide, + header, + name, +}: { + readonly reader: GoBinaryReader; + readonly bits: 32 | 64; + readonly wide: boolean; + readonly header: number; + readonly name: string; +}): GoImageSection => { + const type = reader.u32(header + 4); + const flags = reader.word(header + 8, bits); + const declared = declaredFileRange( + reader.bytes.length, + reader.word(header + (wide ? 24 : 16), bits), + reader.word(header + (wide ? 32 : 20), bits), + ); + // SHT_NOBITS offsets and sizes do not describe stored bytes. + const range = + type === SHT_NOBITS ? { ...declared, file_backed: false } : declared; + const compressed = (flags & 0x800n) !== 0n; + const readOnly = + type === 1 && + range.file_backed && + !compressed && + (flags & 1n) === 0n && + (flags & 4n) === 0n && + (flags & 2n) !== 0n; + return { name, ...range, read_only: readOnly, compressed }; +}; + +const SHT_NOBITS = 8; + const elfArchitecture = (machine: number): string => { const names = new Map([ [3, "386"], diff --git a/src/go/GoMachoImage.ts b/src/go/GoMachoImage.ts index a178a06163..5184a5829d 100644 --- a/src/go/GoMachoImage.ts +++ b/src/go/GoMachoImage.ts @@ -1,9 +1,11 @@ import { + declaredFileRange, GoBinaryFormatFailure, GoBinaryReader, mappedFileOffset, type GoBinaryContainer, type GoFileMapping, + type GoImageSection, type GoZeroFillMapping, } from "./GoBinaryContainer.js"; @@ -30,6 +32,7 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { ); const mappings: GoFileMapping[] = []; const zeroFills: GoZeroFillMapping[] = []; + const sections: GoImageSection[] = []; const named: GoFileMapping[] = []; let fallback: GoFileMapping | null = null; let offset = commandStart; @@ -105,11 +108,25 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { fallback = mapping; for (let sectionIndex = 0; sectionIndex < sectionCount; sectionIndex++) { const section = offset + segmentHeader + sectionIndex * sectionWidth; - if ( - fixedName(reader.range(section, 16, "Mach-O section name")) !== - "__go_buildinfo" - ) - continue; + const sectionName = fixedName( + reader.range(section, 16, "Mach-O section name"), + ); + sections.push( + machoImageSection({ + reader, + bits, + wide, + header: section, + name: `${fixedName(reader.range(section + 16, 16, "Mach-O section segment"))},${sectionName}`, + protection, + unmapped: unmapped || mapping === undefined, + segmentAddress: address, + segmentMemorySize: memorySize, + segmentFileOffset: fileOffset, + segmentFileSize: fileSize, + }), + ); + if (sectionName !== "__go_buildinfo") continue; if (unmapped) throw new GoBinaryFormatFailure( "malformed", @@ -175,10 +192,74 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { byte_order: little ? "little" : "big", mappings, zero_fills: zeroFills, + sections, search: named[0] ?? fallback, }; }; +const machoImageSection = ({ + reader, + bits, + wide, + header, + name, + protection, + unmapped, + segmentAddress, + segmentMemorySize, + segmentFileOffset, + segmentFileSize, +}: { + readonly reader: GoBinaryReader; + readonly bits: 32 | 64; + readonly wide: boolean; + readonly header: number; + readonly name: string; + readonly protection: number; + readonly unmapped: boolean; + readonly segmentAddress: bigint; + readonly segmentMemorySize: bigint; + readonly segmentFileOffset: bigint; + readonly segmentFileSize: bigint; +}): GoImageSection => { + const flags = reader.u32(header + (wide ? 64 : 56)); + const zeroFill = [1, 0xc, 0x12].includes(flags & 0xff); + const sectionAddress = reader.word(header + 32, bits); + const sectionSize = reader.word(header + (wide ? 40 : 36), bits); + const sectionOffset = BigInt(reader.u32(header + (wide ? 48 : 40))); + const addressDelta = sectionAddress - segmentAddress; + const fileDelta = sectionOffset - segmentFileOffset; + const inSegmentMemory = + addressDelta >= 0n && + addressDelta <= segmentMemorySize && + sectionSize <= segmentMemorySize - addressDelta; + const inSegmentFile = + fileDelta >= 0n && + fileDelta <= segmentFileSize && + sectionSize <= segmentFileSize - fileDelta; + const range = unmapped + ? { offset: 0, size: 0, file_backed: false } + : declaredFileRange( + reader.bytes.length, + BigInt(reader.u32(header + (wide ? 48 : 40))), + reader.word(header + (wide ? 40 : 36), bits), + ); + const fileBacked = + range.file_backed && !zeroFill && inSegmentMemory && inSegmentFile; + const readOnly = + fileBacked && + (protection & 1) !== 0 && + (protection & 2) === 0 && + (flags & 0x80000400) === 0; + return { + name, + ...range, + file_backed: fileBacked, + read_only: readOnly, + compressed: false, + }; +}; + const fixedName = (bytes: Buffer): string => { const nul = bytes.indexOf(0); return bytes.subarray(0, nul < 0 ? bytes.length : nul).toString("latin1"); diff --git a/src/go/GoPeImage.ts b/src/go/GoPeImage.ts index 3d7ad95a82..0787bc93d7 100644 --- a/src/go/GoPeImage.ts +++ b/src/go/GoPeImage.ts @@ -1,9 +1,11 @@ import { observeMzWindowsHeader, parseDosMzHeader } from "../domain/dosMz.js"; import { + declaredFileRange, GoBinaryFormatFailure, GoBinaryReader, type GoBinaryContainer, type GoFileMapping, + type GoImageSection, type GoZeroFillMapping, } from "./GoBinaryContainer.js"; @@ -86,14 +88,30 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => { ); const mappings: GoFileMapping[] = []; const zeroFills: GoZeroFillMapping[] = []; + const sections: GoImageSection[] = []; let search: GoFileMapping | null = null; for (let index = 0; index < count; index++) { const entry = start + index * 40; + const sectionName = peSectionName( + reader.range(entry, 8, "PE section name"), + ); const virtualSize = reader.u32(entry + 8); const virtualAddress = reader.u32(entry + 12); const size = reader.u32(entry + 16); const offset = reader.u32(entry + 20); const characteristics = reader.u32(entry + 36); + // SizeOfRawData includes file-alignment padding. VirtualSize is the content. + const contentSize = virtualSize === 0 ? size : Math.min(virtualSize, size); + if (sectionName.length > 0) + sections.push( + peImageSection({ + reader, + name: sectionName, + size: contentSize, + offset, + characteristics, + }), + ); const address = imageBase + BigInt(virtualAddress); if (address + BigInt(Math.max(size, virtualSize)) > 1n << BigInt(bits)) throw new GoBinaryFormatFailure( @@ -138,6 +156,39 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => { byte_order: "little", mappings, zero_fills: zeroFills, + sections, search, }; }; + +const peSectionName = (bytes: Buffer): string => { + const nul = bytes.indexOf(0); + return bytes.toString("latin1", 0, nul < 0 ? bytes.length : nul); +}; + +const peImageSection = ({ + reader, + name, + size, + offset, + characteristics, +}: { + readonly reader: GoBinaryReader; + readonly name: string; + readonly size: number; + readonly offset: number; + readonly characteristics: number; +}): GoImageSection => { + const range = declaredFileRange( + reader.bytes.length, + BigInt(offset), + BigInt(size), + ); + const readOnly = + range.file_backed && + (characteristics & 0x40000000) !== 0 && + (characteristics & 0x80000000) === 0 && + (characteristics & 0x20000000) === 0 && + (characteristics & 0x40) !== 0; + return { name, ...range, read_only: readOnly, compressed: false }; +}; diff --git a/src/rust/RustAuditableReader.test.ts b/src/rust/RustAuditableReader.test.ts new file mode 100644 index 0000000000..cb281185a3 --- /dev/null +++ b/src/rust/RustAuditableReader.test.ts @@ -0,0 +1,221 @@ +import { deflateSync } from "node:zlib"; +import { expect, it } from "vitest"; +import { + RUST_SECTION_BYTES, + readAuditableSection, + RustAuditableFormatFailure, + RustAuditableResourceFailure, +} from "./RustAuditableReader.js"; + +const section = (document: unknown) => + readAuditableSection( + ".dep-v0", + 0, + deflateSync(Buffer.from(JSON.stringify(document))), + ); + +it("preserves package order, omitted kind, and the root index", () => { + const parsed = section({ + packages: [ + { name: "itoa", version: "1.0.18", source: "crates.io" }, + { + name: "sample", + version: "0.1.0", + source: "local", + dependencies: [0], + root: true, + kind: "runtime", + }, + ], + format: 1, + }); + expect(parsed).toMatchObject({ + section_name: ".dep-v0", + format_version: 1, + root_package_index: 1, + compressed_bytes: parsed.section_bytes, + packages: [ + { + name: "itoa", + version: "1.0.18", + source: "crates.io", + kind: null, + dependencies: [], + root: false, + }, + { + name: "sample", + version: "0.1.0", + kind: "runtime", + dependencies: [0], + root: true, + }, + ], + dependency_edges: [{ from_index: 1, to_index: 0 }], + cyclic_dependency_indices: [], + }); + expect(parsed.uncompressed_bytes).toBeGreaterThan(parsed.compressed_bytes); +}); + +it("reports dependency cycles and keeps an omitted format version unknown", () => { + const parsed = section({ + packages: [ + { name: "a", version: "1", source: "local", dependencies: [1] }, + { name: "b", version: "1", source: "local", dependencies: [0] }, + ], + }); + expect(parsed.cyclic_dependency_indices).toEqual([0, 1]); + expect(parsed.format_version).toBeNull(); + expect(parsed.root_package_index).toBeNull(); +}); + +it("includes every package that participates in a dependency cycle", () => { + const parsed = section({ + packages: [ + { name: "a", version: "1", source: "local", dependencies: [1, 2] }, + { name: "b", version: "1", source: "local", dependencies: [0] }, + { name: "c", version: "1", source: "local", dependencies: [1] }, + ], + }); + expect(parsed.cyclic_dependency_indices).toEqual([0, 1, 2]); + const self = section({ + packages: [{ name: "a", version: "1", source: "local", dependencies: [0] }], + }); + expect(self.cyclic_dependency_indices).toEqual([0]); +}); + +it("finishes a long acyclic dependency chain", () => { + const count = 20_000; + const parsed = section({ + packages: Array.from({ length: count }, (_, index) => ({ + name: `p${index.toString(36)}`, + version: "1", + source: "local", + ...(index + 1 < count ? { dependencies: [index + 1] } : {}), + })), + }); + expect(parsed.packages).toHaveLength(count); + expect(parsed.cyclic_dependency_indices).toEqual([]); +}); + +it("rejects out-of-range indices, multiple roots, and schema drift", () => { + expect(() => + section({ + packages: [ + { name: "a", version: "1", source: "local", dependencies: [2] }, + ], + }), + ).toThrow(RustAuditableFormatFailure); + expect(() => + section({ + packages: [ + { name: "a", version: "1", source: "local", root: true }, + { name: "b", version: "1", source: "local", root: true }, + ], + }), + ).toThrow(/more than one root/); + expect(() => + section({ + packages: [{ name: "a", version: "1", source: "local" }], + extra: true, + }), + ).toThrow(/package schema/); +}); + +it("rejects invalid JSON, trailing zlib bytes, and an empty section", () => { + expect(() => + readAuditableSection(".dep-v0", 0, deflateSync(Buffer.from("{"))), + ).toThrow(/JSON is invalid/); + const trailed = Buffer.concat([ + deflateSync(Buffer.from('{"packages":[]}')), + Buffer.from([0]), + ]); + expect(() => readAuditableSection(".dep-v0", 0, trailed)).toThrow( + /zlib stream/, + ); + expect(() => readAuditableSection(".dep-v0", 0, Buffer.alloc(0))).toThrow( + /empty/, + ); + expect(() => + readAuditableSection(".dep-v0", 0, Buffer.from([1, 2, 3])), + ).toThrow(RustAuditableFormatFailure); +}); + +it("stops an inflate that exceeds the section ratio", () => { + const raw = Buffer.alloc(8000); + const compressed = deflateSync(raw); + expect(compressed.length * 64).toBeLessThan(raw.length); + expect(() => readAuditableSection(".dep-v0", 0, compressed)).toThrow( + RustAuditableResourceFailure, + ); + expect(() => + readAuditableSection(".dep-v0", 0, Buffer.alloc(RUST_SECTION_BYTES + 1)), + ).toThrow(/1 MiB/); +}); + +it.each([{ packages: [] }, { packages: [{ future_package_field: true }] }])( + "rejects an unknown format before interpreting its packages: %j", + ({ packages }) => { + expect(() => section({ format: 99, packages })).toThrow( + expect.objectContaining({ + name: "RustAuditableFormatFailure", + kind: "unsupported", + message: "Unsupported cargo-auditable format version 99", + }), + ); + }, +); + +it("rejects the first malformed package before schema issues accumulate", () => { + const stored = (document: unknown) => + deflateSync(Buffer.from(JSON.stringify(document)), { level: 0 }); + const emptyPackages = stored({ + format: 1, + packages: Array.from({ length: 80_000 }, () => ({})), + }); + expect(emptyPackages.length).toBeLessThan(RUST_SECTION_BYTES); + expect(() => readAuditableSection(".dep-v0", 0, emptyPackages)).toThrow( + /Dependency package 0 does not match the package schema/, + ); + const invalidDependencies = stored({ + format: 1, + packages: [ + { + name: "a", + version: "1", + source: "local", + dependencies: Array.from({ length: 1_000 }, () => null), + }, + ], + }); + expect(() => readAuditableSection(".dep-v0", 0, invalidDependencies)).toThrow( + /Dependency package 0 does not match the package schema/, + ); +}); + +it("admits dependency expansion before allocating normalized or reverse edges", () => { + const document = { + packages: [ + { + name: "a", + version: "1", + source: "local", + dependencies: Array(400_000).fill(0), + }, + ], + }; + // An uncompressed zlib stream clears the ratio guard while remaining below + // the one-MiB section limit, isolating the graph representation admission. + const payload = deflateSync(Buffer.from(JSON.stringify(document)), { + level: 0, + }); + expect(payload.length).toBeLessThan(RUST_SECTION_BYTES); + expect(() => readAuditableSection(".dep-v0", 0, payload)).toThrow( + expect.objectContaining({ + constraint: { + resource: "transport", + reported_limits: { maximum_output_bytes: 8 * 1024 * 1024 }, + }, + }), + ); +}); diff --git a/src/rust/RustAuditableReader.ts b/src/rust/RustAuditableReader.ts new file mode 100644 index 0000000000..d28b957047 --- /dev/null +++ b/src/rust/RustAuditableReader.ts @@ -0,0 +1,440 @@ +import { deflateSync, inflateSync } from "node:zlib"; +import { z } from "zod"; +import { + RUST_AUDITABLE_FORMAT_VERSION, + cyclicDependencyIndices, + type RustAuditableMetadata, + type RustPackage, +} from "../domain/rust/rustBinary.js"; + +/** Selected-file admission limit shared with the stable artifact reader. */ +export const RUST_BINARY_INPUT_BYTES = 256 * 1024 * 1024; +/** Compressed `.dep-v0` section limit. */ +export const RUST_SECTION_BYTES = 1024 * 1024; +/** Inflated JSON may grow by at most this multiple of the section size. */ +export const RUST_INFLATE_RATIO = 64; +/** Absolute inflated JSON limit, applied together with the ratio. */ +export const RUST_UNCOMPRESSED_BYTES = 8 * 1024 * 1024; +/** Complete normalized report limit. */ +export const RUST_OUTPUT_BYTES = 8 * 1024 * 1024; + +const JSON_DEPTH = 32; +const packageSchema = z.strictObject({ + name: z.string().min(1), + version: z.string().min(1), + source: z.string().min(1), + kind: z.enum(["runtime", "build"]).optional(), + dependencies: z.array(z.number().int().nonnegative()).optional(), + root: z.boolean().optional(), +}); +const documentVersionSchema = z.object({ + format: z.number().int().nonnegative().optional(), +}); +const documentSchema = documentVersionSchema + .extend({ + format: z.literal(RUST_AUDITABLE_FORMAT_VERSION).optional(), + packages: z.array(packageSchema), + }) + .strict(); + +/** Malformed or unsupported dependency-section bytes. */ +export class RustAuditableFormatFailure extends Error { + constructor( + readonly kind: "malformed" | "unsupported", + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "RustAuditableFormatFailure"; + } +} + +/** A resource admission limit with explicit semantic units. */ +export type RustAuditableConstraint = + | { + readonly resource: "memory"; + readonly reported_limits: { readonly maximum_bytes: number }; + } + | { + readonly resource: "transport"; + readonly reported_limits: { readonly maximum_output_bytes: number }; + } + | { + readonly resource: "capacity"; + readonly reported_limits: { readonly maximum_rustc_hashes: number }; + }; + +/** Admission refused before retained output or graph expansion. */ +export class RustAuditableResourceFailure extends Error { + constructor( + readonly constraint: RustAuditableConstraint, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "RustAuditableResourceFailure"; + } +} + +/** Parsed section payload. Cycles are listed and are not walked. */ +export const readAuditableSection = ( + sectionName: RustAuditableMetadata["section_name"], + fileOffset: number, + bytes: Buffer, +): RustAuditableMetadata => { + if (bytes.length === 0) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section is empty", + ); + if (bytes.length > RUST_SECTION_BYTES) + throw new RustAuditableResourceFailure( + { + resource: "memory", + reported_limits: { maximum_bytes: RUST_SECTION_BYTES }, + }, + "Dependency section exceeds the 1 MiB compressed limit", + ); + const inflated = inflateSection(bytes); + const document = parseDocument(inflated); + const packages = normalizePackages(document.packages); + const root = rootIndex(packages); + return { + section_name: sectionName, + file_offset: fileOffset, + section_bytes: bytes.length, + compressed_bytes: bytes.length, + uncompressed_bytes: inflated.length, + format_version: document.format ?? null, + packages, + root_package_index: root, + dependency_edges: packages.flatMap((pkg, fromIndex) => + pkg.dependencies.map((toIndex) => ({ + from_index: fromIndex, + to_index: toIndex, + })), + ), + cyclic_dependency_indices: cyclicDependencyIndices(packages), + }; +}; + +const inflateSection = (bytes: Buffer): Buffer => { + const maximum = Math.min( + RUST_UNCOMPRESSED_BYTES, + bytes.length * RUST_INFLATE_RATIO, + ); + try { + const inflated = inflateSync(bytes, { + maxOutputLength: maximum, + ...(rejectsTrailingJunk() ? { rejectGarbageAfterEnd: true } : {}), + }); + if ( + !rejectsTrailingJunk() && + shortestPrefix(bytes, inflated, maximum) !== bytes.length + ) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section has bytes after the zlib stream", + ); + return inflated; + } catch (cause) { + if (cause instanceof RustAuditableFormatFailure) throw cause; + throw inflateFailure(cause, maximum); + } +}; + +let trailingJunkRejected: boolean | undefined; + +const rejectsTrailingJunk = (): boolean => { + if (trailingJunkRejected !== undefined) return trailingJunkRejected; + const trailed = Buffer.concat([ + deflateSync(Buffer.from("a")), + Buffer.from([0]), + ]); + try { + inflateSync(trailed, { rejectGarbageAfterEnd: true }); + trailingJunkRejected = false; + } catch (cause) { + trailingJunkRejected = + cause instanceof Error && + "code" in cause && + cause.code === "ERR_TRAILING_JUNK_AFTER_STREAM_END"; + } + return trailingJunkRejected; +}; + +const shortestPrefix = ( + input: Buffer, + output: Buffer, + maximum: number, +): number => { + let low = 0; + let high = input.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (prefixMatches(input.subarray(0, mid), output, maximum)) high = mid; + else low = mid + 1; + } + return high; +}; + +const prefixMatches = ( + prefix: Buffer, + output: Buffer, + maximum: number, +): boolean => { + try { + return inflateSync(prefix, { maxOutputLength: maximum }).equals(output); + } catch { + return false; + } +}; + +const inflateFailure = (cause: unknown, maximum: number): Error => { + const code = + cause instanceof Error && "code" in cause ? String(cause.code) : ""; + if (code === "ERR_BUFFER_TOO_LARGE") + return new RustAuditableResourceFailure( + { resource: "memory", reported_limits: { maximum_bytes: maximum } }, + "Inflated dependency JSON exceeds the size limit", + ); + if ( + code === "ERR_TRAILING_JUNK_AFTER_STREAM_END" || + code.startsWith("Z_") || + code === "ERR_INVALID_ARG_TYPE" + ) + return new RustAuditableFormatFailure( + "malformed", + "Dependency section is not a complete zlib stream", + ); + return cause instanceof Error ? cause : new Error(String(cause)); +}; + +const parseDocument = (bytes: Buffer): z.infer => { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (cause) { + throw new RustAuditableFormatFailure( + "malformed", + "Dependency JSON is not valid UTF-8", + { cause }, + ); + } + assertDepth(text); + let value: unknown; + try { + value = JSON.parse(text); + } catch (cause) { + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section JSON is invalid", + { cause }, + ); + } + const version = documentVersionSchema.safeParse(value); + if (!version.success) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency JSON does not match the format schema", + { cause: version.error }, + ); + if ( + version.data.format !== undefined && + version.data.format !== RUST_AUDITABLE_FORMAT_VERSION + ) + throw new RustAuditableFormatFailure( + "unsupported", + `Unsupported cargo-auditable format version ${version.data.format}`, + ); + admitGraphExpansion(value); + // One malformed package must fail before Zod retains an issue for every field. + rejectMalformedPackages(value); + const parsed = documentSchema.safeParse(value); + if (!parsed.success) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency JSON does not match the package schema", + { cause: parsed.error }, + ); + return parsed.data; +}; + +// These are lower bounds on the required normalized representation, not +// guessed per-object heap sizes. Count source arrays before Zod clones them, +// normalization builds edge objects, or SCC analysis creates reverse edges. +const MIN_PACKAGE_OUTPUT_BYTES = JSON.stringify({ + name: "a", + version: "a", + source: "a", + kind: null, + dependencies: [], + root: true, +}).length; +const MIN_EDGE_OUTPUT_BYTES = JSON.stringify({ + from_index: 0, + to_index: 0, +}).length; +const admitGraphExpansion = (value: unknown): void => { + if ( + typeof value !== "object" || + value === null || + !("packages" in value) || + !Array.isArray(value.packages) + ) + return; + const packages: readonly unknown[] = value.packages; + let minimumBytes = + packages.length * MIN_PACKAGE_OUTPUT_BYTES + + Math.max(0, packages.length - 1); + let edges = 0; + const admit = () => { + const edgeBytes = edges * MIN_EDGE_OUTPUT_BYTES + Math.max(0, edges - 1); + if (minimumBytes + edgeBytes > RUST_OUTPUT_BYTES) + throw new RustAuditableResourceFailure( + { + resource: "transport", + reported_limits: { maximum_output_bytes: RUST_OUTPUT_BYTES }, + }, + "Dependency graph exceeds the complete report byte budget before expansion", + ); + }; + admit(); + for (const pkg of packages) { + // Shape and index validity remain owned by documentSchema/rootIndex. + if ( + typeof pkg !== "object" || + pkg === null || + !("dependencies" in pkg) || + !Array.isArray(pkg.dependencies) + ) + continue; + const count = pkg.dependencies.length; + edges += count; + minimumBytes += Math.max(0, count * 2 - 1); + admit(); + } +}; + +const PACKAGE_FIELDS = new Set([ + "name", + "version", + "source", + "kind", + "dependencies", + "root", +]); + +const rejectMalformedPackages = (value: unknown): void => { + if ( + typeof value !== "object" || + value === null || + !("packages" in value) || + !Array.isArray(value.packages) + ) + return; + for (const [index, pkg] of value.packages.entries()) { + if (!packageRecord(pkg)) + throw new RustAuditableFormatFailure( + "malformed", + `Dependency package ${String(index)} does not match the package schema`, + ); + } +}; + +const packageRecord = ( + pkg: unknown, +): pkg is { + readonly dependencies?: readonly unknown[]; +} => { + if (typeof pkg !== "object" || pkg === null || Array.isArray(pkg)) + return false; + const record = pkg as Record; + for (const key of Object.keys(record)) { + if (!PACKAGE_FIELDS.has(key)) return false; + } + if ( + !nonEmptyString(record.name) || + !nonEmptyString(record.version) || + !nonEmptyString(record.source) + ) + return false; + if ( + record.kind !== undefined && + record.kind !== "runtime" && + record.kind !== "build" + ) + return false; + if (record.root !== undefined && typeof record.root !== "boolean") + return false; + const dependencies = record.dependencies; + if (dependencies === undefined) return true; + return ( + Array.isArray(dependencies) && + dependencies.every( + (entry) => + typeof entry === "number" && Number.isInteger(entry) && entry >= 0, + ) + ); +}; + +const nonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.length > 0; + +const assertDepth = (text: string): void => { + let depth = 0; + let quoted = false; + let escaped = false; + for (const char of text) { + if (quoted) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') quoted = false; + continue; + } + if (char === '"') { + quoted = true; + continue; + } + if (char === "{" || char === "[") { + depth += 1; + if (depth > JSON_DEPTH) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency JSON nesting exceeds 32 levels", + ); + } else if ((char === "}" || char === "]") && depth > 0) depth -= 1; + } +}; + +const normalizePackages = ( + packages: z.infer["packages"], +): RustPackage[] => + packages.map((pkg) => ({ + name: pkg.name, + version: pkg.version, + source: pkg.source, + kind: pkg.kind ?? null, + dependencies: pkg.dependencies ?? [], + root: pkg.root ?? false, + })); + +const rootIndex = (packages: readonly RustPackage[]): number | null => { + const roots = packages.flatMap((pkg, index) => (pkg.root ? [index] : [])); + if (roots.length > 1) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency JSON declares more than one root package", + ); + for (const [index, pkg] of packages.entries()) { + const outside = pkg.dependencies.find( + (target) => target >= packages.length, + ); + if (outside !== undefined) + throw new RustAuditableFormatFailure( + "malformed", + `Dependency index ${String(outside)} from package ${String(index)} is outside the package list`, + ); + } + return roots[0] ?? null; +}; diff --git a/src/rust/RustBinaryImage.ts b/src/rust/RustBinaryImage.ts new file mode 100644 index 0000000000..ae077d21ca --- /dev/null +++ b/src/rust/RustBinaryImage.ts @@ -0,0 +1,181 @@ +import { + GoBinaryFormatFailure, + GoBinaryResourceFailure, + type GoImageSection, +} from "../go/GoBinaryContainer.js"; +import { readGoBinaryContainer } from "../go/GoBinaryImage.js"; +import type { + RustAuditableMetadata, + RustBinary, +} from "../domain/rust/rustBinary.js"; +import { + readAuditableSection, + RustAuditableFormatFailure, + RustAuditableResourceFailure, +} from "./RustAuditableReader.js"; + +const RUSTC_PREFIX = Buffer.from("/rustc/"); +const HASH_BYTES = 40; +/** One toolchain normally leaves one hash; a small cap bounds retained output. */ +const MAX_RUSTC_HASHES = 64; + +/** Container identity plus optional dependency metadata and inferred rustc commits. */ +export interface RustBinaryImage { + readonly format: RustBinary["format"]; + readonly architecture: string; + readonly bits: 32 | 64; + readonly byte_order: RustBinary["byte_order"]; + readonly auditable_metadata: RustAuditableMetadata | null; + readonly rustc_commit: RustBinary["rustc_commit"]; +} + +/** Read dependency metadata from container sections without executing the image. */ +export const readRustBinaryImage = (bytes: Buffer): RustBinaryImage => { + const container = readContainer(bytes); + const dependency = dependencySection(container.format, container.sections); + return { + format: container.format, + architecture: container.architecture, + bits: container.bits, + byte_order: container.byte_order, + auditable_metadata: + dependency === null + ? null + : readDependency(bytes, container.format, dependency), + rustc_commit: rustcCommit(bytes, container.sections), + }; +}; + +const readContainer = (bytes: Buffer) => { + try { + return readGoBinaryContainer(bytes); + } catch (cause) { + if (cause instanceof GoBinaryFormatFailure) + throw new RustAuditableFormatFailure(cause.kind, cause.message, { + cause, + }); + if (cause instanceof GoBinaryResourceFailure) + throw new RustAuditableResourceFailure( + { + resource: "memory", + reported_limits: { maximum_bytes: cause.maximum_bytes }, + }, + cause.message, + { + cause, + }, + ); + throw cause; + } +}; + +const dependencySection = ( + format: RustBinaryImage["format"], + sections: readonly GoImageSection[], +): GoImageSection | null => { + const expected = format === "macho" ? "__DATA,.dep-v0" : ".dep-v0"; + const matches = sections.filter((section) => section.name === expected); + if (matches.length > 1) + throw new RustAuditableFormatFailure( + "malformed", + "Image contains more than one dependency section", + ); + const section = matches[0]; + if (section === undefined) return null; + if (section.compressed) + throw new RustAuditableFormatFailure( + "unsupported", + "Compressed dependency sections are unsupported", + ); + if (!section.file_backed) + throw new RustAuditableFormatFailure( + "malformed", + "Dependency section is not file-backed", + ); + return section; +}; + +const readDependency = ( + bytes: Buffer, + format: RustBinaryImage["format"], + section: GoImageSection, +): RustAuditableMetadata => + readAuditableSection( + format === "macho" ? "__DATA,.dep-v0" : ".dep-v0", + section.offset, + bytes.subarray(section.offset, section.offset + section.size), + ); + +const rustcCommit = ( + bytes: Buffer, + sections: readonly GoImageSection[], +): RustBinaryImage["rustc_commit"] => { + const hashes: { hash: string; file_offset: number }[] = []; + const seen = new Set(); + // Overlapping declared sections are scanned once, so total work stays + // bounded by the file size however many sections reference a range. + for (const range of mergedReadOnlyRanges(sections)) + collectHashes(bytes, range, seen, hashes); + return hashes.length === 0 ? null : { evidence_kind: "inferred", hashes }; +}; + +const mergedReadOnlyRanges = ( + sections: readonly GoImageSection[], +): { readonly offset: number; readonly end: number }[] => { + const ranges = sections + .filter((section) => section.read_only && section.size > 0) + .map((section) => ({ + offset: section.offset, + end: section.offset + section.size, + })) + .sort((left, right) => left.offset - right.offset); + const merged: { offset: number; end: number }[] = []; + for (const range of ranges) { + const last = merged.at(-1); + if (last !== undefined && range.offset <= last.end) + last.end = Math.max(last.end, range.end); + else merged.push({ ...range }); + } + return merged; +}; + +const collectHashes = ( + bytes: Buffer, + range: { readonly offset: number; readonly end: number }, + seen: Set, + hashes: { hash: string; file_offset: number }[], +): void => { + const region = bytes.subarray(range.offset, range.end); + let cursor = 0; + while (cursor < region.length) { + const at = region.indexOf(RUSTC_PREFIX, cursor); + if (at < 0) return; + const start = at + RUSTC_PREFIX.length; + const hash = hashAt(region, start); + if (hash !== null && !seen.has(hash)) { + if (seen.size >= MAX_RUSTC_HASHES) + throw new RustAuditableResourceFailure( + { + resource: "capacity", + reported_limits: { maximum_rustc_hashes: MAX_RUSTC_HASHES }, + }, + `Read-only data contains more than ${MAX_RUSTC_HASHES} distinct rustc commit paths.`, + ); + seen.add(hash); + hashes.push({ hash, file_offset: range.offset + at }); + } + cursor = at + 1; + } +}; + +const hashAt = (region: Buffer, start: number): string | null => { + const end = start + HASH_BYTES; + if (end >= region.length || region[end] !== 0x2f) return null; + for (let index = start; index < end; index += 1) { + const byte = region[index] ?? 0; + const hex = + (byte >= 0x30 && byte <= 0x39) || (byte >= 0x61 && byte <= 0x66); + if (!hex) return null; + } + return region.toString("latin1", start, end); +}; diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts new file mode 100644 index 0000000000..d0ce690f01 --- /dev/null +++ b/src/rust/RustBinaryProvider.ts @@ -0,0 +1,216 @@ +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { ExecutionOptions } from "../application/AnalysisProvider.js"; +import type { RustBinaryPort } from "../application/rust/RustBinaryPort.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import { + rustBinarySchema, + type InspectRustBinaryInput, + type RustBinary, +} from "../domain/rust/rustBinary.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { readRustBinaryImage } from "./RustBinaryImage.js"; +import { + RUST_BINARY_INPUT_BYTES, + RUST_OUTPUT_BYTES, + RustAuditableFormatFailure, + RustAuditableResourceFailure, +} from "./RustAuditableReader.js"; + +/** Bundled reader identity. Embedded package versions are separate observed facts. */ +export const RUST_BINARY_PROVIDER_IDENTITY = { + id: "rust-auditable", + name: "REA Rust dependency reader", + version: "1", +} as const; +const OPERATION = "inspect_rust_binary"; + +/** Stable in-process dependency inspection without subprocesses, temporary files, or a Rust toolchain. */ +export class RustBinaryProvider implements RustBinaryPort { + readonly identity = RUST_BINARY_PROVIDER_IDENTITY; + + /** Read one dependency section and any inferred rustc commit from the selected file. */ + async inspect( + input: InspectRustBinaryInput, + options?: ExecutionOptions, + ): Promise> { + try { + const snapshot = await readStableArtifact( + input.path, + RUST_BINARY_INPUT_BYTES, + options?.signal, + ); + options?.signal?.throwIfAborted(); + const image = readRustBinaryImage(snapshot.bytes); + const report = { + ...image, + artifact: { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + limitations: limitationsFor(image), + }; + if (Buffer.byteLength(JSON.stringify(report), "utf8") > RUST_OUTPUT_BYTES) + throw outputLimit(input.path); + const parsed = rustBinarySchema.safeParse(report); + if (!parsed.success) + throw new AnalysisOutputError( + OPERATION, + "Rust reader produced an invalid dependency report.", + { cause: parsed.error }, + ); + options?.signal?.throwIfAborted(); + return ok(parsed.data); + } catch (cause: unknown) { + return err(failureFor(cause, input.path, options?.signal)); + } + } +} + +const outputLimit = (path: string) => + new AnalysisResourceConstraintError( + OPERATION, + "transport", + `Complete Rust metadata for ${path} exceeds the inline output budget; no partial success is returned.`, + { maximum_output_bytes: RUST_OUTPUT_BYTES }, + ); + +const limitationsFor = ( + image: ReturnType, +): string[] => [ + "Embedded dependency metadata is file content. It does not authenticate package names, versions, sources, or the compiler that produced the binary.", + "Only the .dep-v0 dependency section and read-only compiler path strings are inspected. Functions, types, and runtime behavior are not recovered.", + "Resource guards bound the selected file to 256 MiB, a dependency section to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the section size, and the complete report to 8 MiB. Package and dependency counts are admitted against that output budget before schema cloning, graph objects, or reverse-edge allocation.", + ...(image.auditable_metadata === null + ? [ + "No .dep-v0 section was found. Absence of .dep-v0 does not mean the binary has no dependencies.", + ] + : []), + ...(image.auditable_metadata?.packages.some((pkg) => pkg.kind === null) + ? [ + "An omitted package kind is left null. The embedded format treats an omitted kind as runtime, and that default was not stored in the section.", + ] + : []), + ...(image.auditable_metadata !== null && + image.auditable_metadata.cyclic_dependency_indices.length > 0 + ? [ + "Dependency indices contain a cycle. The cycle is reported and was not traversed.", + ] + : []), + ...rustcLimitation(image.rustc_commit), +]; + +const rustcLimitation = ( + commit: ReturnType["rustc_commit"], +): string[] => { + if (commit === null) + return [ + "No /rustc/<40-hex>/ path was found in read-only data, so a rustc commit hash is unknown.", + ]; + if (commit.hashes.length === 1) + return [ + "The rustc commit hash is inferred from a /rustc/<40-hex>/ path in read-only data. It does not by itself prove which compiler produced the binary.", + ]; + return [ + "Read-only data contains more than one /rustc/<40-hex>/ path, so a single rustc commit is unknown.", + ]; +}; + +const failureFor = ( + cause: unknown, + path: string, + signal?: AbortSignal, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (signal?.aborted) return new AnalysisCancelledError(OPERATION); + if (cause instanceof RustAuditableResourceFailure) + return new AnalysisResourceConstraintError( + OPERATION, + cause.constraint.resource, + `Selected artifact ${path}: ${cause.message}`, + cause.constraint.reported_limits, + { cause }, + ); + if (cause instanceof RustAuditableFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, { + cause, + }) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${path}: ${cause.message}`, + }, + ]); + return artifactFailure(cause, path); +}; + +const artifactFailure = (cause: unknown, path: string): AnalysisError => { + if (cause instanceof ArtifactReaderFailure) + return artifactReaderFailure(cause, path); + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, 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 artifact could not be read (${String(cause.code)}): ${path}.`, + }, + ]); + } + return new ProviderAdapterError(RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, { + cause, + diagnostics: { + path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); +}; + +const artifactReaderFailure = ( + cause: ArtifactReaderFailure, + path: string, +): AnalysisError => { + if (cause.reason === "integrity") + return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { + cause, + }); + if (cause.reason === "cancelled") + return new AnalysisCancelledError(OPERATION); + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + cause.message, + { maximum_input_bytes: RUST_BINARY_INPUT_BYTES }, + { cause }, + ); + if (cause.reason === "path") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_format", message: cause.message }, + ]); + return new ProviderAdapterError(RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, { + cause, + diagnostics: { path, reason: cause.message }, + }); +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index c73071beaf..8acc5ac849 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -22,6 +22,9 @@ import { createRecordedCrashService } from "../composition/binaryDiagnostics.js" import { createGoBinaryService } from "../composition/go.js"; import type { GoBinaryService } from "../application/go/GoBinaryService.js"; import { registerGoTools } from "./registerGoTools.js"; +import { createRustBinaryService } from "../composition/rust.js"; +import type { RustBinaryService } from "../application/rust/RustBinaryService.js"; +import { registerRustTools } from "./registerRustTools.js"; import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js"; import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js"; import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js"; @@ -141,6 +144,7 @@ export interface CreateServerOptions { readonly logger?: Logger; readonly binaryLayout?: BinaryLayoutService; readonly goBinary?: GoBinaryService; + readonly rustBinary?: RustBinaryService; readonly recordedCrash?: RecordedCrashService; readonly firmwareAnalysis?: FirmwareAnalysisPort; readonly javascriptRecovery?: JavaScriptRecoveryPort; @@ -582,6 +586,13 @@ const registerConfiguredAnalysisTools = ( recordEvidence, withAdmittedAnalysis, ); + registerRustTools( + server, + options.rustBinary ?? createRustBinaryService(), + toolLogger, + recordEvidence, + withAdmittedAnalysis, + ); registerWasmTools( server, wasmArtifact, diff --git a/src/server/registerRustTools.ts b/src/server/registerRustTools.ts new file mode 100644 index 0000000000..4f674cd82f --- /dev/null +++ b/src/server/registerRustTools.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { RustBinaryService } from "../application/rust/RustBinaryService.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 Rust contract to its shared workflow and owning session Evidence ledger. */ +export const registerRustTools = ( + server: EvidenceMcpServer, + service: RustBinaryService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_rust_binary"); + 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/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts new file mode 100644 index 0000000000..2f984c618d --- /dev/null +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -0,0 +1,71 @@ +import { deflateSync } from "node:zlib"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { createRustImage } from "../fixtures/rust/image.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "reports cargo-auditable packages through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-"); + const path = join(root, "sample"); + await writeFile(path, createRustImage({ format: "macho" })); + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_rust_binary").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.auditable_metadata).toMatchObject({ + section_name: "__DATA,.dep-v0", + format_version: 1, + root_package_index: 1, + packages: [ + { name: "itoa", version: "1.0.18" }, + { name: "sample", version: "0.1.0", root: true }, + ], + }); + }, +); + +cliTest("returns null metadata when .dep-v0 is absent", async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-missing-"); + const path = join(root, "plain"); + await writeFile(path, createRustImage({ format: "elf", omitSection: true })); + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_rust_binary").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.auditable_metadata).toBeNull(); + expect(evidence.normalized_result.limitations.join("\n")).toContain( + "does not mean the binary has no dependencies", + ); +}); + +cliTest("reports malformed zlib as invalid input", async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-zlib-"); + const path = join(root, "broken.elf"); + await writeFile( + path, + createRustImage({ + format: "elf", + payload: deflateSync(Buffer.from("{")), + includeRustc: false, + }), + ); + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_rust_binary" }, + }); +}); diff --git a/tests/boundary/go/goBinaryImage.test.ts b/tests/boundary/go/goBinaryImage.test.ts index 29f0e952c3..c8069be98d 100644 --- a/tests/boundary/go/goBinaryImage.test.ts +++ b/tests/boundary/go/goBinaryImage.test.ts @@ -6,6 +6,7 @@ import { machoImage, } from "../../../src/artifacts/apple/MachoImage.fixture.js"; import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js"; +import { readGoMachoImage } from "../../../src/go/GoMachoImage.js"; import { createGoBinaryFixture, GO_MODULE_TEXT, @@ -519,6 +520,26 @@ it("rejects build-info declared inside an unmapped Mach-O segment", () => { expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/unmapped/i); }); +it("does not mark Mach-O section bytes outside their owning segment as read-only", () => { + const fixture = createGoBinaryFixture({ format: "macho" }); + const command = 32; + const section = command + 72; + fixture.bytes.write("__TEXT", command + 8, "ascii"); + fixture.bytes.writeBigUInt64LE(1024n, command + 48); + fixture.bytes.writeUInt32LE(1, command + 56); + fixture.bytes.writeUInt32LE(1, command + 60); + fixture.bytes.write("__debug_info", section, "ascii"); + fixture.bytes.write("__TEXT", section + 16, "ascii"); + fixture.bytes.writeBigUInt64LE(0x10c00n, section + 32); + fixture.bytes.writeBigUInt64LE(128n, section + 40); + fixture.bytes.writeUInt32LE(3072, section + 48); + const image = readGoMachoImage(fixture.bytes); + expect(image.sections[0]).toMatchObject({ + file_backed: false, + read_only: false, + }); +}); + it.each(["elf", "pe", "macho"] as const)( "refuses file aliases overlapping only part of a zero-filled %s module header", (format) => { diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index ac42b93881..97ad303414 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -1,6 +1,6 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { expect, it } from "vitest"; -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js"; @@ -13,7 +13,6 @@ import { sessionEvidence, } from "./ghidraMcpHarness.js"; import { spawn } from "node:child_process"; -import { mkdir } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { GhidraClient } from "../../../src/ghidra/GhidraClient.js"; diff --git a/tests/boundary/mcp/rustBinaryMcp.test.ts b/tests/boundary/mcp/rustBinaryMcp.test.ts new file mode 100644 index 0000000000..d903affd4d --- /dev/null +++ b/tests/boundary/mcp/rustBinaryMcp.test.ts @@ -0,0 +1,122 @@ +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 { + createRustImage, + createRustHashOverflowImage, +} from "../../fixtures/rust/image.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("Rust metadata must not start a deep provider"); + }); + const client = await mcp.connect(createServer({ kind: "session", session })); + return { client, session }; +}; + +mcpTest( + "advertises inspect_rust_binary and reads an embedded dependency list", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_rust_binary", + ); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-rust-mcp-"); + const path = join(root, "sample.elf"); + await writeFile(path, createRustImage({ format: "elf" })); + const response = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = toolContract("inspect_rust_binary").outputSchema.parse( + response.structuredContent, + ); + expect(evidence.normalized_result.auditable_metadata?.packages).toEqual([ + expect.objectContaining({ name: "itoa", version: "1.0.18" }), + expect.objectContaining({ + name: "sample", + version: "0.1.0", + root: true, + }), + ]); + const missing = join(root, "plain.elf"); + await writeFile( + missing, + createRustImage({ format: "pe", omitSection: true }), + ); + const absent = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path: missing }, + }); + expect(absent.isError).not.toBe(true); + const absentEvidence = toolContract( + "inspect_rust_binary", + ).outputSchema.parse(absent.structuredContent); + expect(absentEvidence.normalized_result.auditable_metadata).toBeNull(); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "requires an absolute path for inspect_rust_binary", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const response = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path: "sample" }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_rust_binary" }, + }); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "preserves rustc hash capacity units through the public error contract", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-rust-mcp-"); + const path = join(root, "many-hashes.elf"); + await writeFile(path, createRustHashOverflowImage()); + const response = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "resource_constraint", + details: { + operation: "inspect_rust_binary", + resource: "capacity", + reported_limits: { maximum_rustc_hashes: 64 }, + }, + }); + expect(parseMcpToolError(response).error.details).not.toHaveProperty( + "reported_limits.maximum_bytes", + ); + } finally { + await session.close(); + } + }, +); diff --git a/tests/boundary/rust/rustBinaryImage.test.ts b/tests/boundary/rust/rustBinaryImage.test.ts new file mode 100644 index 0000000000..d4aa12283f --- /dev/null +++ b/tests/boundary/rust/rustBinaryImage.test.ts @@ -0,0 +1,141 @@ +import { expect, it } from "vitest"; +import { fat } from "../../../src/domain/binaryTarget.fixture.js"; +import { + createRustImage, + createRustHashOverflowImage, + RUSTC_HASH, + rustDependencyPayload, +} from "../../fixtures/rust/image.js"; +import { readRustBinaryImage } from "../../../src/rust/RustBinaryImage.js"; +import { RustAuditableFormatFailure } from "../../../src/rust/RustAuditableReader.js"; + +const packages = [ + { + name: "itoa", + version: "1.0.18", + source: "crates.io", + kind: null, + dependencies: [], + root: false, + }, + { + name: "sample", + version: "0.1.0", + source: "local", + kind: null, + dependencies: [0], + root: true, + }, +]; + +it.each(["elf", "pe"] as const)( + "reads a .dep-v0 section and an inferred rustc hash from %s", + (format) => { + const image = readRustBinaryImage(createRustImage({ format })); + expect(image.format).toBe(format); + expect(image.auditable_metadata).toMatchObject({ + section_name: ".dep-v0", + file_offset: 1024, + section_bytes: rustDependencyPayload().length, + format_version: 1, + root_package_index: 1, + packages, + dependency_edges: [{ from_index: 1, to_index: 0 }], + }); + expect(image.rustc_commit).toEqual({ + evidence_kind: "inferred", + hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }], + }); + }, +); + +it("reads a Mach-O __DATA,.dep-v0 section", () => { + const image = readRustBinaryImage(createRustImage({ format: "macho" })); + expect(image).toMatchObject({ + format: "macho", + architecture: "arm64", + auditable_metadata: { + section_name: "__DATA,.dep-v0", + file_offset: 1024, + root_package_index: 1, + packages, + }, + rustc_commit: { + evidence_kind: "inferred", + hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }], + }, + }); +}); + +it("ignores PE file-alignment padding after the dependency content", () => { + const bytes = createRustImage({ format: "pe", includeRustc: false }); + const payloadBytes = rustDependencyPayload().length; + const entry = 264; + expect(bytes.readUInt32LE(entry + 8)).toBe(payloadBytes); + bytes.writeUInt32LE(512, entry + 16); + const image = readRustBinaryImage(bytes); + expect(image.auditable_metadata).toMatchObject({ + section_name: ".dep-v0", + section_bytes: payloadBytes, + packages, + }); +}); + +it("returns null metadata when the dependency section is absent", () => { + const image = readRustBinaryImage( + createRustImage({ format: "elf", omitSection: true }), + ); + expect(image.auditable_metadata).toBeNull(); + expect(image.rustc_commit?.hashes).toEqual([ + { hash: RUSTC_HASH, file_offset: 2048 }, + ]); +}); + +it("refuses a universal Mach-O container", () => { + expect(() => readRustBinaryImage(fat([0x0100000c]))).toThrow( + RustAuditableFormatFailure, + ); + expect(() => readRustBinaryImage(fat([0x0100000c]))).toThrow(/thin slice/); +}); + +it("does not read SHT_NOBITS dependency sections as stored bytes", () => { + const bytes = createRustImage({ format: "elf" }); + // The fixture writes the .dep-v0 section header at byte 384. + bytes.writeUInt32LE(8, 384 + 4); + let metadata: unknown; + try { + metadata = readRustBinaryImage(bytes).auditable_metadata; + } catch (cause: unknown) { + expect(cause).toBeInstanceOf(RustAuditableFormatFailure); + return; + } + expect(metadata).toBeNull(); +}); + +it("reads a rustc path from a read-only ELF section whose name exceeds 64 bytes", () => { + const bytes = createRustImage({ format: "elf", omitSection: true }); + // omitSection places the rustc section header at 384 and shstrtab at 320. + const nameOffset = bytes.readUInt32LE(384); + const tableOffset = Number(bytes.readBigUInt64LE(320 + 24)); + const longName = Buffer.concat([Buffer.alloc(65, 0x61), Buffer.from([0])]); + longName.copy(bytes, tableOffset + nameOffset); + bytes.writeBigUInt64LE(BigInt(nameOffset + longName.length), 320 + 32); + const image = readRustBinaryImage(bytes); + expect(image.auditable_metadata).toBeNull(); + expect(image.rustc_commit).toEqual({ + evidence_kind: "inferred", + hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }], + }); +}); + +it("refuses read-only data with more distinct rustc paths than it retains", () => { + const bytes = createRustHashOverflowImage(); + expect(() => readRustBinaryImage(bytes)).toThrow( + expect.objectContaining({ + constraint: { + resource: "capacity", + reported_limits: { maximum_rustc_hashes: 64 }, + }, + }), + ); +}); diff --git a/tests/fixtures/rust/image.ts b/tests/fixtures/rust/image.ts new file mode 100644 index 0000000000..6bbac25abe --- /dev/null +++ b/tests/fixtures/rust/image.ts @@ -0,0 +1,263 @@ +import { deflateSync } from "node:zlib"; + +/** zlib payload matching the embedded dependency JSON shape. */ +export const rustDependencyPayload = ( + document: unknown = { + packages: [ + { name: "itoa", version: "1.0.18", source: "crates.io" }, + { + name: "sample", + version: "0.1.0", + source: "local", + dependencies: [0], + root: true, + }, + ], + format: 1, + }, +): Buffer => deflateSync(Buffer.from(JSON.stringify(document))); + +export const RUSTC_HASH = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const RUSTC_PATH = `/rustc/${RUSTC_HASH}/library/core`; + +/** Synthetic ELF, PE, or thin Mach-O with an optional dependency section. */ +export const createRustImage = (options: { + readonly format: "elf" | "pe" | "macho"; + readonly payload?: Buffer; + readonly omitSection?: boolean; + readonly includeRustc?: boolean; +}): Buffer => { + const payload = options.payload ?? rustDependencyPayload(); + const rustc = options.includeRustc === false ? null : Buffer.from(RUSTC_PATH); + const bytes = Buffer.alloc(4096); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const little = (offset: number, value: number, width: 16 | 32 | 64) => { + if (width === 16) view.setUint16(offset, value, true); + else if (width === 32) view.setUint32(offset, value, true); + else view.setBigUint64(offset, BigInt(value), true); + }; + const image = { + bytes, + little, + payload, + omitSection: options.omitSection === true, + rustc, + }; + if (options.format === "elf") writeElf(image); + else if (options.format === "pe") writePe(image); + else writeMacho(image); + return bytes; +}; + +interface RustImageWrite { + readonly bytes: Buffer; + readonly little: (offset: number, value: number, width: 16 | 32 | 64) => void; + readonly payload: Buffer; + readonly omitSection: boolean; + readonly rustc: Buffer | null; +} + +const writeElf = ({ + bytes, + little, + payload, + omitSection, + rustc, +}: RustImageWrite): void => { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]); + little(16, 2, 16); + little(18, 62, 16); + little(20, 1, 32); + little(32, 128, 64); + little(40, 256, 64); + little(52, 64, 16); + little(54, 56, 16); + little(56, 1, 16); + little(58, 64, 16); + const names = omitSection + ? Buffer.from("\0.shstrtab\0.rodata\0") + : Buffer.from("\0.shstrtab\0.dep-v0\0.rodata\0"); + little(60, rustc === null && omitSection ? 0 : omitSection ? 3 : 4, 16); + little(62, rustc === null && omitSection ? 0 : 1, 16); + little(128, 1, 32); + little(132, 6, 32); + little(144, 0x400000, 64); + little(160, bytes.length, 64); + little(168, bytes.length, 64); + if (rustc === null && omitSection) return; + names.copy(bytes, 768); + const shstr = 320; + little(shstr, 1, 32); + little(shstr + 4, 3, 32); + little(shstr + 24, 768, 64); + little(shstr + 32, names.length, 64); + let next = 384; + if (!omitSection) { + little(next, 11, 32); + little(next + 4, 1, 32); + little(next + 24, 1024, 64); + little(next + 32, payload.length, 64); + payload.copy(bytes, 1024); + next += 64; + } + if (rustc !== null) { + const name = omitSection ? 11 : 19; + little(next, name, 32); + little(next + 4, 1, 32); + little(next + 8, 2, 64); + little(next + 16, 0x400000 + 2048, 64); + little(next + 24, 2048, 64); + little(next + 32, rustc.length, 64); + rustc.copy(bytes, 2048); + } +}; + +const writePe = ({ + bytes, + little, + payload, + omitSection, + rustc, +}: RustImageWrite): void => { + bytes.write("MZ", 0, "ascii"); + little(60, 128, 32); + bytes.write("PE\0\0", 128, "ascii"); + little(132, 0x8664, 16); + const count = Number(!omitSection) + Number(rustc !== null); + little(134, count, 16); + little(148, 112, 16); + little(152, 0x20b, 16); + little(176, 0x140000000, 64); + const writeSection = (section: { + readonly entry: number; + readonly name: string; + readonly offset: number; + readonly body: Buffer; + readonly address: number; + }) => { + bytes.write(section.name, section.entry, "ascii"); + little(section.entry + 8, section.body.length, 32); + little(section.entry + 12, section.address, 32); + little(section.entry + 16, section.body.length, 32); + little(section.entry + 20, section.offset, 32); + little(section.entry + 36, 0x40000040, 32); + section.body.copy(bytes, section.offset); + }; + let entry = 264; + if (!omitSection) { + writeSection({ + entry, + name: ".dep-v0", + offset: 1024, + body: payload, + address: 0x1000, + }); + entry += 40; + } + if (rustc !== null) + writeSection({ + entry, + name: ".rdata", + offset: 2048, + body: rustc, + address: 0x2000, + }); +}; + +const writeMacho = ({ + bytes, + little, + payload, + omitSection, + rustc, +}: RustImageWrite): void => { + const commands = Number(rustc !== null) + Number(!omitSection); + little(0, 0xfeedfacf, 32); + little(4, 0x0100000c, 32); + little(12, 2, 32); + little(16, commands, 32); + little(20, commands * 152, 32); + let command = 32; + if (rustc !== null) { + writeMachoSegment({ + bytes, + little, + command, + segment: "__TEXT", + section: "__cstring", + body: rustc, + fileOffset: 2048, + protection: 5, + flags: 2, + }); + command += 152; + } + if (!omitSection) + writeMachoSegment({ + bytes, + little, + command, + segment: "__DATA", + section: ".dep-v0", + body: payload, + fileOffset: 1024, + protection: 3, + flags: 0, + }); +}; + +const writeMachoSegment = ({ + bytes, + little, + command, + segment, + section, + body, + fileOffset, + protection, + flags, +}: { + readonly bytes: Buffer; + readonly little: RustImageWrite["little"]; + readonly command: number; + readonly segment: string; + readonly section: string; + readonly body: Buffer; + readonly fileOffset: number; + readonly protection: number; + readonly flags: number; +}): void => { + little(command, 0x19, 32); + little(command + 4, 152, 32); + bytes.write(segment, command + 8, "ascii"); + little(command + 24, 0x100000000, 64); + little(command + 32, 0x2000, 64); + little(command + 40, fileOffset, 64); + little(command + 48, body.length, 64); + little(command + 56, protection, 32); + little(command + 60, protection, 32); + little(command + 64, 1, 32); + const header = command + 72; + bytes.write(section, header, "ascii"); + bytes.write(segment, header + 16, "ascii"); + little(header + 32, 0x100000000, 64); + little(header + 40, body.length, 64); + little(header + 48, fileOffset, 32); + little(header + 64, flags, 32); + body.copy(bytes, fileOffset); +}; + +/** Read-only ELF data exceeding the distinct rustc-hash retention capacity. */ +export const createRustHashOverflowImage = (): Buffer => { + const bytes = createRustImage({ format: "elf", omitSection: true }); + // Without .dep-v0 the read-only section header is the second one, at 384. + // Section names end before byte 800, so the hash block starts there. + bytes.writeBigUInt64LE(800n, 384 + 24); + bytes.writeBigUInt64LE(3296n, 384 + 32); + bytes.fill(0, 800); + for (let index = 0; index < 65; index += 1) { + const hash = index.toString(16).padStart(40, "0"); + bytes.write(`/rustc/${hash}/`, 800 + index * 48, "latin1"); + } + return bytes; +}; diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/process-global/cleanLinuxHopperRuntime.test.ts index 8b7be17da6..fdd19e8ee1 100644 --- a/tests/process-global/cleanLinuxHopperRuntime.test.ts +++ b/tests/process-global/cleanLinuxHopperRuntime.test.ts @@ -41,6 +41,9 @@ it } // A rejection must come from the fixture we are about to launch. An // existing Xvfb would otherwise mask a broken executable-identity check. + // The copied launcher has a unique file identity and has not run yet. + // The synthetic proc root isolates the positive identity check from + // unrelated restricted host processes while retaining display-name checks. await assertCleanLinuxHopperRuntime( alternateMode ? [launcher] : [], procRoot, diff --git a/vitest.config.ts b/vitest.config.ts index bfef8e139d..a8bf6b0454 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -51,7 +51,7 @@ const TEST_PROJECTS = [ name: "adapters", include: [ "src/*.test.ts", - "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts", + "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,rust,server,wasm,windows}/**/*.test.ts", ], pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS,