diff --git a/.github/workflows/real-evm-interface.yml b/.github/workflows/real-evm-interface.yml new file mode 100644 index 0000000000..a493481812 --- /dev/null +++ b/.github/workflows/real-evm-interface.yml @@ -0,0 +1,66 @@ +name: Real offline EVM interface + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/real-evm-interface.yml" + - "src/evm/**" + - "src/domain/evm/**" + - "src/application/evm/**" + - "src/contracts/evm/**" + - "src/composition/evm.ts" + - "src/cli/evmCommands.ts" + - "src/server/registerEvmTools.ts" + - "src/server/createServer.ts" + - "src/cli.ts" + - "src/cliCommandNames.ts" + - "src/contracts/toolContracts.ts" + - "src/contracts/toolEffects.ts" + - "src/process/OwnedCommand.ts" + - "src/process/ProviderProcess.ts" + - "src/process/ProcessOwnership*.ts" + - "src/process/ProviderDeadline.ts" + - "src/process/PrivateRuntimeRoot.ts" + - "src/artifacts/readStableArtifact*.ts" + - "src/artifacts/ArtifactReader.ts" + - "scripts/verify-evm-interface.mjs" + - "scripts/fixtures/evm-interface.sol" + - "third_party/evmole/**" + - "package.json" + - "package-lock.json" + +permissions: + contents: read + +concurrency: + group: real-evm-interface-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + NODE_OPTIONS: --max-old-space-size=768 + HUSKY: "0" + +jobs: + real-evm-interface: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - name: Acquire unchanged fixture compiler in isolated runner storage + shell: bash + run: | + evm_fixture_root="${RUNNER_TEMP}/rea-evm-fixture-compiler" + mkdir -p "${evm_fixture_root}" + npm install --prefix "${evm_fixture_root}" --ignore-scripts --no-audit --no-fund --save-exact solc@0.8.30 + command -v prlimit + command -v strace + echo "REA_VERIFY_STRACE_COMMAND=$(command -v strace)" >> "${GITHUB_ENV}" + echo "REA_VERIFY_SOLC_MODULE=${evm_fixture_root}/node_modules/solc/index.js" >> "${GITHUB_ENV}" + - name: Verify source-owned compiled bytecode through public CLI and MCP + run: npm run verify:evm:interface diff --git a/README.md b/README.md index e0a1090a34..348e188296 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,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://morluto.github.io/rea/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) | +| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier; [offline EVM guide](docs/evm-bytecode.md) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://morluto.github.io/rea/guides/javascript/) | | Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://morluto.github.io/rea/guides/browser/) | | Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) | diff --git a/README_ar.md b/README_ar.md index 328c3b716b..8bc08d594a 100644 --- a/README_ar.md +++ b/README_ar.md @@ -218,7 +218,7 @@ rea setup | الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات | | سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C | | أدوات الملفات الثنائية | 9 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة على Linux | -| مخطط المخرجات | 5 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple والاستخراج | +| مخطط المخرجات | 7 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وتحليل تحميل Mach-O dylib والاستخراج واستدلال واجهات EVM دون اتصال | | PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات | | البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح | | Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة | diff --git a/README_ja.md b/README_ja.md index 813e870b2f..85fd9ba551 100644 --- a/README_ja.md +++ b/README_ja.md @@ -236,20 +236,20 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して ## 調査ツールカタログ -| ツール分類 | 数 | 用途 | -| -------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット | -| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 | -| ネイティブバイナリ | 9 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウトと静的な保護機構の証拠 | -| 成果物グラフ | 5 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、抽出 | -| マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 | -| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 | -| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッドの逆コンパイル、静的な被参照 | -| ブラウザー観察 | 12 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP の検出、スクリーンショット、キャプチャ比較 | -| Electron 解析 | 5 | レンダラーの観察、静的なアプリのマッピング、静的結果と実行時結果の照合 | -| JavaScript 実行時 | 2 | Node/Electron Inspector の対象検出、スクリプトの位置、実行コンテキストのイベント | -| アプリワークフロー | 13 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 | -| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 | +| ツール分類 | 数 | 用途 | +| -------------------- | --: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット | +| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 | +| ネイティブバイナリ | 9 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウトと静的な保護機構の証拠 | +| 成果物グラフ | 7 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib のロード解決、抽出、オフライン EVM インターフェース推定 | +| マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 | +| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 | +| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッドの逆コンパイル、静的な被参照 | +| ブラウザー観察 | 12 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP の検出、スクリーンショット、キャプチャ比較 | +| Electron 解析 | 5 | レンダラーの観察、静的なアプリのマッピング、静的結果と実行時結果の照合 | +| JavaScript 実行時 | 2 | Node/Electron Inspector の対象検出、スクリプトの位置、実行コンテキストのイベント | +| アプリワークフロー | 13 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 | +| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 | ## ロードマップ diff --git a/README_ko.md b/README_ko.md index ee9a5fa0cb..3eb11722eb 100644 --- a/README_ko.md +++ b/README_ko.md @@ -109,7 +109,7 @@ AI 코딩 어시스턴트에 스킬을 추가하면 더 풍부한 컨텍스트 npx skills add morluto/rea --skill reverse-engineer-anything ``` -이 스킬은 REA의 조사 워크플로를 제공합니다. 위 setup을 실행해 REA를 에이전트에 연결하고 분석 도구를 설정하세요. Setup은 기본적으로 버전에 맞는 스킬을 설치합니다. 이 명령은 저장소 버전의 스킬을 설치합니다. +이 스킬은 REA의 조사 워크플로를 제공합니다. 위 Setup을 실행해 REA를 에이전트에 연결하고 분석 도구를 설정하세요. Setup은 기본적으로 버전에 맞는 스킬을 설치합니다. 이 명령은 저장소 버전의 스킬을 설치합니다. 압축을 해제한 JavaScript/Electron 앱 트리나 ASAR라면 MCP 설정이나 네이티브 엔진 없이 바로 분석할 수 있습니다. @@ -117,7 +117,7 @@ npx skills add morluto/rea --skill reverse-engineer-anything npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json ``` -경로를 조사 대상으로 바꾸세요(Windows에서는 `"D:/apps/example"` 같은 경로). 이 정적 워크플로에서는 이런 경로도 지원됩니다. 네이티브 분석에는 전용 엔진 설정이 필요합니다. +경로를 조사 대상의 경로로 바꾸세요(Windows에서는 `"D:/apps/example"` 같은 경로). 이 정적 워크플로에서는 이런 경로도 지원됩니다. 네이티브 분석에는 전용 엔진 설정이 필요합니다. ### rea 명령 설치하기 @@ -144,7 +144,7 @@ rea setup 네이티브 바이너리 분석에는 Hopper 또는 Ghidra가 필요합니다. Hopper는 별도 소프트웨어입니다. 데모에는 공급업체의 제한이 있지만 유료 라이선스가 필수는 아닙니다. -Ghidra는 Linux x64와 macOS x64/arm64를 지원합니다. Ghidra 12.1.x와 그 설치본이 선언한 완전한 64비트 JDK(`application.java.min`부터 `application.java.max`까지)를 별도로 설치한 뒤 REA가 사용하도록 설정하세요. 현재 12.1 릴리스는 JDK 21 이상을 요구하고 상한은 없습니다. 브리지는 Ghidra 12.1.4와 JDK 21에서 검증됩니다. macOS에서는 호스트 아키텍처에 맞는 네이티브 디컴파일러도 필요합니다. +Ghidra는 Linux x64와 macOS x64/arm64를 지원합니다. Ghidra 12.1.x와 그 설치본이 선언한 완전한 64비트 JDK(`application.java.min`부터 `application.java.max`까지)를 별도로 설치한 뒤 REA에서 사용하도록 설정하세요. 현재 12.1 릴리스는 JDK 21 이상을 요구하고 상한은 없습니다. 브리지는 Ghidra 12.1.4와 JDK 21에서 검증됩니다. macOS에서는 호스트 아키텍처에 맞는 네이티브 디컴파일러도 필요합니다. Setup은 설치를 확인하고 경로를 저장할 수 있습니다. Ghidra, Java, Node.js, npm, Homebrew를 설치하거나 업데이트하지 않습니다. @@ -164,7 +164,7 @@ Linux에서는 실행 가능한 `/opt/hopper/bin/Hopper`를 우선 사용하고, ## 현재 지원 범위 -저장소의 현재 기능과 플랫폼 요구 사항은 [영문 지원 가이드](README.md#current-status)에 설명되어 있습니다. main은 [npm 릴리스](docs/installation.md#released-package-and-main)보다 앞설 수 있습니다. +저장소의 현재 기능과 플랫폼 요구 사항은 [영문 지원 가이드](README.md#current-status)에 설명되어 있습니다. main은 [npm 릴리스](docs/installation.md#released-package-and-main)보다 앞선 내용이 포함될 수 있습니다. - Ghidra는 Linux x64, macOS x64/arm64, 실험적인 Windows x64 P0 경계에서 25개의 읽기 전용 작업을 제공합니다. Linux/macOS에서는 원자적 세션 함수 주석도 추가로 지원합니다. Windows P0는 읽기 전용이며 Ghidra에는 GUI 제어가 없습니다. - 정적 Android 검사는 별도로 준비한 JADX/Java가 필요합니다. 실제 제공자 검증은 Linux를 대상으로 합니다. [Android 분석](docs/android-analysis.md)을 참고하세요. @@ -187,7 +187,7 @@ TypeScript와 SQLite를 사용해 제 프로젝트에 맞는 버전을 구현해 | 5 | 관련 루틴 디컴파일 | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` | | 6 | 프로젝트에 기능 구현 | 기술 스택, 제품, 요구 사항에 맞는 코드 | -REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이전트가 일반 파일 편집 및 테스트 도구로 수행합니다. +REA는 1~5단계의 바이너리 분석을 처리합니다. 6단계는 에이전트가 일반 파일 편집 및 테스트 도구로 수행합니다. ## 에이전트가 할 수 있는 일 @@ -203,20 +203,20 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이 ## 조사 도구 카탈로그 -| 도구 분류 | 수 | 용도 | -| ----------------- | --: | ------------------------------------------------------------------------------------------------------------------------- | -| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 | -| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 | -| 네이티브 바이너리 | 9 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃과 정적 보호 증거 | -| 아티팩트 그래프 | 5 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, 추출 | -| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 | -| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 | -| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 | -| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 | -| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 | -| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 | -| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 | -| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 | +| 도구 분류 | 수 | 용도 | +| ----------------- | --: | ---------------------------------------------------------------------------------------------------------------------------------------- | +| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 | +| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 | +| 네이티브 바이너리 | 9 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃과 정적 보호 증거 | +| 아티팩트 그래프 | 7 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, Mach-O dylib 로드 해석, 추출, 오프라인 EVM 인터페이스 추론 | +| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 | +| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 | +| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 | +| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 | +| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 | +| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 | +| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 | +| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 | ## 로드맵 @@ -273,7 +273,7 @@ rea --help rea mcp ``` -REA는 Mac의 `.app` 폴더를 직접 열 수 있습니다. 에이전트가 앱을 찾지 못하면 설치 위치를 알려 주세요. +REA는 macOS의 `.app` 폴더를 직접 열 수 있습니다. 에이전트가 앱을 찾지 못하면 설치 위치를 알려 주세요. ## Hopper 앱 동작 diff --git a/README_zh.md b/README_zh.md index 3c0f1d9e8a..9b187b5ce1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -202,20 +202,20 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常 ## 调查工具目录 -| 工具类别 | 数量 | 用途 | -| ----------------- | ---: | ------------------------------------------------------------------------------------------------------ | -| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 | -| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 | -| 原生二进制工具 | 9 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局和静态保护证据 | -| 产物图 | 5 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录与提取 | -| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 | -| 固件 | 2 | Linux 固件区域检查与显式提取 | -| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 | -| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 | -| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 | -| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 | -| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 | -| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 | +| 工具类别 | 数量 | 用途 | +| ----------------- | ---: | ----------------------------------------------------------------------------------------------------------------- | +| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 | +| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 | +| 原生二进制工具 | 9 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局和静态保护证据 | +| 产物图 | 7 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录、Mach-O dylib 加载解析、提取与离线 EVM 接口推断 | +| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 | +| 固件 | 2 | Linux 固件区域检查与显式提取 | +| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 | +| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 | +| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 | +| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 | +| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 | +| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 | ## 路线图 diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index bf606f34d7..e99504e164 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -110,3 +110,78 @@ The projection does not read plists. Use `inspect_plist` on a bundle's `info_plist_path` or a launchd plist to read `CFBundleExecutable`, identifiers, `SMPrivilegedExecutables`, `BundleProgram`, URL schemes, and usage descriptions. Use `inspect_signature` on an executable candidate for code-signing claims. + +## Dylib load resolution + +`trace_dylib_resolution` answers which file each Mach-O dylib load reaches. It +runs on the active Mach-O, or on every executable in the active `.app`. Each +executable in a bundle (the main app, XPC services, app extensions, login items, +privileged helpers, and helper tools) is a separate process root with its own +`@executable_path`. + +```sh +rea trace-dylib-resolution MyApp.app --json +rea trace-dylib-resolution MyApp.app --root Contents/MacOS/MyApp --architecture arm64 --json +``` + +```json +{ + "name": "trace_dylib_resolution", + "arguments": { "roots": ["Contents/MacOS/MyApp"], "architecture": "arm64" } +} +``` + +Load commands are parsed in TypeScript from Mach-O and FAT headers, including +the `dylib_use_command` encoding newer linkers emit for flags such as delayed +initialization. Nothing is executed and no Apple tool is required, so the tool +also works off macOS. + +Resolution follows dyld: + +- `@executable_path` is the directory of the process root's executable. It is + undetermined when the root is a library. +- `@loader_path` is the directory of the image that holds the load command, or + of the image that owns the `LC_RPATH` entry being expanded. +- `@rpath` tries each `LC_RPATH` of the loading image first, then those of the + image that loaded it, up to the process root. +- Dependencies load dependents-first in load-command order, and each image + loads once per process. A later request whose install name matches an + already loaded image reuses it. +- Symlinks such as `Versions/Current` are followed inside the analyzed root. + An escape through `..` or an absolute link is `escapes-target`. + +Each edge lists every candidate path tried, with its outcome: `resolved`, +`absent`, `not-mach-o`, `malformed`, `unsupported` (recognized Mach-O outside +parser coverage, such as big-endian images), `architecture-missing`, +`outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's +graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `resolution` is one of: + +| Status | Meaning | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. | +| `conditional` | An image inside the root resolves, but an earlier candidate outside the root (for example `/usr/lib/swift`) would win if it exists on the running system. | +| `unresolved` | Every candidate is definitively absent or unusable inside the root. | +| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. | + +A `conditional` image is still traced, because it is the image the analyzed +root supplies. Every edge below it has `loader_conditional: true`, and a +request that reuses it by install name is `conditional` too. Findings on those +edges say that they apply only if the conditional image loads. + +Absolute install names such as `/usr/lib/libSystem.B.dylib` are +`outside-target`. On macOS 11 and later most of them live in the dyld shared +cache rather than on disk, so REA does not check them against the host. + +`findings` are derived, not observed: + +- `required-load-unresolved`, `weak-load-unresolved`, and `lazy-load-unresolved`. + `LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld + loads them on first use; an unresolved one is not a launch failure. +- `earlier-rpath-candidate-absent`: a Mach-O placed at an earlier search path + would load first. Whether code-signing library validation would reject it is + not evaluated; use `inspect_signature`. +- `dyld-environment-present`: `LC_DYLD_ENVIRONMENT` search paths are not modeled. + +`verify:macos-bundle` checks the parser against `otool -l` for every traced +image. It also compares the predicted load order of two process roots with the +images dyld actually loads (`DYLD_PRINT_LIBRARIES`). diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid index 2bad907aa9..8bbb9a1474 100644 --- a/docs/architecture.mermaid +++ b/docs/architecture.mermaid @@ -27,6 +27,7 @@ graph TD subgraph "Provider Layers" PWNTOOLS["src/native/pwntools/
BYO offline ELF adapter
owned Python process, unchanged upstream tables/heuristics"] + EVM["src/evm/
unchanged EVMole/WASM in bounded owned worker
explicit carrier parsing and inferred interface adapter"] HOPPER["src/hopper/
Hopper launch & Unix-socket protocol
BridgeLauncher, HopperClient, protocol"] GHIDRA["src/ghidra/
Ghidra discovery, profile & function analysis
snapshot, Unix/Windows transport, bounded lifecycle"] IDA["src/ida/
Upstream MCP contract adaptation
live GUI binding or owned headless database"] @@ -144,3 +145,8 @@ graph TD TESTS -.-> BROWSER TESTS -.-> EVALUATION TESTS -.-> ERRORS + + COMPOSITION --> EVM + APP -->|EVM interface port| EVM + EVM --> PROCESS + EVM --> DOMAIN diff --git a/docs/error-contract.schema.json b/docs/error-contract.schema.json index 11f0fc6591..b79e8cee31 100644 --- a/docs/error-contract.schema.json +++ b/docs/error-contract.schema.json @@ -10,6 +10,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -45,6 +46,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -80,6 +82,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -115,6 +118,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -150,6 +154,43 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "resource_constraint", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { "action": { "type": "string", "minLength": 1 } }, + "required": ["action"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": {} + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "unsupported_target" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -185,6 +226,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -220,6 +262,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -255,6 +298,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -290,6 +334,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -325,6 +370,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -382,6 +428,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -417,6 +464,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -452,6 +500,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -487,6 +536,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -522,6 +572,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -557,6 +608,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -592,6 +644,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -627,6 +680,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -662,6 +716,7 @@ "enum": [ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md new file mode 100644 index 0000000000..5c0c67ae67 --- /dev/null +++ b/docs/evm-bytecode.md @@ -0,0 +1,84 @@ +# Offline EVM bytecode + +`inspect_evm_interface` / `inspect-evm-interface` infers interface candidates +from one explicitly selected local bytecode carrier. The capability is general: +contract investigation, compatibility, audit and CTF showcases use the same +portable primitive. It does not require an active native binary target. + +```sh +rea inspect-evm-interface ./runtime.hex hex --json +rea inspect-evm-interface ./runtime.bin raw --json +``` + +```json +{ + "name": "inspect_evm_interface", + "arguments": { "path": "/artifacts/runtime.hex", "encoding": "hex" } +} +``` + +Initial real verification covers flat legacy bytecode on Linux x64 and unchanged +bundled EVMole 0.9.3. EOF-style EF00 containers return `unsupported_target`, +with the selected path and format reason. Provider/configuration failures retain +their distinct unavailable-provider or capability classification. +The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`; +`REA_EVM_PRLIMIT_COMMAND` can select another absolute executable. REA installs +no system tools and changes no user configuration. [Upstream provenance and +license](../third_party/evmole/README.md) are packaged. + +## Evidence semantics + +- `encoding` is required: raw bytes or UTF-8 hex, with optional `0x` prefix and + outer ASCII whitespace. Odd hex, interior separators, BOM, non-ASCII whitespace + and invalid UTF-8 are rejected. Empty bytecode is valid and has unknown coverage. +- Carrier SHA-256 identifies the exact original file, including prefix/casing + and whitespace. Decoded-byte SHA-256 identifies actual selected EVM bytes. + Neither digest is the Ethereum Keccak code hash. +- Full bytecode, upstream function offsets and original producer representation + are returned inline. Offsets are byte positions in decoded bytecode. +- Upstream also returns terminal metadata during selector recovery. Its reported + values remain in `raw_result`; integer precision and non-text-key coverage are + unknown in the JS binding. Full bytecode retains the original encoded metadata. +- Selectors, argument strings and state mutability are **inferences**. Function + names/signatures are not looked up. Optimized code may produce different + mutability candidates; recovered types are not a verified source ABI. +- Runtime/init-code identity, deployed authenticity, hardfork and discovery + completeness remain unknown. Empty candidates do not prove no callable interface. +- Analysis is offline: no target runtime execution, implicit chain/RPC/explorer + lookup, wallet action, transaction broadcast or network request. + +## Resource and lifecycle limits + +Inputs are limited to 4 MiB, complete replies to 16 MiB and combined diagnostic +output to 1 MiB. An owned worker has a 256 MiB JavaScript heap, 3 GiB virtual +address-space limit, 30 CPU seconds and a 30-second command deadline. Virtual +address space differs from resident memory. The worker retains inherited tighter +soft/hard limits, reports its configured soft limits inline and leaves hard limits +unchanged. WASM trap reservations are disabled +so the engine can run within that virtual limit; actual memory is not equated to +its 3 GiB allowance. Limits fail without partial success. Stable snapshots, +private files and owned processes are cleaned independently of caller cancellation. +Invalid-input, unsupported, cancellation, timeout and malformed-reply failures +retain bounded stdout/stderr under `details.captured_output`, including whether +diagnostic output was truncated. Cleanup failures preserve the +original error projection and any captured output. +Successful results expose `diagnostics.truncated` from the supervisor as well. +Excess diagnostic output fails the complete-output contract even when the worker +has written a valid reply. +SIGXCPU retains a CPU resource diagnostic and CPU-specific guidance. Observed +file-size write failures (EFBIG) and SIGXFSZ likewise retain a file-size resource +diagnostic. Exact signal/write causes remain unknown. Configured +soft limits are reported separately from unknown effective limits; the received +signal alone does not establish its exact cause. +Reserved file-size exits require a matching private marker written by the +worker's EFBIG branch. Bare launcher exits or unwritable markers retain process +diagnostics with the resource cause unverified. + +Source/metadata, proxy resolution, storage/CFG and fork-explicit execution/traces +remain separate increments under #972, with materially different result or +execution authority. This primitive makes no deployment or runtime behavior claim. + +The real verification lane traces representative raw/hex CLI workflows and checks +that only declared Node/prlimit launchers and process ownership inspection run. +Socket traces permit local stdio socket pairs and reject attempted network requests. +These observations verify those fixtures; they are not a general network sandbox. diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 24ccf7ebe8..48860c5311 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -12,34 +12,13 @@ input file and include the observed lipo slice offset for universal binaries. If that offset is unavailable, segment evidence locations are omitted with an explicit limitation; architecture inventory locations remain available. -- `inspect_signature` / `rea inspect-signature ` reports - codesign identity, requirements and entitlements, and adds: - - **Observed facts:** - - the parsed CodeDirectory, with flags decoded from their numeric value - (`runtime`, `library-validation`, `restrict`, `kill`, `adhoc`, - `linker-signed`, …), slot counts, platform identifier and runtime - version; - - sealed resources; - - a local `codesign --verify --deep --strict` result, with diagnostics and the - nested code it validated; - - whether a notarization ticket is stapled at `Contents/CodeResources`. - The ticket itself and Apple's notarization record are not checked, - because that needs the network. - - **Derived `security_facets`**, each with the flags or entitlements that - support it: - - library validation; - - whether dyld honors `DYLD_*` variables; - - debugger attach; - - executable-memory policy; - - App Sandbox. - - A CodeDirectory platform identifier counts as a platform binary only - when the signature also satisfies `anchor apple` (Apple's own code; - `codesign --verify -R="anchor apple"`). Otherwise the facets that depend - on it are `unknown`. A universal file whose slices differ in signing - state reports every facet as `unknown`, because no one CodeDirectory - describes the process. System Integrity Protection, AMFI and setuid - policy are not evaluated. +- `trace_dylib_resolution` / `rea trace-dylib-resolution ` + parses Mach-O load commands in TypeScript and follows dyld's path expansion + for every executable in an app bundle, or for one Mach-O within its directory. + It reports each `@rpath`, `@loader_path` and `@executable_path` candidate with + its outcome. Paths outside the analyzed root, including shared-cache system + libraries, stay undetermined. See + [Apple application analysis](apple-application-analysis.md#dylib-load-resolution). - `inspect_asset_catalog` / `rea inspect-asset-catalog ` reads compiled `Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw rendition fields, pagination and exact UI resource-name matches are returned. diff --git a/docs/product-catalog.json b/docs/product-catalog.json index f3339f5ffc..6946f04daf 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -7,10 +7,10 @@ "client": "2.3.1", "core": "2.3.1" }, - "skill_version": "31" + "skill_version": "32" }, "tools": { - "total": 136, + "total": 138, "families": [ { "id": "direct", @@ -100,13 +100,15 @@ { "id": "artifact", "surface": "artifact-provider", - "count": 5, + "count": 7, "tools": [ "decode_interface_builder", "extract_artifact", "inspect_artifact", "inspect_asset_catalog", - "inspect_keyed_archive" + "inspect_evm_interface", + "inspect_keyed_archive", + "trace_dylib_resolution" ] }, { @@ -238,6 +240,12 @@ "version": "3.1.0", "capabilities": ["inspect_firmware_regions"] }, + { + "id": "evmole-interface", + "name": "REA EVM interface adapter", + "version": "evmole@0.9.3", + "capabilities": ["inspect_evm_interface"] + }, { "id": "ghidra", "name": "Ghidra", @@ -400,7 +408,8 @@ "extract_artifact", "inspect_artifact", "inspect_asset_catalog", - "inspect_keyed_archive" + "inspect_keyed_archive", + "trace_dylib_resolution" ] }, { @@ -592,7 +601,7 @@ } ], "cli": { - "primary_count": 91, + "primary_count": 93, "commands": [ "address-to-file-offset", "analyze", @@ -636,6 +645,7 @@ "inspect-asset-catalog", "inspect-binary-layout", "inspect-electron-page", + "inspect-evm-interface", "inspect-firmware-regions", "inspect-keyed-archive", "inspect-macho", @@ -676,6 +686,7 @@ "trace", "trace-android-references", "trace-application-feature", + "trace-dylib-resolution", "trace-javascript-semantics", "trace-native-ui-action", "trace-native-values", @@ -695,15 +706,15 @@ }, "runtime_catalog": { "counts": { - "cli_commands": 91, - "mcp_tools": 136, + "cli_commands": 93, + "mcp_tools": 138, "mcp_prompts": 6 }, "digests": { - "tools_sha256": "b9d50917b751626717ad032fd2e6e1c3d56c93d9df6e8d18c60887b5967e574b", + "tools_sha256": "a7bb9ac341e2164857914c18ad0d4a499f9f7ae7927bb2c684c983d32cfa928d", "prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d", - "combined_sha256": "3206a7f58e9e56f55570ba375ac90b0be91891d21ee97a493870f6a1a0149d77", - "providers_sha256": "6e0080dea56ee7f3ebdf5ce0d74ea18e5d44ebce80b89c7f4de94e9c69f4d2e5" + "combined_sha256": "aab2ca1d88c1ce394e8eb2c842ab101e000e2aebd7489ce482cc46a36eef940f", + "providers_sha256": "44935c127fd040af492f2da591dfcd782d766770d306b91242216674220047ed" } } } diff --git a/docs/testing.md b/docs/testing.md index 8a33a4eac7..21e33bcb56 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -111,7 +111,16 @@ extension, a login item, a privileged helper, launchd plists, and a helper tool, signed ad hoc. It packs the app as a directory, a `ditto` ZIP, and an APFS DMG, then checks that `inspect-artifact` plus `project-apple-application-graph` report the same bundle anatomy for all three through the CLI, with stdio MCP -parity. It also checks that the DMG is detached afterwards. It runs in macOS CI. +parity. It also checks that the DMG is detached afterwards. The same app +covers `trace-dylib-resolution`: + +- each resolution status and finding, with CLI/MCP parity; +- for every traced image, dependencies, rpaths, and install names equal to + `otool -l`; +- for the main executable and an XPC service, a predicted load order equal to + the images dyld actually loads under `DYLD_PRINT_LIBRARIES`. + +It runs in macOS CI. Apple artifact verifiers live in `scripts/verify/apple/`, with the macOS bundle builder under `scripts/fixtures/apple/` and NIB byte fixtures beside the decoder @@ -641,6 +650,16 @@ the ordinary lane. Its large MCP transfer is opt-in with explicit 256 MiB SDK receive buffer and five-minute request timeout. Ordinary MCP fixtures retain the pinned SDK defaults. +### Offline EVM interface + +`npm run verify:evm:interface` requires Linux x64, an absolute +`REA_VERIFY_STRACE_COMMAND`, caller-supplied util-linux `prlimit` and `REA_VERIFY_SOLC_MODULE` selecting the absolute module path for +solc 0.8.30. It compiles source-owned plain/optimized/via-IR Cancun fixtures in +private storage and checks actual CLI/MCP selector evidence, raw/hex identity, +unknowns, malformed carriers and independent cleanup. An optional positional +entrypoint verifies a fresh installed package. It acquires no engine, compiler +or chain dependency and does not execute a contract on a chain. + ## Agent evaluation and conformance records Evaluate native, JavaScript, managed and browser investigation tasks through a diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json index 3f4cba022f..2733e0bf8f 100644 --- a/docs/verification/managed-conformance-manifest.json +++ b/docs/verification/managed-conformance-manifest.json @@ -1,5 +1,5 @@ { - "manifest_id": "ecm_8c723660f3d2f6488eba07eb0dd886168f99455325f652fc3c843484cc3e016e", + "manifest_id": "ecm_637a2f0b5413b8b3764b3614e8d327dd3d423bab98d0e94be0a8653fa386d562", "verifier": { "id": "rea-managed-conformance", "version": "1" @@ -13,7 +13,7 @@ "skill_digests": [ { "skill_id": "reverse-engineer-anything", - "sha256": "427adc0c37996ff688edf9e2d194eacf8147b858187678b9912654f6009dda3d" + "sha256": "cd5321f1bb83f53447f7ab19b98da997211158f889c9736ca965cf7b9f825564" } ], "claims": [ diff --git a/package-lock.json b/package-lock.json index 1bb44c60c8..cd3225dab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "canonicalize": "3.0.0", "cross-spawn": "7.0.6", "diff": "9.0.0", + "evmole": "0.9.3", "har-schema": "2.0.0", "ignore": "7.0.6", "incur": "0.6.1", @@ -4643,6 +4644,18 @@ "node": ">=18.0.0" } }, + "node_modules/evmole": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/evmole/-/evmole-0.9.3.tgz", + "integrity": "sha512-UhO5HMAoEPCupSxvBJPlm/L0Qk/KVJ/OJo3r4EyhXADIEHqU5aTkAzsBfzk4lXGjqxVnaYbh1GPSmZtmSHFnQQ==", + "license": "MIT", + "bin": { + "evmole": "dist/evmole_cli.mjs" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", diff --git a/package.json b/package.json index aa19ecbb4d..4f7f10954a 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,9 @@ "scripts/verify-windows-native-artifact.mjs", "bridge/pwntools/layout.py", "bridge/pwntools/layout_impl.py", - "third_party/pwntools/README.md" + "third_party/pwntools/README.md", + "third_party/evmole/README.md", + "third_party/evmole/LICENSE" ], "scripts": { "deps:check": "node scripts/check-dependency-install.mjs", @@ -200,7 +202,8 @@ "verify:browser:source-maps": "npm run build:cached && node scripts/verify-browser-source-maps.mjs", "verify:browser:runtime": "npm run build:cached && node scripts/verify-browser-runtime.mjs", "verify:web:network-captures": "npm run build:cached && node scripts/verify-web-network-captures.mjs", - "verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs" + "verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs", + "verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs" }, "lint-staged": { "*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [ @@ -240,7 +243,8 @@ "smol-toml": "1.9.0", "write-file-atomic": "7.0.0", "ws": "8.21.0", - "zod": "4.4.3" + "zod": "4.4.3", + "evmole": "0.9.3" }, "overrides": { "vitepress": { diff --git a/scripts/fixtures/apple/macos-bundle.mjs b/scripts/fixtures/apple/macos-bundle.mjs index de2dcae812..adb1a2c108 100644 --- a/scripts/fixtures/apple/macos-bundle.mjs +++ b/scripts/fixtures/apple/macos-bundle.mjs @@ -138,7 +138,11 @@ const char *core_chain(void) { return sibling_value() == 7 ? chain_variant() : " return join(version, "Core"); }; -/** The main executable, with a weak dependency deleted after linking. */ +/** + * The main executable. It searches a missing `Overrides` rpath before + * `Frameworks`, weakly links a library deleted after linking, and + * delay-links a library, which the linker encodes as `dylib_use_command`. + */ const buildMainExecutable = async (root, contents, coreBinary) => { const gone = join(contents, "Frameworks", "libgone.dylib"); await clang(root, { @@ -147,22 +151,33 @@ const buildMainExecutable = async (root, contents, coreBinary) => { output: gone, flags: ["-dynamiclib", "-install_name", "@rpath/libgone.dylib"], }); + const delayed = join(contents, "Frameworks", "libdelay.dylib"); + await clang(root, { + name: "delay", + source: `int delay_value(void) { return 3; }\n`, + output: delayed, + flags: ["-dynamiclib", "-install_name", "@rpath/libdelay.dylib"], + }); const executable = join(contents, "MacOS", "MacFixture"); await clang(root, { name: "main", source: `#include const char *core_chain(void); +int delay_value(void); extern int gone_value(void) __attribute__((weak_import)); int main(void) { + if (delay_value() != 3) return 1; printf("chain=%s weak=%s\\n", core_chain(), gone_value ? "present" : "absent"); return 0; } `, output: executable, flags: [ + "-Wl,-rpath,@executable_path/../Overrides", "-Wl,-rpath,@executable_path/../Frameworks", coreBinary, `-Wl,-weak_library,${gone}`, + `-Wl,-delay_library,${delayed}`, ], }); await rm(gone); @@ -191,7 +206,12 @@ const buildNestedBundles = async (root, contents, coreBinary) => { int main(void) { return core_chain()[0] == 'b'; } `, output: join(service, "MacOS", "Svc"), - flags: ["-Wl,-rpath,@executable_path/../../../../Frameworks", coreBinary], + // An absolute rpath outside the bundle precedes the bundled framework. + flags: [ + "-Wl,-rpath,/opt/rea-fixture-absent/lib", + "-Wl,-rpath,@executable_path/../../../../Frameworks", + coreBinary, + ], }); await write( join(service, "Info.plist"), diff --git a/scripts/fixtures/evm-interface.sol b/scripts/fixtures/evm-interface.sol new file mode 100644 index 0000000000..396479172d --- /dev/null +++ b/scripts/fixtures/evm-interface.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +// Public source-owned fixture; generated runtime bytecode is never tracked. +contract InterfaceFixture { + uint256 public stored; + function set(uint256 value) external { stored = value; } + function read() external view returns (uint256) { return stored; } + function pair(uint32 a, address b) external pure returns (uint32, address) { return (a, b); } + receive() external payable {} + fallback() external {} +} diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index efe58e26c3..8049261cd6 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -24,7 +24,10 @@ export const toolFamilyCatalog = (sources) => { { id: "artifact", surface: "artifact-provider", - contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, + contracts: [ + ...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, + ...sources.evmContracts.EVM_TOOL_CONTRACTS, + ], }, { id: "managed", @@ -151,6 +154,10 @@ export const providerCatalog = (sources) => { contracts: sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS, }, + { + identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY, + contracts: sources.evmContracts.EVM_TOOL_CONTRACTS, + }, { identity: sources.artifactProviders.ARTIFACT_GRAPH_PROVIDER, contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 178702f1ca..9dcfdbb187 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -44,6 +44,8 @@ const SOURCE_PATHS = { "dist/contracts/native/binaryDiagnosticsToolContracts.js", pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js", nativeContracts: "dist/contracts/native/nativeToolContracts.js", + evmContracts: "dist/contracts/evm/evmToolContracts.js", + evmProvider: "dist/evm/EvmoleRelease.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 b50050d79f..d52a651f56 100644 --- a/scripts/lib/module-boundaries.mjs +++ b/scripts/lib/module-boundaries.mjs @@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const PURE_LAYERS = new Set(["domain", "contracts"]); const MIGRATED_PROVIDER_ROOTS = new Set([ + "evm", "android", "firmware", "ghidra", diff --git a/scripts/verify-evm-interface.mjs b/scripts/verify-evm-interface.mjs new file mode 100644 index 0000000000..39cb2b356c --- /dev/null +++ b/scripts/verify-evm-interface.mjs @@ -0,0 +1,577 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { access, readFile, readdir, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { mcpTextValue } from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +const solcModule = process.env.REA_VERIFY_SOLC_MODULE; +const strace = process.env.REA_VERIFY_STRACE_COMMAND; +if ( + process.platform !== "linux" || + process.arch !== "x64" || + !isAbsolute(solcModule ?? "") || + !isAbsolute(strace ?? "") +) + throw new Error( + "verify:evm:interface requires Linux x64, caller-supplied util-linux prlimit and absolute REA_VERIFY_SOLC_MODULE pointing to unchanged solc 0.8.30 and absolute REA_VERIFY_STRACE_COMMAND. No target or chain is executed.", + ); +try { + await access( + process.env.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit", + constants.X_OK, + ); + await access(solcModule, constants.R_OK); + await access(strace, constants.X_OK); +} catch (cause) { + throw new Error( + "verify:evm:interface prerequisite unavailable: selected solc module, strace or util-linux prlimit", + { cause }, + ); +} +const solc = (await import(pathToFileURL(solcModule).href)).default; +assert.equal(solc.version(), "0.8.30+commit.73712a01.Emscripten.clang"); +const source = await readFile( + new URL("./fixtures/evm-interface.sol", import.meta.url), + "utf8", +); +const entrypoint = + process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const execute = promisify(execFile); +const environment = Object.fromEntries( + Object.entries(process.env).filter(([, value]) => typeof value === "string"), +); +const run = createVerifierRun(); +const root = await PrivateRuntimeRoot.create({ prefix: "rea-evm-verifier-" }); +const client = new Client({ name: "evm-interface-verifier", version: "1" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "pipe", +}); +const failures = []; +let cases = 0; +try { + await client.connect(transport); + const tools = await client.listTools(); + assert.ok(tools.tools.some(({ name }) => name === "inspect_evm_interface")); + const inspect = async ( + mode, + path, + encoding, + expectedCategory, + scope = { command: process.execPath, prefix: [], client }, + ) => { + let value; + if (mode === "cli") { + let stdout; + try { + ({ stdout } = await execute( + scope.command, + [ + ...scope.prefix, + entrypoint, + "inspect-evm-interface", + path, + encoding, + "--json", + ], + { + env: scope.environment ?? environment, + timeout: 45_000, + maxBuffer: 32 * 1024 * 1024, + }, + )); + } catch (cause) { + if (typeof cause.code !== "number" || typeof cause.stdout !== "string") + throw cause; + stdout = cause.stdout; + } + value = JSON.parse(stdout); + } else { + const reply = await scope.client.callTool({ + name: "inspect_evm_interface", + arguments: { path, encoding }, + }); + value = JSON.parse(mcpTextValue(reply)); + if (expectedCategory !== undefined) { + assert.equal(reply.isError, true); + value = value.error; + } else assert.notEqual(reply.isError, true, mcpTextValue(reply)); + } + if (expectedCategory !== undefined) { + assert.equal(value.category, expectedCategory, JSON.stringify(value)); + return value; + } + assert.equal( + value.category, + undefined, + `Unexpected public failure: ${JSON.stringify(value)}`, + ); + const evidence = parseEvidence(mode === "cli" ? value : value.evidence); + const report = evidence.normalized_result; + assert.equal(report.diagnostics.truncated, false); + assert.equal(evidence.provider.version, "evmole@0.9.3"); + assert.equal(evidence.confidence, "inferred"); + assert.equal(report.artifact.path, path); + assert.equal(report.artifact.encoding, encoding); + const carrier = await readFile(path); + assert.equal( + report.artifact.sha256, + createHash("sha256").update(carrier).digest("hex"), + ); + assert.equal(evidence.subject.digest.sha256, report.artifact.sha256); + assert.equal(report.artifact.bytes, carrier.length); + assert.equal( + report.bytecode.sha256, + createHash("sha256") + .update(Buffer.from(report.bytecode.hex, "hex")) + .digest("hex"), + ); + assert.equal(report.bytecode.bytes, report.bytecode.hex.length / 2); + for (const key of ["kind", "hardfork", "deployment_authenticity"]) + assert.equal(report.bytecode[key], "unknown"); + assert.equal(report.discovery_completeness, "unknown"); + assert.equal(report.runtime_execution, "not-performed"); + assert.deepEqual( + report.functions.map((fn) => ({ + selector: fn.selector.slice(2), + bytecodeOffset: fn.bytecode_offset, + dispatch: fn.dispatch, + ...(fn.inferred_arguments === null + ? {} + : { arguments: fn.inferred_arguments }), + ...(fn.inferred_state_mutability === null + ? {} + : { stateMutability: fn.inferred_state_mutability }), + })), + evidence.raw_result.functions ?? [], + ); + if (mode === "mcp") assert.deepEqual(value.result, report); + return report; + }; + for (const [name, optimizer, viaIR] of [ + ["plain", false, false], + ["optimized", true, false], + ["via-ir", true, true], + ]) { + const output = JSON.parse( + solc.compile( + JSON.stringify({ + language: "Solidity", + sources: { "Interface.sol": { content: source } }, + settings: { + optimizer: { enabled: optimizer, runs: 200 }, + viaIR, + evmVersion: "cancun", + outputSelection: { + "*": { + "*": [ + "evm.deployedBytecode.object", + "evm.methodIdentifiers", + "abi", + ], + }, + }, + }, + }), + ), + ); + assert.ok( + !output.errors?.some(({ severity }) => severity === "error"), + JSON.stringify(output.errors), + ); + const artifact = output.contracts["Interface.sol"].InterfaceFixture; + const hex = artifact.evm.deployedBytecode.object; + const expected = Object.values(artifact.evm.methodIdentifiers) + .map((selector) => "0x" + selector) + .sort(); + const forms = [ + ["hex", ` \t0x${hex.toUpperCase()}\r\n`], + ["raw", Buffer.from(hex, "hex")], + ]; + for (const [encoding, data] of forms) { + const path = join(root.path, `${name}.${encoding}`); + await writeFile(path, data); + const original = await readFile(path); + for (const mode of ["cli", "mcp"]) { + const report = await inspect(mode, path, encoding); + assert.equal(report.bytecode.hex, hex); + assert.deepEqual( + report.functions + .filter(({ dispatch }) => dispatch === "abi") + .map(({ selector }) => selector) + .sort(), + expected, + ); + assert.ok( + report.functions.every( + ({ bytecode_offset }) => bytecode_offset < report.bytecode.bytes, + ), + ); + const set = report.functions.find( + ({ selector }) => + selector === "0x" + artifact.evm.methodIdentifiers["set(uint256)"], + ); + assert.equal(set.inferred_arguments, "uint256"); + assert.equal(set.inferred_state_mutability, "nonpayable"); + cases++; + } + assert.deepEqual(await readFile(path), original); + } + } + for (const [name, data, encoding] of [ + ["empty", "0x", "hex"], + ["empty-raw", Buffer.alloc(0), "raw"], + ["incomplete-push", Buffer.from([0x7f]), "raw"], + ["no-dispatch", "60006000f3", "hex"], + ]) { + const path = join(root.path, name); + await writeFile(path, data); + for (const mode of ["cli", "mcp"]) { + const report = await inspect(mode, path, encoding); + assert.equal(report.functions.length, 0); + cases++; + } + } + for (const [name, hex, expected] of [ + ["zero-selector", "60003560e01c630000000014601057005b00", ["0x00000000"]], + ["push-immediate", "63123456785060006000f3", []], + ]) { + const path = join(root.path, name); + await writeFile(path, hex); + for (const mode of ["cli", "mcp"]) { + const report = await inspect(mode, path, "hex"); + assert.deepEqual( + report.functions.map(({ selector }) => selector), + expected, + ); + cases++; + } + } + for (const [encoding, data] of [ + ["raw", Buffer.from("ef0001", "hex")], + ["hex", "0xef0001"], + ]) { + const path = join(root.path, "eof." + encoding); + await writeFile(path, data); + for (const mode of ["cli", "mcp"]) { + const failure = await inspect(mode, path, encoding, "unsupported_target"); + assert.equal(failure.code, "unsupported_target"); + assert.equal(failure.details.path, path); + assert.match(failure.details.reason, /EF00/); + assert.match(failure.remediation.action, /target format/); + cases++; + } + } + for (const [name, data] of [ + ["odd", "0x1"], + ["invalid-hex", "xyz"], + ["interior-space", "60 00"], + ["bom", "\uFEFF6000"], + ["utf8", Buffer.from([0xff])], + ]) { + const path = join(root.path, name); + await writeFile(path, data); + for (const mode of ["cli", "mcp"]) { + await inspect(mode, path, "hex", "invalid_input"); + cases++; + } + } + for (const mode of ["cli", "mcp"]) { + await inspect(mode, join(root.path, "absent"), "raw", "invalid_input"); + cases++; + } + for (const [option, expected] of [ + ["--as=2147483648", "address_space_bytes=2147483648"], + ["--cpu=20", "cpu_seconds=20"], + ["--fsize=8388608", "file_size_bytes=8388608"], + ]) { + const limiter = environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; + const prefix = [ + option, + "--", + process.execPath, + "--disable-wasm-trap-handler", + ]; + const limitedClient = new Client({ + name: "limited-evm-verifier", + version: "1", + }); + const limitedTransport = new StdioClientTransport({ + command: limiter, + args: [...prefix, entrypoint, "mcp"], + env: environment, + stderr: "pipe", + }); + try { + await limitedClient.connect(limitedTransport); + const scope = { command: limiter, prefix, client: limitedClient }; + for (const mode of ["cli", "mcp"]) { + const report = await inspect( + mode, + join(root.path, "zero-selector"), + "hex", + undefined, + scope, + ); + assert.ok(report.limitations.some((item) => item.includes(expected))); + cases++; + } + } finally { + try { + await limitedClient.close(); + } finally { + await limitedTransport.close(); + } + } + } + const brokenLimiter = join(root.path, "broken-limiter"); + await writeFile(brokenLimiter, "#!/rea-verifier-missing-interpreter\n", { + mode: 0o700, + }); + const signalLimiter = join(root.path, "signal-limiter"); + await writeFile(signalLimiter, '#!/bin/sh\nulimit -c 0\nkill -XCPU "$$"\n', { + mode: 0o700, + }); + const fileLimiter = join(root.path, "file-size-limiter"); + const unmarkedLimiter = join(root.path, "unmarked-file-size-limiter"); + await writeFile( + unmarkedLimiter, + '#!/bin/sh\nprintf "launcher exit 76\\n" >&2\nexit 76\n', + { mode: 0o700 }, + ); + // Fixture-only launcher: run the actual Node/WASM worker with a one-byte + // file allowance, keeping the usual address-space, CPU and core budgets. + await writeFile( + fileLimiter, + '#!/bin/sh\nwhile [ "$#" -gt 0 ] && [ "$1" != "--" ]; do shift; done\n[ "$#" -gt 0 ] || exit 64\nshift\nexec /usr/bin/prlimit --as=3221225472: --cpu=30: --fsize=1: --core=0: -- "$@"\n', + { mode: 0o700 }, + ); + for (const [limiter, category, resource] of [ + [root.path, "unsupported_provider"], + [brokenLimiter, "unsupported_provider"], + [signalLimiter, "resource_constraint", "cpu"], + [fileLimiter, "resource_constraint", "file-size"], + [unmarkedLimiter, "execution_failure"], + ]) { + const selectedEnvironment = { + ...environment, + REA_EVM_PRLIMIT_COMMAND: limiter, + }; + const selectedClient = new Client({ + name: "unavailable-evm-limiter-verifier", + version: "1", + }); + const selectedTransport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: selectedEnvironment, + stderr: "pipe", + }); + try { + await selectedClient.connect(selectedTransport); + const scope = { + command: process.execPath, + prefix: [], + client: selectedClient, + environment: selectedEnvironment, + }; + for (const mode of ["cli", "mcp"]) { + const error = await inspect( + mode, + join(root.path, "zero-selector"), + "hex", + category, + scope, + ); + if (category === "resource_constraint") { + assert.equal(error.code, "resource_constraint"); + assert.equal(error.details.resource, resource); + assert.equal( + error.details.reported_limits.effective_soft_limits, + null, + ); + assert.equal( + typeof error.details.reported_limits.configured_soft_limits + .cpu_seconds, + "number", + ); + assert.ok( + JSON.stringify(error).includes( + resource === "cpu" ? "SIGXCPU" : "EFBIG", + ), + ); + } else if (category === "execution_failure") { + assert.equal(error.code, "execution_failure"); + assert.ok(JSON.stringify(error).includes("launcher exit 76")); + assert.ok(JSON.stringify(error).includes("file_size_failure_marker")); + } else { + assert.equal(error.code, "capability_unavailable"); + assert.ok(JSON.stringify(error).includes(limiter)); + } + cases++; + } + } finally { + try { + await selectedClient.close(); + } finally { + await selectedTransport.close(); + } + } + } + for (const [name, encoding] of [ + ["plain.hex", "hex"], + ["via-ir.raw", "raw"], + ]) { + await execute( + strace, + [ + "-ff", + "-e", + "trace=network,execve,execveat", + "-s", + "4096", + "-o", + join(root.path, `offline.trace.${name}`), + process.execPath, + entrypoint, + "inspect-evm-interface", + join(root.path, name), + encoding, + "--json", + ], + { + env: { ...environment, PATH: "/usr/bin:/bin" }, + timeout: 45_000, + maxBuffer: 32 * 1024 * 1024, + }, + ); + } + const observedExecutables = new Set(); + for (const file of await readdir(root.path)) { + if (!file.startsWith("offline.trace.")) continue; + const localSocketDescriptors = new Set(); + for (const line of (await readFile(join(root.path, file), "utf8")).split( + "\n", + )) { + if (/execve(?:at)?\(/.test(line)) { + const match = /^execve\("([^"]+)"/.exec(line); + assert.notEqual(match, null, `Unresolved executable identity: ${line}`); + assert.ok( + line.endsWith(" = 0") || / = -1 [A-Z]+/.test(line), + `Incomplete exec observation: ${line}`, + ); + const ownershipInspection = + ["/usr/bin/ps", "/bin/ps"].includes(match[1]) && + line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]'); + assert.ok( + [ + process.execPath, + environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit", + ].includes(match[1]) || ownershipInspection, + `Unexpected host execution: ${line}`, + ); + observedExecutables.add(match[1]); + } else if (/^[a-z]+\(/.test(line)) { + assert.ok( + permitsStdioSocketObservation(line, localSocketDescriptors), + `Unexpected attempted network operation: ${line}`, + ); + } + } + } + assert.ok(observedExecutables.has(process.execPath)); + assert.ok( + observedExecutables.has( + environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit", + ), + ); + cases += 2; +} catch (cause) { + failures.push(cause); +} finally { + for (const close of [ + () => client.close(), + () => transport.close(), + () => root.close(), + ]) { + try { + await close(); + } catch (cause) { + failures.push(cause); + } + } +} +const verifier = await completeVerifierRun(run); +if ( + verifier.process_lineage.status !== "verified" || + verifier.process_lineage.descendants.length !== 0 +) + failures.push( + new Error( + "Verifier process lineage was not fully released: " + + JSON.stringify(verifier), + ), + ); +console.log( + JSON.stringify( + { + status: failures.length === 0 ? "passed" : "failed", + public_cases: cases, + compiler: solc.version(), + engine: "evmole@0.9.3", + ...(failures.length === 0 + ? { + target_execution: "exec-syscalls-verified-absent", + network_requests: "socket-syscalls-verified-absent", + } + : {}), + verifier, + }, + null, + 2, + ), +); +if (failures.length !== 0) + throw new AggregateError( + failures, + "Offline EVM interface verification failed; cleanup failures retained.", + ); + +function permitsStdioSocketObservation(line, localDescriptors) { + if (line.startsWith("socketpair(AF_UNIX,")) { + const pair = /\[(\d+), (\d+)\]\)\s+= 0$/.exec(line); + if (pair !== null) { + localDescriptors.add(pair[1]); + localDescriptors.add(pair[2]); + } + return true; + } + const name = /^getsockname\((\d+), \{sa_family=AF_UNIX\}/.exec(line); + if (name !== null) { + localDescriptors.add(name[1]); + return true; + } + // A failed descriptor query performs no request and identifies no socket. + if (line.startsWith("getsockname(") && / = -1 ENOTSOCK/.test(line)) + return true; + const metadata = + /^(?:getsockopt\((\d+), SOL_SOCKET, SO_TYPE,|setsockopt\((\d+), SOL_SOCKET, SO_(?:RCVBUF|SNDBUF),|shutdown\((\d+), SHUT_WR\))/.exec( + line, + ); + const descriptor = metadata?.[1] ?? metadata?.[2] ?? metadata?.[3]; + // Allow descriptor queries/buffer configuration only after observing a local + // stdio socket. Other socket creation, connection and data operations fail. + return descriptor !== undefined && localDescriptors.has(descriptor); +} diff --git a/scripts/verify/apple/macos-bundle.mjs b/scripts/verify/apple/macos-bundle.mjs index bc4522b731..972c8a7c4a 100644 --- a/scripts/verify/apple/macos-bundle.mjs +++ b/scripts/verify/apple/macos-bundle.mjs @@ -14,6 +14,7 @@ import { buildMacosBundleFixture, preflightMacosBundleFixture, } from "../../fixtures/apple/macos-bundle.mjs"; +import { verifyDylibResolution } from "./macos-dylib-e2e.mjs"; const exec = promisify(execFile); @@ -150,6 +151,7 @@ try { assert.deepEqual(anatomies[2], anatomies[0], "DMG anatomy differs"); const { stdout: attached } = await exec("/usr/bin/hdiutil", ["info"]); assert.ok(!attached.includes(dmg), "DMG remained attached after inventory"); + const dylibs = await verifyDylibResolution(app); process.stdout.write( `${JSON.stringify({ @@ -160,6 +162,7 @@ try { containers: containers.map(({ format }) => format), bundles: EXPECTED_BUNDLES.length, dmg_detached: true, + dylib_resolution: dylibs, })}\n`, ); } finally { diff --git a/scripts/verify/apple/macos-dylib-e2e.mjs b/scripts/verify/apple/macos-dylib-e2e.mjs new file mode 100644 index 0000000000..eacd06e9fb --- /dev/null +++ b/scripts/verify/apple/macos-dylib-e2e.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { realpath } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { promisify } from "node:util"; + +import { + artifactCliEvidence, + artifactMcpResult, + withArtifactMcp, +} from "../../lib/artifact-e2e.mjs"; + +const exec = promisify(execFile); + +const MAIN = "Contents/MacOS/MacFixture"; +const SERVICE = "Contents/XPCServices/Svc.xpc/Contents/MacOS/Svc"; +const CORE = "Contents/Frameworks/Core.framework/Versions/A/Core"; +const DEPENDENCY_COMMANDS = new Set([ + "LC_LOAD_DYLIB", + "LC_LOAD_WEAK_DYLIB", + "LC_REEXPORT_DYLIB", + "LC_LAZY_LOAD_DYLIB", + "LC_LOAD_UPWARD_DYLIB", +]); + +/** Dependencies, rpaths and install name of one slice as Apple's otool reports them. */ +const otoolLoadCommands = async (path, architecture) => { + const { stdout } = await exec( + "/usr/bin/xcrun", + ["otool", "-arch", architecture, "-l", path], + { maxBuffer: 64 * 1024 * 1024 }, + ); + const result = { dependencies: [], rpaths: [], install_name: null }; + for (const block of stdout.split(/\nLoad command \d+\n/u)) { + const command = /^\s*cmd (\S+)/mu.exec(block)?.[1]; + const name = /^\s*name (.+?) \(offset \d+\)$/mu.exec(block)?.[1]; + if (command === "LC_RPATH") + result.rpaths.push(/^\s*path (.+?) \(offset \d+\)$/mu.exec(block)?.[1]); + else if (command === "LC_ID_DYLIB") result.install_name = name; + else if (DEPENDENCY_COMMANDS.has(command)) + result.dependencies.push(`${command} ${name}`); + } + return result; +}; + +/** In-bundle images dyld actually loaded, in load order. */ +const loadedImages = async (app, executable) => { + const { stderr } = await exec(join(app, executable), [], { + env: { PATH: "/usr/bin:/bin", DYLD_PRINT_LIBRARIES: "1" }, + }); + const appRoot = await realpath(app); + const loaded = []; + for (const line of stderr.split("\n")) { + const path = /^dyld\[\d+\]: <[0-9A-F-]+> (.+)$/u.exec(line)?.[1]; + if (path === undefined) continue; + // Shared-cache libraries are reported by path but are not files on disk. + const real = await realpath(path).catch(() => undefined); + if (real?.startsWith(`${appRoot}/`) === true) + loaded.push(relative(appRoot, real)); + } + return loaded; +}; + +/** Images a process root loads according to the trace, in first-load order. */ +const predictedImages = (trace, root) => { + const loaded = [root]; + for (const edge of trace.edges) + if ( + edge.root === root && + ["resolved", "conditional"].includes(edge.resolution.status) && + !loaded.includes(edge.resolution.image) + ) + loaded.push(edge.resolution.image); + return loaded; +}; + +const findEdge = (trace, root, loader, installName) => { + const edge = trace.edges.find( + (candidate) => + candidate.root === root && + candidate.loader === loader && + candidate.install_name === installName, + ); + assert.ok(edge, `missing edge ${root}: ${loader} -> ${installName}`); + return edge; +}; + +const verifyResolutionShapes = (trace) => { + const core = findEdge( + trace, + MAIN, + MAIN, + "@rpath/Core.framework/Versions/A/Core", + ); + assert.deepEqual( + core.candidates.map(({ source, outcome }) => [source, outcome]), + [ + ["rpath", "absent"], + ["rpath", "resolved"], + ], + ); + assert.deepEqual(core.resolution, { status: "resolved", image: CORE }); + const weak = findEdge(trace, MAIN, MAIN, "@rpath/libgone.dylib"); + assert.equal(weak.weak, true); + assert.equal(weak.resolution.status, "unresolved"); + const delayed = findEdge(trace, MAIN, MAIN, "@rpath/libdelay.dylib"); + assert.equal(delayed.encoding, "dylib_use_command"); + assert.equal(delayed.delayed_init, true); + const chain = findEdge(trace, MAIN, CORE, "@rpath/libchain.dylib"); + assert.equal( + chain.resolution.image, + "Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib", + "the loading image's own LC_RPATH must be searched first", + ); + const service = findEdge( + trace, + SERVICE, + SERVICE, + "@rpath/Core.framework/Versions/A/Core", + ); + assert.equal(service.candidates[0]?.outcome, "outside-target"); + assert.deepEqual(service.resolution, { status: "conditional", image: CORE }); + const system = trace.edges.filter(({ install_name: name }) => + name.startsWith("/usr/lib/"), + ); + assert.ok(system.length > 0); + assert.ok( + system.every(({ resolution }) => resolution.status === "undetermined"), + ); + const kinds = trace.findings.map(({ kind, image }) => `${kind}:${image}`); + assert.ok(kinds.includes(`weak-load-unresolved:${MAIN}`)); + assert.ok(kinds.includes(`earlier-rpath-candidate-absent:${MAIN}`)); +}; + +const verifyParserConformance = async (app, trace) => { + let slices = 0; + for (const image of trace.images) + for (const slice of image.slices) { + const expected = await otoolLoadCommands( + join(app, image.path), + slice.architecture, + ); + const edges = trace.edges.filter( + ({ loader, architecture, root }) => + loader === image.path && + architecture === slice.architecture && + root === + trace.edges.find( + (edge) => + edge.loader === image.path && + edge.architecture === slice.architecture, + )?.root, + ); + assert.deepEqual( + { + dependencies: edges.map( + ({ command, install_name: name }) => `${command} ${name}`, + ), + rpaths: slice.rpaths, + install_name: slice.install_name, + }, + expected, + `${image.path} (${slice.architecture}) differs from otool -l`, + ); + slices += 1; + } + return slices; +}; + +/** Verify trace_dylib_resolution against otool and the real dyld on the fixture app. */ +export async function verifyDylibResolution(app) { + const trace = (await artifactCliEvidence("trace-dylib-resolution", app)) + .normalized_result; + await withArtifactMcp(app, async (client) => { + assert.deepEqual( + await artifactMcpResult(client, "trace_dylib_resolution"), + trace, + ); + const invalid = await client.callTool({ + name: "trace_dylib_resolution", + arguments: { roots: ["../outside"] }, + }); + assert.equal(invalid.isError, true); + }); + assert.equal(trace.coverage.status, "complete"); + assert.deepEqual(trace.roots.map(({ image }) => image).sort(), [ + "Contents/Frameworks/Core.framework/Versions/A/XPCServices/FwSvc.xpc/Contents/MacOS/FwSvc", + "Contents/Helpers/rea-tool", + "Contents/Library/LaunchServices/com.example.rea.helper", + "Contents/Library/LoginItems/Login.app/Contents/MacOS/Login", + MAIN, + "Contents/PlugIns/Ext.appex/Contents/MacOS/Ext", + SERVICE, + ]); + verifyResolutionShapes(trace); + const slices = await verifyParserConformance(app, trace); + for (const root of [MAIN, SERVICE]) + assert.deepEqual( + await loadedImages(app, root), + predictedImages(trace, root), + `${root} load order differs from DYLD_PRINT_LIBRARIES`, + ); + return { + roots: trace.roots.length, + edges: trace.edges.length, + otool_slices: slices, + dyld_runtime_roots: 2, + }; +} diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md index b59b1f0bb4..b74854a633 100644 --- a/skills/reverse-engineer-anything/SKILL.md +++ b/skills/reverse-engineer-anything/SKILL.md @@ -2,9 +2,9 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "31" - tool_count: 136 - catalog_digest: "3206a7f58e9e56f55570ba375ac90b0be91891d21ee97a493870f6a1a0149d77" + version: "32" + tool_count: 138 + catalog_digest: "aab2ca1d88c1ce394e8eb2c842ab101e000e2aebd7489ce482cc46a36eef940f" --- # REA @@ -99,6 +99,11 @@ explicit path or endpoint and do not need it. [historical captures](https://github.com/morluto/rea/blob/main/docs/web-network-captures.md) for the exact upstream profile and credential exclusions. - User-owned Electron runtime already open: `list_electron_targets`. +- Explicit local EVM bytecode carrier: `inspect_evm_interface` with caller-selected + `raw` or `hex` encoding. Preserve carrier/decoded digests and treat selectors, + argument strings and mutability as inferred candidates; this performs no chain + lookup or target execution. See the + [EVM bytecode guide](https://github.com/morluto/rea/blob/main/docs/evm-bytecode.md). - Explicit Linux ELF file for offline layout, symbols, relocations or static mitigation evidence: `inspect_binary_layout`. This target-free operation uses caller-supplied pwntools without opening a disassembler database. Preserve its diff --git a/skills/reverse-engineer-anything/references/native-and-artifacts.md b/skills/reverse-engineer-anything/references/native-and-artifacts.md index 87edb1f9de..157da39824 100644 --- a/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -89,6 +89,13 @@ helpers, launchd plists, symlinks, and each bundle's `info_plist_path` and executable candidates. Roles are path conventions, not parsed plists; read the listed plists with `inspect_plist`. +After `open_binary` on a `.app` or Mach-O, use `trace_dylib_resolution` to see +which file each `@rpath`, `@loader_path` and `@executable_path` load reaches for +every executable in the bundle. It also lists missing or weak loads and earlier +`@rpath` candidates that are absent. Narrow large bundles with `roots` or +`architecture`. System paths stay undetermined because the dyld shared cache +provides them. + Use `extract_artifact` when materialized files are needed. It takes no arguments and materializes all regular files into a fresh temporary directory chosen by REA. Symlinks and encrypted entries are inventory facts, not extractable files. diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index ab2c25d94e..ea627693e6 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -31,6 +31,7 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason; type ProviderDescriptor = ProviderCapability; export type AvailabilityPolicy = { + readonly evmInterfaceEnabled?: boolean; readonly processCaptureEnabled: boolean; readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures; readonly binaryLayoutEnabled?: boolean; @@ -289,6 +290,14 @@ const workflowAvailabilityFor = ({ remediation: "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", }; + if (name === "inspect_evm_interface") + return policy.evmInterfaceEnabled === true + ? { reason: "available", remediation: null } + : { + reason: "provider_missing", + remediation: + "The bundled offline EVM interface profile is currently real-verified on Linux x64. No active binary target or chain endpoint is required.", + }; if (kind === "firmware-provider") { const enabled = name === "inspect_firmware_regions" diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 2e256bb1a8..fa01de1246 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -374,7 +374,8 @@ export class BinarySession lookupSnapshot: (target, profile, operation, parameters) => operation === "decode_interface_builder" || operation === "inspect_asset_catalog" || - operation === "inspect_keyed_archive" + operation === "inspect_keyed_archive" || + operation === "trace_dylib_resolution" ? undefined : this.lookupSnapshot(target, profile, operation, parameters), }); @@ -397,7 +398,8 @@ export class BinarySession profile !== undefined && name !== "decode_interface_builder" && name !== "inspect_asset_catalog" && - name !== "inspect_keyed_archive" + name !== "inspect_keyed_archive" && + name !== "trace_dylib_resolution" ) { const evidence = createEvidence( profiled.value.subject ?? active.target, diff --git a/src/application/evm/EvmInterfacePort.ts b/src/application/evm/EvmInterfacePort.ts new file mode 100644 index 0000000000..1d5c39ef45 --- /dev/null +++ b/src/application/evm/EvmInterfacePort.ts @@ -0,0 +1,15 @@ +import type { + AnalysisExecution, + ExecutionOptions, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { InspectEvmInterfaceInput } from "../../domain/evm/evmInterface.js"; +import type { Result } from "../../domain/result.js"; + +/** Replaceable offline interface-recovery engine, independent of wallets and chain clients. */ +export interface EvmInterfacePort { + inspect( + input: InspectEvmInterfaceInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/evm/EvmInterfaceService.test.ts b/src/application/evm/EvmInterfaceService.test.ts new file mode 100644 index 0000000000..9b01973884 --- /dev/null +++ b/src/application/evm/EvmInterfaceService.test.ts @@ -0,0 +1,124 @@ +import { expect, it } from "vitest"; +import { EvmInterfaceService } from "./EvmInterfaceService.js"; +import { + evmInterfaceExecution, + evmInterfaceFixture, +} from "../../../tests/fixtures/evm/interface.js"; +import { err, ok } from "../../domain/result.js"; +import { parseEvidence } from "../../domain/evidence.js"; +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; + +it("returns full inline inferred Evidence and unmodified upstream representation", async () => { + const value = evmInterfaceFixture(); + const service = new EvmInterfaceService({ + inspect: () => Promise.resolve(ok(evmInterfaceExecution(value))), + }); + const result = await service.inspect({ + path: value.artifact.path, + encoding: "hex", + }); + if (!result.ok) throw result.error; + const evidence = parseEvidence(result.value); + expect(evidence.normalized_result).toEqual(value); + expect(evidence.raw_result).toEqual({ functions: [] }); + expect(evidence.confidence).toBe("inferred"); + expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256); +}); +it.each([ + { path: "relative.hex", encoding: "hex" }, + { path: "/selected", encoding: "auto" }, + { path: "/selected" }, + { path: "/selected", encoding: "raw", approval: true }, +])("rejects invalid selection before effects: %j", async (input) => { + const service = new EvmInterfaceService({ + inspect: () => { + throw new Error("must not start"); + }, + }); + expect(await service.inspect(input)).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); +}); +it.each(["carrier", "encoding", "digest", "no-subject"])( + "rejects mismatched producer binding: %s", + async (problem) => { + const value = evmInterfaceFixture(); + if (problem === "carrier") value.artifact.path = "/other"; + if (problem === "encoding") value.artifact.encoding = "raw"; + const observed = evmInterfaceExecution(value); + const execution = { + ...observed, + subject: + problem === "no-subject" + ? null + : { + path: "/artifacts/source-owned.hex", + sha256: + problem === "digest" ? "b".repeat(64) : value.artifact.sha256, + format: "file" as const, + }, + }; + const service = new EvmInterfaceService({ + inspect: () => Promise.resolve(ok(execution)), + }); + expect( + await service.inspect({ + path: "/artifacts/source-owned.hex", + encoding: "hex", + }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError", capturedOutput: value.diagnostics }, + }); + }, +); +it("preserves meaningful provider failure and cancellation before/after execution", async () => { + const failure = new AnalysisCapabilityUnavailableError( + "evm", + "inspect_evm_interface", + "Selected limiter missing", + ); + const service = new EvmInterfaceService({ + inspect: () => Promise.resolve(err(failure)), + }); + expect(await service.inspect({ path: "/code", encoding: "raw" })).toEqual( + err(failure), + ); + const controller = new AbortController(); + const observed = evmInterfaceFixture(); + observed.diagnostics = { + stdout: "selected\u0000", + stderr: "warning", + truncated: true, + }; + const cancelled = new EvmInterfaceService({ + inspect: () => { + controller.abort(); + return Promise.resolve(ok(evmInterfaceExecution(observed))); + }, + }); + expect( + await cancelled.inspect( + { path: "/artifacts/source-owned.hex", encoding: "hex" }, + { signal: controller.signal }, + ), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + capturedOutput: observed.diagnostics, + }, + }); + const untouched = new EvmInterfaceService({ + inspect: () => { + throw new Error("must not start"); + }, + }); + expect( + await untouched.inspect( + { path: "/code", encoding: "raw" }, + { signal: controller.signal }, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); +}); diff --git a/src/application/evm/EvmInterfaceService.ts b/src/application/evm/EvmInterfaceService.ts new file mode 100644 index 0000000000..9769255c00 --- /dev/null +++ b/src/application/evm/EvmInterfaceService.ts @@ -0,0 +1,92 @@ +import { isAbsolute } from "node:path"; +import { z } from "zod"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { EvmInterfacePort } from "./EvmInterfacePort.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 { + inspectEvmInterfaceInputSchema, + evmInterfaceSchema, +} from "../../domain/evm/evmInterface.js"; +import { projectInputIssues } from "../../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +const OPERATION = "inspect_evm_interface"; +const diagnosticsSchema = z.object({ + diagnostics: evmInterfaceSchema.shape.diagnostics, +}); + +/** Shared carrier validation and typed inline Evidence for CLI/MCP. */ +export class EvmInterfaceService { + constructor(readonly provider: EvmInterfacePort) {} + + /** Preserve selected byte identity while clearly labeling recovered ABI candidates as inferences. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const input = inspectEvmInterfaceInputSchema.safeParse(rawInput); + if (!input.success) + return err( + new AnalysisInputError( + OPERATION, + { cause: input.error }, + projectInputIssues(input.error.issues, rawInput), + ), + ); + 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; + const diagnostics = diagnosticsSchema.safeParse(inspected.value.result); + const outputOptions = diagnostics.success + ? { capturedOutput: diagnostics.data.diagnostics } + : undefined; + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION, outputOptions)); + const execution = inspected.value; + const report = evmInterfaceSchema.safeParse(execution.result); + if ( + !report.success || + report.data.artifact.path !== input.data.path || + report.data.artifact.encoding !== input.data.encoding || + execution.subject?.path !== input.data.path || + execution.subject.sha256 !== report.data.artifact.sha256 + ) + return err( + new AnalysisOutputError( + OPERATION, + "Interface adapter returned malformed data or changed the selected carrier identity/encoding.", + outputOptions, + ), + ); + return ok( + createEvidence(execution.subject, execution.provider, { + operation: OPERATION, + parameters: input.data, + result: report.data, + ...(execution.rawResult === null + ? {} + : { rawResult: execution.rawResult }), + confidence: "inferred", + locations: execution.locations, + limitations: execution.limitations, + }), + ); + } +} diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 8d4885f48a..4a3d02fc2e 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -8,6 +8,7 @@ import { type ExecutionOptions, } from "../application/AnalysisProvider.js"; import { inspectBundleKeyedArchive } from "./apple/KeyedArchiveReader.js"; +import { traceDylibResolution } from "./apple/DylibResolutionReader.js"; import { basename, dirname } from "node:path"; import { inventoryArtifact } from "./inventory/ArtifactInventory.js"; import { extractArtifact } from "./extraction/ArtifactExtraction.js"; @@ -153,6 +154,8 @@ class ArtifactClient implements AnalysisClient { if (operation === "inspect_asset_catalog") { return await this.inspectAssetCatalog(parameters, options); } + if (operation === "trace_dylib_resolution") + return await this.traceDylibResolution(parameters, options); if (operation === "extract_artifact") { const parsed = artifactExtractionExecutionSchema.parse(parameters); const result = await extractArtifact( @@ -234,6 +237,40 @@ class ArtifactClient implements AnalysisClient { ); } + private async traceDylibResolution( + parameters: Readonly>, + options?: ExecutionOptions, + ) { + if (this.target.kind !== "executable" || this.target.format !== "mach-o") + throw new ArtifactReaderFailure( + "unavailable", + "trace_dylib_resolution requires an active Mach-O or .app bundle target", + ); + // Only a target opened from an app bundle directory carries its Info.plist; + // a regular file whose name ends in .app is a standalone image. + const bundle = + this.target.bundleInfoPlist === undefined + ? undefined + : this.target.sourcePath; + const result = await traceDylibResolution({ + rootPath: bundle ?? dirname(this.target.path), + targetPath: this.target.path, + targetSha256: this.target.sha256, + enumerateRoots: bundle !== undefined, + parameters, + ...(options?.signal === undefined ? {} : { signal: options.signal }), + }); + return ok( + createAnalysisExecution(result, IDENTITY, { + limitations: result.limitations, + locations: result.images.map(({ path }) => ({ + kind: "artifact-path" as const, + path, + })), + }), + ); + } + private async inspectArtifact( parameters: Readonly>, options?: ExecutionOptions, diff --git a/src/artifacts/ArtifactProviderMetadata.ts b/src/artifacts/ArtifactProviderMetadata.ts index 9cbe0d65e9..3e350508ed 100644 --- a/src/artifacts/ArtifactProviderMetadata.ts +++ b/src/artifacts/ArtifactProviderMetadata.ts @@ -24,7 +24,8 @@ export const artifactCapabilities = ( mutatesArtifact: false, launchesProcess: operation !== "decode_interface_builder" && - operation !== "inspect_keyed_archive", + operation !== "inspect_keyed_archive" && + operation !== "trace_dylib_resolution", mayShowUi: false, mayAccessNetwork: false, mayWriteFilesystem: diff --git a/src/artifacts/apple/DylibResolutionReader.ts b/src/artifacts/apple/DylibResolutionReader.ts new file mode 100644 index 0000000000..812e147150 --- /dev/null +++ b/src/artifacts/apple/DylibResolutionReader.ts @@ -0,0 +1,360 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, open, readlink, realpath } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; + +import { resolveTreePath } from "../../domain/apple/dyldPaths.js"; +import { + dylibResolutionInputSchema, + dylibResolutionResultSchema, + traceDylibLoading, + type DylibResolutionResult, + type DylibTreeEntry, + type DylibTreeView, + type MachoImageFacts, +} from "../../domain/apple/dylibResolution.js"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; +import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; +import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js"; + +const HASH_CHUNK_BYTES = 1024 * 1024; + +/** Device, inode, size and change times of the file whose bytes were parsed. */ +type FileIdentity = string; + +/** Lazily probed, symlink-preserving view of one analyzed directory. */ +class FilesystemTreeView implements DylibTreeView { + readonly #entries = new Map>(); + readonly #images = new Map>(); + readonly #identities = new Map(); + + constructor( + private readonly root: string, + private readonly signal?: AbortSignal, + ) {} + + entry(path: string): Promise { + const cached = this.#entries.get(path); + if (cached !== undefined) return cached; + const pending = this.#readEntry(path); + this.#entries.set(path, pending); + return pending; + } + + image(path: string): Promise { + const cached = this.#images.get(path); + if (cached !== undefined) return cached; + const pending = readImage(join(this.root, path), this.signal).then( + ({ facts, identity }) => { + this.#identities.set(path, identity); + return facts; + }, + ); + this.#images.set(path, pending); + return pending; + } + + /** Identity of the file whose header bytes `image()` parsed. */ + identity(path: string): FileIdentity | undefined { + return this.#identities.get(path); + } + + async #readEntry(path: string): Promise { + cancelled(this.signal); + const absolute = join(this.root, path); + try { + const metadata = await lstat(absolute); + if (metadata.isSymbolicLink()) + return { kind: "symlink", target: await readlink(absolute) }; + if (metadata.isDirectory()) return { kind: "directory" }; + return metadata.isFile() ? { kind: "file" } : undefined; + } catch (cause: unknown) { + if (missing(cause)) return undefined; + throw cause; + } + } +} + +/** Resolve dyld load paths for the active Mach-O or every executable in its bundle. */ +export const traceDylibResolution = async (options: { + readonly rootPath: string; + readonly targetPath: string; + readonly targetSha256: string; + readonly enumerateRoots: boolean; + readonly parameters: unknown; + readonly signal?: AbortSignal; +}): Promise => { + const parsed = dylibResolutionInputSchema.safeParse(options.parameters); + if (!parsed.success) + throw new ArtifactReaderFailure( + "path", + "roots must be normalized paths relative to the analyzed root", + ); + // Canonicalization is inside the translated region: a revoked permission + // keeps its reason and path. + let root = options.rootPath; + try { + root = await realpath(options.rootPath); + const target = relative(root, await realpath(options.targetPath)) + .split(sep) + .join("/"); + const view = new FilesystemTreeView(root, options.signal); + const { roots: requested, unclassified } = + parsed.data.roots !== undefined + ? { roots: parsed.data.roots, unclassified: [] } + : options.enumerateRoots + ? await executableRoots(root, view, options.signal) + : { roots: [target], unclassified: [] }; + const roots = await requireMachoRoots(view, requested); + const trace = await traceDylibLoading(view, { + roots, + unclassified, + ...(parsed.data.architecture === undefined + ? {} + : { architecture: parsed.data.architecture }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); + // Each digest must describe the same file whose headers were parsed. + const digests = new Map(); + for (const { path } of trace.images) + digests.set( + path, + await fileSha256(join(root, path), { + path, + expected: view.identity(path), + ...(options.signal === undefined ? {} : { signal: options.signal }), + }), + ); + const targetDigest = + digests.get(target) ?? + (await fileSha256(join(root, target), { + path: target, + expected: undefined, + ...(options.signal === undefined ? {} : { signal: options.signal }), + })); + if (targetDigest !== options.targetSha256) + throw new ArtifactReaderFailure( + "integrity", + `Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`, + ); + return dylibResolutionResultSchema.parse({ + ...trace, + root_path: options.rootPath, + target_sha256: options.targetSha256, + images: trace.images.map((image) => ({ + ...image, + sha256: digests.get(image.path), + })), + }); + } catch (cause: unknown) { + if (options.signal?.aborted === true) + throw new ArtifactReaderFailure( + "cancelled", + "Dylib resolution was cancelled", + { cause }, + ); + const denied = permissionDenied(cause); + if (denied !== undefined) + throw new ArtifactReaderFailure( + "unavailable", + `Permission denied (${denied.code}) reading ${deniedPath(root, denied.path)}`, + { cause }, + ); + throw cause; + } +}; + +/** + * Every Mach-O in the bundle with an executable slice is its own process root. + * Mach-O files that do not parse are returned separately: whether they are + * executables is unknown, so they make coverage partial instead of vanishing. + */ +const executableRoots = async ( + root: string, + view: FilesystemTreeView, + signal?: AbortSignal, +): Promise<{ readonly roots: string[]; readonly unclassified: string[] }> => { + const roots: string[] = []; + const unclassified: string[] = []; + for await (const entry of new DirectoryArtifactReader(root).entries(signal)) { + if ( + entry.kind !== "file" || + !(await startsWithMachoMagic(entry.adapterKey)) + ) + continue; + const facts = await view.image(entry.path); + if (facts.status === "malformed" || facts.status === "unsupported") + unclassified.push(entry.path); + else if ( + facts.status === "parsed" && + facts.slices.some(({ file_type: type }) => type === "execute") + ) + roots.push(entry.path); + } + return { + roots: roots.sort(compare), + unclassified: unclassified.sort(compare), + }; +}; + +const compare = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +/** + * Resolve each root segment by segment, as dependency candidates are, so a + * symlink in an intermediate directory cannot lead outside the analyzed root. + */ +const requireMachoRoots = async ( + view: FilesystemTreeView, + roots: readonly string[], +): Promise => { + const resolved: string[] = []; + for (const path of roots) { + const lookup = await resolveTreePath(view, path); + if (lookup.kind === "escapes") + throw new ArtifactReaderFailure( + "path", + `Root ${path} resolves outside the analyzed root`, + ); + if (lookup.kind !== "file") + throw new ArtifactReaderFailure( + "path", + `Root ${path} is not a regular file in the analyzed root`, + ); + if ((await view.image(lookup.path)).status === "not-mach-o") + throw new ArtifactReaderFailure( + "format", + `Root ${path} is not a Mach-O image`, + ); + if (!resolved.includes(lookup.path)) resolved.push(lookup.path); + } + return resolved; +}; + +const startsWithMachoMagic = async (path: string): Promise => { + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const buffer = new Uint8Array(4); + const { bytesRead } = await handle.read(buffer, 0, 4, 0); + return hasMachoMagic(buffer.subarray(0, bytesRead)); + } finally { + await handle.close(); + } +}; + +const identityOf = (metadata: { + readonly dev: bigint; + readonly ino: bigint; + readonly size: bigint; + readonly mtimeNs: bigint; + readonly ctimeNs: bigint; +}): FileIdentity => + [ + metadata.dev, + metadata.ino, + metadata.size, + metadata.mtimeNs, + metadata.ctimeNs, + ].join(":"); + +const readImage = async ( + path: string, + signal?: AbortSignal, +): Promise<{ + readonly facts: MachoImageFacts; + readonly identity: FileIdentity; +}> => { + cancelled(signal); + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const metadata = await handle.stat({ bigint: true }); + const size = Number(metadata.size); + const facts = await readMachoImage(async (offset, length) => { + cancelled(signal); + const buffer = new Uint8Array( + Math.max(0, Math.min(length, size - offset)), + ); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset); + return buffer.subarray(0, bytesRead); + }, size); + return { facts, identity: identityOf(metadata) }; + } finally { + await handle.close(); + } +}; + +/** Hash a file, failing if it is not the file (or the version) that was parsed. */ +const fileSha256 = async ( + absolute: string, + options: { + readonly path: string; + readonly expected: FileIdentity | undefined; + readonly signal?: AbortSignal; + }, +): Promise => { + const { signal } = options; + const handle = await open( + absolute, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + const unchanged = async (): Promise => { + if ( + options.expected !== undefined && + identityOf(await handle.stat({ bigint: true })) !== options.expected + ) + throw new ArtifactReaderFailure( + "integrity", + `${options.path} changed while its dyld load commands were traced`, + ); + }; + try { + await unchanged(); + const hash = createHash("sha256"); + const buffer = new Uint8Array(HASH_CHUNK_BYTES); + for (;;) { + cancelled(signal); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + hash.update(buffer.subarray(0, bytesRead)); + } + await unchanged(); + return hash.digest("hex"); + } finally { + await handle.close(); + } +}; + +const cancelled = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new ArtifactReaderFailure( + "cancelled", + "Dylib resolution was cancelled", + ); +}; + +/** A denied path relative to the analyzed root, or absolute when outside it. */ +const deniedPath = (root: string, path: string | undefined): string => { + if (path === undefined) return "a file in the analyzed root"; + const inside = relative(root, path); + return inside === "" || inside.startsWith("..") ? path : inside; +}; + +/** Host permission denials, kept distinct from malformed or missing files. */ +const permissionDenied = ( + cause: unknown, +): { readonly code: string; readonly path: string | undefined } | undefined => { + if (!(cause instanceof Error) || !("code" in cause)) return undefined; + if (cause.code !== "EACCES" && cause.code !== "EPERM") return undefined; + return { + code: cause.code, + path: + "path" in cause && typeof cause.path === "string" + ? cause.path + : undefined, + }; +}; + +const missing = (cause: unknown): boolean => + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR"); diff --git a/src/artifacts/apple/MachoImage.fixture.ts b/src/artifacts/apple/MachoImage.fixture.ts new file mode 100644 index 0000000000..634ab4ecf6 --- /dev/null +++ b/src/artifacts/apple/MachoImage.fixture.ts @@ -0,0 +1,152 @@ +/** Little-endian Mach-O images and FAT containers built field by field for tests. */ + +export const LC = { + LOAD_DYLIB: 0xc, + ID_DYLIB: 0xd, + LOAD_WEAK_DYLIB: 0x80000018, + REEXPORT_DYLIB: 0x8000001f, + LAZY_LOAD_DYLIB: 0x20, + LOAD_UPWARD_DYLIB: 0x80000023, + RPATH: 0x8000001c, + DYLD_ENVIRONMENT: 0x27, + CODE_SIGNATURE: 0x1d, +} as const; + +export const CPU = { + arm64: { type: 0x0100000c, subtype: 0 }, + arm64e: { type: 0x0100000c, subtype: 0x80000002 }, + x86_64: { type: 0x01000007, subtype: 3 }, +} as const; + +export const FILE_TYPE = { execute: 2, dylib: 6, bundle: 8 } as const; + +const encoder = new TextEncoder(); + +/** One load command with a NUL-terminated string at `nameOffset`, padded to 8 bytes. */ +const commandWithString = ( + command: number, + fields: readonly number[], + text: string, +): Uint8Array => { + const nameOffset = 8 + fields.length * 4 + 4; + const name = encoder.encode(text); + const size = Math.ceil((nameOffset + name.length + 1) / 8) * 8; + const bytes = new Uint8Array(size); + const view = new DataView(bytes.buffer); + view.setUint32(0, command, true); + view.setUint32(4, size, true); + view.setUint32(8, nameOffset, true); + fields.forEach((value, index) => view.setUint32(12 + index * 4, value, true)); + bytes.set(name, nameOffset); + return bytes; +}; + +/** Classic `dylib_command`: timestamp, current and compatibility versions. */ +export const dylibCommand = ( + command: number, + name: string, + versions: { readonly current?: number; readonly compatibility?: number } = {}, +): Uint8Array => + commandWithString( + command, + [2, versions.current ?? 0x10000, versions.compatibility ?? 0x10000], + name, + ); + +/** `dylib_use_command`: marker, versions and `DYLIB_USE_*` flags. */ +export const dylibUseCommand = ( + command: number, + name: string, + flags: number, +): Uint8Array => + commandWithString(command, [0x1a741800, 0x10000, 0x10000, flags], name); + +export const rpathCommand = (path: string): Uint8Array => + commandWithString(LC.RPATH, [], path); + +export const dyldEnvironmentCommand = (value: string): Uint8Array => + commandWithString(LC.DYLD_ENVIRONMENT, [], value); + +export const codeSignatureCommand = (): Uint8Array => { + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + view.setUint32(0, LC.CODE_SIGNATURE, true); + view.setUint32(4, 16, true); + return bytes; +}; + +/** A 64-bit (or 32-bit) little-endian Mach-O image made of the given commands. */ +export const machoImage = (options: { + readonly cpu?: { readonly type: number; readonly subtype: number }; + readonly fileType?: number; + readonly commands?: readonly Uint8Array[]; + readonly wide?: boolean; +}): Uint8Array => { + const wide = options.wide ?? true; + const commands = options.commands ?? []; + const headerSize = wide ? 32 : 28; + const commandBytes = commands.reduce((total, item) => total + item.length, 0); + const bytes = new Uint8Array(headerSize + commandBytes + 16); + const view = new DataView(bytes.buffer); + const cpu = options.cpu ?? CPU.arm64; + view.setUint32(0, wide ? 0xfeedfacf : 0xfeedface, true); + view.setUint32(4, cpu.type, true); + view.setUint32(8, cpu.subtype, true); + view.setUint32(12, options.fileType ?? FILE_TYPE.execute, true); + view.setUint32(16, commands.length, true); + view.setUint32(20, commandBytes, true); + let offset = headerSize; + for (const command of commands) { + bytes.set(command, offset); + offset += command.length; + } + return bytes; +}; + +/** + * A FAT container whose slices start on 4 KiB boundaries. Headers are + * big-endian unless `littleEndian` selects the swapped FAT_CIGAM magics. + */ +export const fatImage = ( + slices: readonly { + readonly cpu: { readonly type: number; readonly subtype: number }; + readonly bytes: Uint8Array; + }[], + wide = false, + littleEndian = false, +): Uint8Array => { + const entrySize = wide ? 32 : 20; + const offsets: number[] = []; + let end = 4096; + for (const slice of slices) { + offsets.push(end); + end += Math.ceil(slice.bytes.length / 4096) * 4096; + } + const bytes = new Uint8Array(end); + const view = new DataView(bytes.buffer); + view.setUint32(0, wide ? 0xcafebabf : 0xcafebabe, littleEndian); + view.setUint32(4, slices.length, littleEndian); + slices.forEach((slice, index) => { + const base = 8 + index * entrySize; + const offset = offsets[index] ?? 0; + view.setUint32(base, slice.cpu.type, littleEndian); + view.setUint32(base + 4, slice.cpu.subtype, littleEndian); + if (wide) { + view.setBigUint64(base + 8, BigInt(offset), littleEndian); + view.setBigUint64(base + 16, BigInt(slice.bytes.length), littleEndian); + view.setUint32(base + 24, 12, littleEndian); + } else { + view.setUint32(base + 8, offset, littleEndian); + view.setUint32(base + 12, slice.bytes.length, littleEndian); + view.setUint32(base + 16, 12, littleEndian); + } + bytes.set(slice.bytes, offset); + }); + return bytes; +}; + +/** In-memory `ReadAt` over one buffer. */ +export const readerOf = + (bytes: Uint8Array) => + (offset: number, length: number): Promise => + Promise.resolve(bytes.subarray(offset, offset + length)); diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts new file mode 100644 index 0000000000..892312ea0d --- /dev/null +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from "vitest"; + +import { + CPU, + FILE_TYPE, + LC, + codeSignatureCommand, + dyldEnvironmentCommand, + dylibCommand, + dylibUseCommand, + fatImage, + machoImage, + readerOf, + rpathCommand, +} from "./MachoImage.fixture.js"; +import { + MAX_LOAD_COMMAND_BYTES, + hasMachoMagic, + readMachoImage, +} from "./MachoLoadCommandReader.js"; + +const read = (bytes: Uint8Array) => + readMachoImage(readerOf(bytes), bytes.length); + +describe("Mach-O load command reader", () => { + it("decodes dylib loading commands of a thin image", async () => { + const image = machoImage({ + fileType: FILE_TYPE.dylib, + commands: [ + dylibCommand(LC.ID_DYLIB, "@rpath/Core.framework/Core"), + dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib", { + current: 0x050f0400, + compatibility: 0x10000, + }), + dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libweak.dylib"), + dylibCommand(LC.REEXPORT_DYLIB, "@loader_path/libre.dylib"), + dylibCommand(LC.LOAD_UPWARD_DYLIB, "@rpath/libup.dylib"), + dylibCommand(LC.LAZY_LOAD_DYLIB, "@rpath/liblazy.dylib"), + rpathCommand("@loader_path/Frameworks"), + rpathCommand("/usr/lib/swift"), + dyldEnvironmentCommand("DYLD_FRAMEWORK_PATH=@executable_path"), + codeSignatureCommand(), + ], + }); + const facts = await read(image); + expect(facts.status).toBe("parsed"); + if (facts.status !== "parsed") return; + expect(facts.slices).toHaveLength(1); + const [slice] = facts.slices; + expect(slice).toMatchObject({ + architecture: "arm64", + file_type: "dylib", + install_name: "@rpath/Core.framework/Core", + rpaths: ["@loader_path/Frameworks", "/usr/lib/swift"], + dyld_environment: ["DYLD_FRAMEWORK_PATH=@executable_path"], + code_signature_present: true, + }); + expect( + slice?.dependencies.map( + ({ command, install_name: name, weak, reexport, upward }) => [ + command, + name, + weak, + reexport, + upward, + ], + ), + ).toEqual([ + ["LC_LOAD_DYLIB", "/usr/lib/libSystem.B.dylib", false, false, false], + ["LC_LOAD_WEAK_DYLIB", "@rpath/libweak.dylib", true, false, false], + ["LC_REEXPORT_DYLIB", "@loader_path/libre.dylib", false, true, false], + ["LC_LOAD_UPWARD_DYLIB", "@rpath/libup.dylib", false, false, true], + ["LC_LAZY_LOAD_DYLIB", "@rpath/liblazy.dylib", false, false, false], + ]); + expect(slice?.dependencies[0]).toMatchObject({ + encoding: "dylib_command", + current_version: "1295.4.0", + compatibility_version: "1.0.0", + }); + }); + + it("decodes dylib_use_command flags", async () => { + const facts = await read( + machoImage({ + commands: [ + dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libdelay.dylib", 0x8), + dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libweakup.dylib", 0x1 | 0x4), + dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libre.dylib", 0x2), + ], + }), + ); + if (facts.status !== "parsed") throw new Error(facts.status); + expect( + facts.slices[0]?.dependencies.map( + ({ encoding, weak, upward, reexport, delayed_init: delayed }) => ({ + encoding, + weak, + upward, + reexport, + delayed, + }), + ), + ).toEqual([ + { + encoding: "dylib_use_command", + weak: false, + upward: false, + reexport: false, + delayed: true, + }, + { + encoding: "dylib_use_command", + weak: true, + upward: true, + reexport: false, + delayed: false, + }, + { + encoding: "dylib_use_command", + weak: false, + upward: false, + reexport: true, + delayed: false, + }, + ]); + }); + + it("reads every slice of FAT and FAT_64 containers in either byte order", async () => { + for (const [wide, littleEndian] of [ + [false, false], + [true, false], + [false, true], + [true, true], + ] as const) { + const facts = await read( + fatImage( + [ + { + cpu: CPU.arm64e, + bytes: machoImage({ + cpu: CPU.arm64e, + commands: [rpathCommand("@executable_path/a")], + }), + }, + { + cpu: CPU.x86_64, + bytes: machoImage({ + cpu: CPU.x86_64, + commands: [rpathCommand("@executable_path/b")], + }), + }, + ], + wide, + littleEndian, + ), + ); + if (facts.status !== "parsed") throw new Error(facts.status); + expect( + facts.slices.map(({ architecture, rpaths }) => [architecture, rpaths]), + ).toEqual([ + ["arm64e", ["@executable_path/a"]], + ["x86_64", ["@executable_path/b"]], + ]); + } + }); +}); + +describe("Mach-O load command reader inputs", () => { + it("names 32-bit and non-Mach-O inputs without guessing", async () => { + const i386 = await read( + machoImage({ cpu: { type: 7, subtype: 3 }, wide: false }), + ); + expect(i386.status === "parsed" && i386.slices[0]?.architecture).toBe( + "i386", + ); + // A Java class file shares the FAT magic; its version is not a slice count. + const javaClass = Uint8Array.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34]); + expect(hasMachoMagic(javaClass)).toBe(true); + expect(await read(javaClass)).toEqual({ status: "not-mach-o" }); + expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({ + status: "not-mach-o", + }); + expect(await read(Uint8Array.from([0xcf, 0xfa]))).toEqual({ + status: "not-mach-o", + }); + // A recognized magic without room for a header is a truncated image. + expect( + await read(Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00])), + ).toEqual({ status: "malformed", reason: "Mach-O header is truncated" }); + }); +}); + +describe("Mach-O load command reader failures", () => { + const withCommandSize = (size: number): Uint8Array => { + const image = machoImage({ commands: [rpathCommand("@loader_path")] }); + new DataView(image.buffer).setUint32(32 + 4, size, true); + return image; + }; + + it("reports malformed command tables with the failing command", async () => { + expect(await read(withCommandSize(0))).toMatchObject({ + status: "malformed", + reason: expect.stringContaining("load command 0"), + }); + expect(await read(withCommandSize(4096))).toMatchObject({ + status: "malformed", + reason: expect.stringContaining("invalid cmdsize"), + }); + const extraCommand = machoImage({ commands: [rpathCommand("x")] }); + new DataView(extraCommand.buffer).setUint32(16, 2, true); + expect(await read(extraCommand)).toMatchObject({ + status: "malformed", + reason: "load command 1 starts beyond sizeofcmds", + }); + }); + + it("requires 8-byte command alignment in 64-bit images", async () => { + // A command the reader does not decode; only its size is checked. + const command = new Uint8Array(12); + const view = new DataView(command.buffer); + view.setUint32(0, 0x7f000001, true); + view.setUint32(4, 12, true); + expect(await read(machoImage({ commands: [command] }))).toMatchObject({ + status: "malformed", + reason: expect.stringContaining("invalid cmdsize 12"), + }); + expect( + await read( + machoImage({ + cpu: { type: 7, subtype: 3 }, + wide: false, + commands: [command], + }), + ), + ).toMatchObject({ status: "parsed" }); + }); + + it("accepts FAT tables up to 128 records that name a Mach-O CPU", async () => { + const slices = Array.from({ length: 24 }, () => ({ + cpu: CPU.arm64, + bytes: machoImage({}), + })); + const facts = await read(fatImage(slices)); + expect(facts.status === "parsed" && facts.slices).toHaveLength(24); + // An empty table is not a Java class file, whose major version is nonzero. + for (const magic of [ + [0xca, 0xfe, 0xba, 0xbe], + [0xbf, 0xba, 0xfe, 0xca], + ]) + expect(await read(Uint8Array.from([...magic, 0, 0, 0, 0]))).toEqual({ + status: "malformed", + reason: "FAT header declares no architectures", + }); + }); + + it("rejects string commands too short to hold their string offset", async () => { + const short = new Uint8Array(8); + const view = new DataView(short.buffer); + view.setUint32(0, LC.RPATH, true); + view.setUint32(4, 8, true); + expect(await read(machoImage({ commands: [short] }))).toMatchObject({ + status: "malformed", + reason: "load command 0 is too short to hold a string offset", + }); + view.setUint32(0, LC.CODE_SIGNATURE, true); + expect(await read(machoImage({ commands: [short] }))).toMatchObject({ + status: "malformed", + reason: "load command 0 is too short for LC_CODE_SIGNATURE", + }); + }); + + it("rejects strings that are unterminated, misplaced or not UTF-8", async () => { + const unterminated = rpathCommand("abcd"); + unterminated.fill(0x41, 12); + expect(await read(machoImage({ commands: [unterminated] }))).toMatchObject({ + status: "malformed", + reason: expect.stringContaining("unterminated"), + }); + const misplaced = rpathCommand("abcd"); + new DataView(misplaced.buffer).setUint32(8, 4, true); + expect(await read(machoImage({ commands: [misplaced] }))).toMatchObject({ + status: "malformed", + reason: expect.stringContaining("string offset 4"), + }); + const invalid = rpathCommand("ab"); + invalid[12] = 0xff; + expect(await read(machoImage({ commands: [invalid] }))).toMatchObject({ + status: "malformed", + reason: expect.stringContaining("not UTF-8"), + }); + }); + + it("refuses truncated, oversized, big-endian and out-of-range images", async () => { + const image = machoImage({ commands: [rpathCommand("@loader_path")] }); + expect(await read(image.subarray(0, 20))).toMatchObject({ + status: "malformed", + reason: "Mach-O header is truncated", + }); + const oversized = machoImage({}); + new DataView(oversized.buffer).setUint32( + 20, + MAX_LOAD_COMMAND_BYTES + 8, + true, + ); + expect(await read(oversized)).toMatchObject({ status: "unsupported" }); + const beyond = machoImage({ commands: [rpathCommand("x")] }); + new DataView(beyond.buffer).setUint32(20, beyond.length, true); + expect(await read(beyond)).toMatchObject({ + status: "malformed", + reason: "load commands extend beyond the Mach-O slice", + }); + const bigEndian = Uint8Array.from([0xfe, 0xed, 0xfa, 0xcf, 0, 0, 0, 0]); + expect(await read(bigEndian)).toMatchObject({ status: "unsupported" }); + const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]); + expect(await read(fat.subarray(0, 4100))).toMatchObject({ + status: "malformed", + reason: "FAT architecture 0 extends beyond the file", + }); + }); +}); diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts new file mode 100644 index 0000000000..b78ce7fbc6 --- /dev/null +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -0,0 +1,426 @@ +import type { + MachoDependency, + MachoImageFacts, + MachoSlice, +} from "../../domain/apple/dylibResolution.js"; + +/** Read `length` bytes at `offset`; shorter results mean end of file. */ +export type ReadAt = (offset: number, length: number) => Promise; + +/** Load commands larger than this are refused instead of buffered. */ +export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024; +/** Target opening accepts up to 128 FAT records; Java class files share the FAT magic. */ +const MAX_FAT_ARCHITECTURES = 128; +/** CPU types of real Mach-O slices (x86, x86_64, arm, arm64, arm64_32, ppc, ppc64). */ +const KNOWN_CPU_TYPES: readonly number[] = [ + 7, 0x01000007, 12, 0x0100000c, 0x0200000c, 18, 0x01000012, +]; + +const MH_MAGIC = 0xfeedface; +const MH_MAGIC_64 = 0xfeedfacf; +const MH_CIGAM = 0xcefaedfe; +const MH_CIGAM_64 = 0xcffaedfe; +const FAT_MAGIC = 0xcafebabe; +const FAT_MAGIC_64 = 0xcafebabf; +const FAT_CIGAM = 0xbebafeca; +const FAT_CIGAM_64 = 0xbfbafeca; + +const LC_ID_DYLIB = 0xd; +const LC_LOAD_DYLIB = 0xc; +const LC_LOAD_WEAK_DYLIB = 0x80000018; +const LC_REEXPORT_DYLIB = 0x8000001f; +const LC_LAZY_LOAD_DYLIB = 0x20; +const LC_LOAD_UPWARD_DYLIB = 0x80000023; +const LC_RPATH = 0x8000001c; +const LC_DYLD_ENVIRONMENT = 0x27; +const LC_CODE_SIGNATURE = 0x1d; + +/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */ +const DYLIB_USE_MARKER = 0x1a741800; +const DYLIB_USE_WEAK_LINK = 0x1; +const DYLIB_USE_REEXPORT = 0x2; +const DYLIB_USE_UPWARD = 0x4; +const DYLIB_USE_DELAYED_INIT = 0x8; + +const DEPENDENCY_COMMANDS: ReadonlyMap = + new Map([ + [LC_LOAD_DYLIB, "LC_LOAD_DYLIB"], + [LC_LOAD_WEAK_DYLIB, "LC_LOAD_WEAK_DYLIB"], + [LC_REEXPORT_DYLIB, "LC_REEXPORT_DYLIB"], + [LC_LAZY_LOAD_DYLIB, "LC_LAZY_LOAD_DYLIB"], + [LC_LOAD_UPWARD_DYLIB, "LC_LOAD_UPWARD_DYLIB"], + ]); + +/** Malformed or unsupported structure found while decoding one image. */ +class MachoFormatIssue extends Error { + constructor( + readonly status: "malformed" | "unsupported", + message: string, + ) { + super(message); + } +} + +/** Report whether the first bytes carry a Mach-O or FAT magic number. */ +export const hasMachoMagic = (head: Uint8Array): boolean => { + if (head.byteLength < 4) return false; + const magic = new DataView( + head.buffer, + head.byteOffset, + head.byteLength, + ).getUint32(0, false); + return [ + MH_MAGIC, + MH_MAGIC_64, + MH_CIGAM, + MH_CIGAM_64, + FAT_MAGIC, + FAT_MAGIC_64, + FAT_CIGAM, + FAT_CIGAM_64, + ].includes(magic); +}; + +/** Decode every slice's dylib-loading load commands without reading code. */ +export const readMachoImage = async ( + readAt: ReadAt, + size: number, +): Promise => { + try { + const head = await readAt(0, Math.min(size, 8)); + if (head.byteLength < 8) + // A recognized magic with no room for a header is a truncated image. + return hasMachoMagic(head) + ? { status: "malformed", reason: "Mach-O header is truncated" } + : { status: "not-mach-o" }; + const view = viewOf(head); + const magic = view.getUint32(0, false); + if ([FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic)) { + // FAT headers are big-endian; the swapped magics store them little-endian. + const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64; + const count = view.getUint32(4, littleEndian); + const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64; + // A Java class file's nonzero major version reads as the count, so an + // empty architecture table can only be a malformed universal header. + if (count === 0) + return { + status: "malformed", + reason: "FAT header declares no architectures", + }; + if (count > MAX_FAT_ARCHITECTURES || 8 + count * (wide ? 32 : 20) > size) + return { status: "not-mach-o" }; + const table = viewOf( + await readExact(readAt, 8, count * (wide ? 32 : 20), "FAT table"), + ); + // A Java class file's version fields read as a FAT count; its "table" names no CPU. + const cpuTypes = Array.from({ length: count }, (_, index) => + table.getUint32(index * (wide ? 32 : 20), littleEndian), + ); + if (!cpuTypes.some((type) => KNOWN_CPU_TYPES.includes(type))) + return { status: "not-mach-o" }; + return { + status: "parsed", + slices: await readFatSlices(readAt, size, { + wide, + littleEndian, + count, + }), + }; + } + if (magic === MH_CIGAM || magic === MH_CIGAM_64) + return { status: "parsed", slices: [await readSlice(readAt, 0, size)] }; + if (magic === MH_MAGIC || magic === MH_MAGIC_64) + throw new MachoFormatIssue( + "unsupported", + "big-endian Mach-O images are not supported", + ); + return { status: "not-mach-o" }; + } catch (cause: unknown) { + if (cause instanceof MachoFormatIssue) + return { status: cause.status, reason: cause.message }; + throw cause; + } +}; + +const readFatSlices = async ( + readAt: ReadAt, + size: number, + table: { + readonly wide: boolean; + readonly littleEndian: boolean; + readonly count: number; + }, +): Promise => { + const { wide, littleEndian, count } = table; + const entrySize = wide ? 32 : 20; + const view = viewOf( + await readExact(readAt, 8, count * entrySize, "FAT table"), + ); + const slices: MachoSlice[] = []; + for (let index = 0; index < count; index++) { + const base = index * entrySize; + const offset = wide + ? safeNumber(view.getBigUint64(base + 8, littleEndian)) + : view.getUint32(base + 8, littleEndian); + const length = wide + ? safeNumber(view.getBigUint64(base + 16, littleEndian)) + : view.getUint32(base + 12, littleEndian); + if (offset + length > size) + throw new MachoFormatIssue( + "malformed", + `FAT architecture ${index} extends beyond the file`, + ); + slices.push(await readSlice(readAt, offset, length)); + } + return slices; +}; + +const readSlice = async ( + readAt: ReadAt, + base: number, + length: number, +): Promise => { + const header = viewOf(await readExact(readAt, base, 28, "Mach-O header")); + const magic = header.getUint32(0, true); + if (magic === MH_MAGIC_64 || magic === MH_MAGIC) { + const wide = magic === MH_MAGIC_64; + const headerSize = wide ? 32 : 28; + const commandCount = header.getUint32(16, true); + const commandBytes = header.getUint32(20, true); + if (commandBytes > MAX_LOAD_COMMAND_BYTES) + throw new MachoFormatIssue( + "unsupported", + `load commands occupy ${commandBytes} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`, + ); + if (headerSize + commandBytes > length) + throw new MachoFormatIssue( + "malformed", + "load commands extend beyond the Mach-O slice", + ); + const commands = await readExact( + readAt, + base + headerSize, + commandBytes, + "load commands", + ); + return decodeCommands(commands, { + wide, + cpuType: header.getUint32(4, true), + cpuSubtype: header.getUint32(8, true), + fileType: header.getUint32(12, true), + commandCount, + }); + } + throw new MachoFormatIssue( + magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64) + ? "unsupported" + : "malformed", + magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64) + ? "big-endian Mach-O slices are not supported" + : `slice at offset ${base} has no Mach-O header`, + ); +}; + +const decodeCommands = ( + bytes: Uint8Array, + header: { + readonly wide: boolean; + readonly cpuType: number; + readonly cpuSubtype: number; + readonly fileType: number; + readonly commandCount: number; + }, +): MachoSlice => { + // loader.h: load commands are 8-byte aligned in 64-bit images, 4 in 32-bit. + const alignment = header.wide ? 8 : 4; + const view = viewOf(bytes); + const slice: MachoSlice = { + architecture: architectureName(header.cpuType, header.cpuSubtype), + file_type: fileTypeName(header.fileType), + install_name: null, + dependencies: [], + rpaths: [], + dyld_environment: [], + code_signature_present: false, + }; + let offset = 0; + for (let index = 0; index < header.commandCount; index++) { + if (offset + 8 > bytes.byteLength) + throw new MachoFormatIssue( + "malformed", + `load command ${index} starts beyond sizeofcmds`, + ); + const command = view.getUint32(offset, true); + const size = view.getUint32(offset + 4, true); + if (size < 8 || size % alignment !== 0 || offset + size > bytes.byteLength) + throw new MachoFormatIssue( + "malformed", + `load command ${index} at offset ${offset} has invalid cmdsize ${size}`, + ); + const body = bytes.subarray(offset, offset + size); + decodeCommand(command, body, index, slice); + offset += size; + } + return slice; +}; + +const decodeCommand = ( + command: number, + body: Uint8Array, + index: number, + slice: MachoSlice, +): void => { + if (command === LC_ID_DYLIB) { + slice.install_name = commandString(body, 24, index); + return; + } + if (command === LC_RPATH) { + slice.rpaths.push(commandString(body, 12, index)); + return; + } + if (command === LC_DYLD_ENVIRONMENT) { + slice.dyld_environment.push(commandString(body, 12, index)); + return; + } + if (command === LC_CODE_SIGNATURE) { + // linkedit_data_command: cmd, cmdsize, dataoff, datasize. + if (body.byteLength < 16) + throw new MachoFormatIssue( + "malformed", + `load command ${index} is too short for LC_CODE_SIGNATURE`, + ); + slice.code_signature_present = true; + return; + } + const name = DEPENDENCY_COMMANDS.get(command); + if (name !== undefined) + slice.dependencies.push(decodeDependency(name, body, index)); +}; + +const decodeDependency = ( + command: MachoDependency["command"], + body: Uint8Array, + index: number, +): MachoDependency => { + if (body.byteLength < 24) + throw new MachoFormatIssue( + "malformed", + `load command ${index} is too short for a dylib command`, + ); + const view = viewOf(body); + const nameOffset = view.getUint32(8, true); + const usesFlags = + nameOffset === 28 && + body.byteLength >= 28 && + view.getUint32(12, true) === DYLIB_USE_MARKER; + const flags = usesFlags ? view.getUint32(24, true) : 0; + return { + command, + encoding: usesFlags ? "dylib_use_command" : "dylib_command", + install_name: commandString(body, usesFlags ? 28 : 24, index), + weak: + command === "LC_LOAD_WEAK_DYLIB" || (flags & DYLIB_USE_WEAK_LINK) !== 0, + upward: + command === "LC_LOAD_UPWARD_DYLIB" || (flags & DYLIB_USE_UPWARD) !== 0, + reexport: + command === "LC_REEXPORT_DYLIB" || (flags & DYLIB_USE_REEXPORT) !== 0, + delayed_init: (flags & DYLIB_USE_DELAYED_INIT) !== 0, + current_version: version(view.getUint32(16, true)), + compatibility_version: version(view.getUint32(20, true)), + }; +}; + +/** NUL-terminated UTF-8 string at a `lc_str` offset inside one command. */ +const commandString = ( + body: Uint8Array, + minimumOffset: number, + index: number, +): string => { + if (body.byteLength < 12) + throw new MachoFormatIssue( + "malformed", + `load command ${index} is too short to hold a string offset`, + ); + const offset = viewOf(body).getUint32(8, true); + if (offset < minimumOffset || offset >= body.byteLength) + throw new MachoFormatIssue( + "malformed", + `load command ${index} has string offset ${offset} outside the command`, + ); + const end = body.indexOf(0, offset); + if (end < 0) + throw new MachoFormatIssue( + "malformed", + `load command ${index} has an unterminated string`, + ); + try { + return new TextDecoder("utf-8", { fatal: true }).decode( + body.subarray(offset, end), + ); + } catch { + throw new MachoFormatIssue( + "malformed", + `load command ${index} has a string that is not UTF-8`, + ); + } +}; + +const readExact = async ( + readAt: ReadAt, + offset: number, + length: number, + label: string, +): Promise => { + const bytes = await readAt(offset, length); + if (bytes.byteLength < length) + throw new MachoFormatIssue("malformed", `${label} is truncated`); + return bytes; +}; + +const CPU_ARCH_ABI64 = 0x01000000; +const CPU_ARCH_ABI64_32 = 0x02000000; +const CPU_TYPE_X86 = 7; +const CPU_TYPE_ARM = 12; +const CPU_SUBTYPE_MASK = 0xff000000; +const CPU_SUBTYPE_ARM64E = 2; +const CPU_SUBTYPE_X86_64_H = 8; + +const architectureName = (cpuType: number, cpuSubtype: number): string => { + const subtype = cpuSubtype & ~CPU_SUBTYPE_MASK; + if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64)) + return subtype === CPU_SUBTYPE_ARM64E ? "arm64e" : "arm64"; + if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64_32)) return "arm64_32"; + if (cpuType === (CPU_TYPE_X86 | CPU_ARCH_ABI64)) + return subtype === CPU_SUBTYPE_X86_64_H ? "x86_64h" : "x86_64"; + if (cpuType === CPU_TYPE_X86) return "i386"; + if (cpuType === CPU_TYPE_ARM) return "arm"; + return `cpu-${cpuType.toString(16)}`; +}; + +const fileTypeName = (fileType: number): MachoSlice["file_type"] => + fileType === 2 + ? "execute" + : fileType === 6 + ? "dylib" + : fileType === 8 + ? "bundle" + : "other"; + +const version = (value: number): string => + `${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`; + +const swap = (value: number): number => + (((value & 0xff) << 24) | + ((value & 0xff00) << 8) | + ((value >>> 8) & 0xff00) | + (value >>> 24)) >>> + 0; + +const safeNumber = (value: bigint): number => { + if (value > BigInt(Number.MAX_SAFE_INTEGER)) + throw new MachoFormatIssue("malformed", "FAT offset exceeds safe range"); + return Number(value); +}; + +const viewOf = (bytes: Uint8Array): DataView => + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); diff --git a/src/cli.ts b/src/cli.ts index 6fa48dd331..38b5ab7595 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,3 +1,4 @@ +import { registerEvmCommands } from "./cli/evmCommands.js"; import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js"; import { Cli } from "incur"; @@ -56,6 +57,7 @@ export const createCli = ( registerSetupCommands(cli, logger); registerCoreAnalysisCommands(cli, logger, resultOutput); registerUtilityCommands(cli, logger, environment); + registerEvmCommands(cli, logger, environment); registerArtifactCommands(cli, logger); registerManagedCommands(cli, logger); registerAndroidCommands(cli, logger, environment); diff --git a/src/cli/artifactCommands.ts b/src/cli/artifactCommands.ts index 6dc93f7ecb..de16d4b33d 100644 --- a/src/cli/artifactCommands.ts +++ b/src/cli/artifactCommands.ts @@ -15,6 +15,7 @@ export const registerArtifactCommands = ( registerExtractionCommand(cli, logger); registerInterfaceBuilderCommand(cli, logger); registerAssetCatalogCommand(cli, logger); + registerDylibResolutionCommand(cli, logger); cli.command(CLI_COMMANDS.inspectKeyedArchive, { description: "Inspect a Foundation keyed archive as an object-reference graph", @@ -58,6 +59,45 @@ export const registerArtifactCommands = ( }); }; +const registerDylibResolutionCommand = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.traceDylibResolution, { + description: + "Trace dyld load-path resolution for a Mach-O or every executable in an app bundle", + args: z.object({ + path: z.string().describe("Mach-O file or Apple .app bundle path"), + }), + options: z.object({ + root: z + .array(z.string()) + .optional() + .describe( + "Process root relative to the bundle or the Mach-O's directory (repeatable)", + ), + architecture: z + .enum(["arm64", "arm64e", "x86_64"]) + .optional() + .describe("Trace only this slice of each root"), + }), + run: ({ args, options }) => + logCliCommand(logger, CLI_COMMANDS.traceDylibResolution, () => + runProviderAnalysis( + args.path, + "trace_dylib_resolution", + { + ...(options.root === undefined ? {} : { roots: options.root }), + ...(options.architecture === undefined + ? {} + : { architecture: options.architecture }), + }, + logger, + ), + ), + }); +}; + const registerAssetCatalogCommand = ( cli: CliInstance, logger: Logger, diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts new file mode 100644 index 0000000000..61f292587b --- /dev/null +++ b/src/cli/evmCommands.ts @@ -0,0 +1,37 @@ +import { z } from "incur"; +import { resolve } from "node:path"; +import { createEvmInterfaceService } from "../composition/evm.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { Logger } from "../logger.js"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; + +/** Resolve operator paths and preserve explicit encoding through the shared workflow. */ +export const registerEvmCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly, +): void => { + const service = createEvmInterfaceService(environment); + cli.command(CLI_COMMANDS.inspectEvmInterface, { + description: "Infer interface candidates from explicit local EVM bytecode", + args: z.object({ + path: z.string().describe("Local bytecode carrier"), + encoding: z + .enum(["raw", "hex"]) + .describe("Selected carrier representation"), + }), + run: ({ args }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectEvmInterface, async () => { + const result = await service.inspect( + { path: resolve(args.path), encoding: args.encoding }, + { signal }, + ); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 021ff04fcc..83d2194460 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -1,5 +1,6 @@ /** Canonical names used by every one-shot CLI command registration. */ export const CLI_COMMANDS = Object.freeze({ + inspectEvmInterface: "inspect-evm-interface", inspectWebNetworkCapture: "inspect-web-network-capture", observeWebExecution: "observe-web-execution", inspectWebEventListeners: "inspect-web-event-listeners", @@ -28,6 +29,7 @@ export const CLI_COMMANDS = Object.freeze({ extractArtifact: "extract-artifact", decodeInterfaceBuilder: "decode-interface-builder", inspectKeyedArchive: "inspect-keyed-archive", + traceDylibResolution: "trace-dylib-resolution", inspectNativeDataType: "inspect-native-data-type", inspectNativeLoadImage: "inspect-native-load-image", readBytes: "read-bytes", diff --git a/src/composition/evm.ts b/src/composition/evm.ts new file mode 100644 index 0000000000..92c7e73fce --- /dev/null +++ b/src/composition/evm.ts @@ -0,0 +1,8 @@ +import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; +import { EvmoleInterfaceProvider } from "../evm/EvmoleInterfaceProvider.js"; + +/** Compose offline interface analysis without import-time I/O or dependency acquisition. */ +export const createEvmInterfaceService = ( + environment: Readonly = process.env, +): EvmInterfaceService => + new EvmInterfaceService(new EvmoleInterfaceProvider(environment)); diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index 85fb8936bc..b52f115af1 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -6,6 +6,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; import { toolContractMetadata } from "./toolEffects.js"; import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js"; import { appleAssetCatalogInputSchema } from "../domain/apple/appleAssetCatalog.js"; +import { dylibResolutionInputSchema } from "../domain/apple/dylibResolution.js"; import { keyedArchiveInputSchema } from "../domain/apple/keyedArchive.js"; /** Exact caller boundary for deterministic artifact inventory. */ export const artifactInventoryInputSchema = z.strictObject({ @@ -27,6 +28,10 @@ const examples: Readonly>>> = { decode_interface_builder: {}, inspect_asset_catalog: { offset: 0, limit: 1000 }, inspect_keyed_archive: { path: "Contents/Resources/Model.plist" }, + trace_dylib_resolution: { + roots: ["Contents/MacOS/App"], + architecture: "arm64", + }, }; const artifact = < @@ -85,6 +90,11 @@ export const ARTIFACT_TOOL_CONTRACTS = [ "Inspect compiled Assets.car metadata in an active Apple app bundle. Returns stable paginated catalog and rendition records with raw assetutil metadata and catalog digests; rendition bytes are not extracted.", appleAssetCatalogInputSchema, ), + artifact( + "trace_dylib_resolution", + "Trace how dyld would resolve each Mach-O dylib load for the active Mach-O, or for every executable in the active .app bundle (the main app, XPC services, extensions, login items, helpers). Expands @executable_path, @loader_path, and @rpath through each process's LC_RPATH stack in dependents-first load order, and reports every candidate path with its outcome, the resolved image and digest, weak, re-export, and upward flags, and derived findings such as unresolved required loads or absent earlier @rpath candidates. Pure header parsing: nothing is executed, and absolute or system paths outside the analyzed root stay undetermined. Select roots or one architecture to narrow large bundles.", + dylibResolutionInputSchema, + ), ] as const satisfies readonly ToolContract[]; /** Provider operations; inventory remains an internal primitive of inspection. */ diff --git a/src/contracts/contractSnapshot.test.ts b/src/contracts/contractSnapshot.test.ts index 82ee4acd9f..f0e7a6ce94 100644 --- a/src/contracts/contractSnapshot.test.ts +++ b/src/contracts/contractSnapshot.test.ts @@ -1,3 +1,4 @@ +import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -72,6 +73,7 @@ describe("tool contract surface", () => { ...NATIVE_TOOL_CONTRACTS, ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, ...ARTIFACT_TOOL_CONTRACTS, + ...EVM_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, ...FIRMWARE_TOOL_CONTRACTS, ...MANAGED_TOOL_CONTRACTS, diff --git a/src/contracts/errorSchemas.ts b/src/contracts/errorSchemas.ts index da7dd924e3..a535f6abf2 100644 --- a/src/contracts/errorSchemas.ts +++ b/src/contracts/errorSchemas.ts @@ -5,6 +5,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; const categorySchema = z.enum([ "invalid_input", "unsupported_provider", + "unsupported_target", "integrity_mismatch", "truncated", "cancelled", @@ -37,6 +38,7 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [ generic("artifact_changed"), generic("unreadable_output"), generic("capability_unavailable"), + generic("unsupported_target"), generic("provider_unavailable"), generic("provider_timeout"), generic("resource_constraint"), diff --git a/src/contracts/evm/evmToolContracts.ts b/src/contracts/evm/evmToolContracts.ts new file mode 100644 index 0000000000..7b8f5bcf9f --- /dev/null +++ b/src/contracts/evm/evmToolContracts.ts @@ -0,0 +1,26 @@ +import { + inspectEvmInterfaceInputSchema, + evmInterfaceSchema, +} from "../../domain/evm/evmInterface.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemas.js"; + +/** Target-free EVM capabilities are independent of archive/Apple artifact providers. */ +export const EVM_TOOL_CONTRACTS = [ + { + name: "inspect_evm_interface", + ...toolContractMetadata("inspect_evm_interface"), + kind: "artifact-provider", + description: + "Inspect an explicit local flat EVM bytecode carrier and infer dispatch selectors, argument strings and mutability without executing the target or accessing a chain/RPC endpoint. Choose raw or hex encoding explicitly. Returns original carrier and decoded-byte SHA-256, full bytecode and interface candidates inline as inferred Evidence; bytecode kind, hardfork, deployed authenticity and discovery completeness remain unknown. Initial real-verified Linux x64 profile uses unchanged bundled EVMole 0.9.3/WASM in a bounded owned worker; EOF-style EF00 containers are unsupported. No signature lookup, wallet action or transaction broadcast.", + inputSchema: inspectEvmInterfaceInputSchema, + outputSchema: evidenceResultOf(evmInterfaceSchema), + examples: [ + { + title: "Inspect a local runtime bytecode hex carrier", + input: { path: "/artifacts/runtime.hex", encoding: "hex" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.test.ts b/src/contracts/toolContracts.test.ts index 44c1e9abd1..f0086cff4b 100644 --- a/src/contracts/toolContracts.test.ts +++ b/src/contracts/toolContracts.test.ts @@ -1,3 +1,4 @@ +import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { describe, expect, it } from "vitest"; @@ -26,6 +27,7 @@ const GROUPS = { native: NATIVE_TOOL_CONTRACTS, binary_diagnostics: BINARY_DIAGNOSTICS_TOOL_CONTRACTS, artifact: ARTIFACT_TOOL_CONTRACTS, + evm: EVM_TOOL_CONTRACTS, managed: MANAGED_TOOL_CONTRACTS, android: ANDROID_TOOL_CONTRACTS, firmware: FIRMWARE_TOOL_CONTRACTS, diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 370c83efc3..304ef41d5f 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -1,3 +1,4 @@ +import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { NATIVE_TOOL_CONTRACTS } from "./native/nativeToolContracts.js"; import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; @@ -26,6 +27,7 @@ export const TOOL_CONTRACTS = [ ...NATIVE_TOOL_CONTRACTS, ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, ...ARTIFACT_TOOL_CONTRACTS, + ...EVM_TOOL_CONTRACTS, ...MANAGED_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, ...FIRMWARE_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 5db06293ec..fd416c7c6c 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -41,6 +41,11 @@ const sessionEvidence = effects({ mutatesSession: true }); /** Explicit effect audit for every public tool. */ export const TOOL_EFFECTS: Readonly> = { + inspect_evm_interface: effects({ + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + }), inspect_web_network_capture: effects({ mutatesSession: true, writesFilesystem: true, @@ -213,6 +218,7 @@ export const TOOL_EFFECTS: Readonly> = { decode_interface_builder: evidence, inspect_asset_catalog: nativeEvidence, inspect_keyed_archive: evidence, + trace_dylib_resolution: evidence, inspect_managed_artifact: evidence, inspect_managed_members: evidence, inspect_managed_native_boundaries: evidence, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 12815d3b09..3fd1575ed9 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -32,6 +32,7 @@ import { import { artifactExtractionResultSchema } from "../domain/artifactGraph.js"; import { artifactInspectionResultSchema } from "../domain/artifactInspection.js"; import { interfaceBuilderAnalysisSchema } from "../domain/apple/interfaceBuilderGraph.js"; +import { dylibResolutionResultSchema } from "../domain/apple/dylibResolution.js"; import { keyedArchiveResultSchema } from "../domain/apple/keyedArchive.js"; import { appleAssetCatalogResultSchema } from "../domain/apple/appleAssetCatalog.js"; import { @@ -338,6 +339,7 @@ export const artifactOutputSchemas: Readonly> = { decode_interface_builder: resultOf(interfaceBuilderAnalysisSchema), inspect_keyed_archive: resultOf(keyedArchiveResultSchema), inspect_asset_catalog: resultOf(appleAssetCatalogResultSchema), + trace_dylib_resolution: resultOf(dylibResolutionResultSchema), }; /** Exact Evidence schema for execution-free managed static analysis. */ diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 90843234b6..47fd251edf 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -6,6 +6,7 @@ const ANALYSIS_ERROR_TAGS = [ "AnalysisArtifactChangedError", "AnalysisOutputError", "AnalysisCapabilityUnavailableError", + "AnalysisUnsupportedTargetError", "AnalysisCancelledError", "AnalysisTimeoutError", "AnalysisResourceConstraintError", diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index 691347facc..03835a2b36 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -4,6 +4,19 @@ import { type AnalysisErrorOptions, } from "./analysisErrorBase.js"; +/** A readable supplied artifact lies outside this operation's supported target formats. */ +export class AnalysisUnsupportedTargetError extends AnalysisError { + readonly _tag = "AnalysisUnsupportedTargetError"; + constructor( + readonly operation: string, + readonly path: string, + readonly reason: string, + options?: AnalysisErrorOptions, + ) { + super(`Unsupported target for ${operation} at ${path}: ${reason}`, options); + } +} + /** Provider reported a resource failure, distinct from malformed input or unsupported coverage. */ export class AnalysisResourceConstraintError extends AnalysisError { readonly _tag = "AnalysisResourceConstraintError"; diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index af486b87b5..48a1f0860a 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -2,6 +2,7 @@ import { AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisInputError, + AnalysisUnsupportedTargetError, AnalysisResourceConstraintError, } from "./analysisErrorCore.js"; import { ArtifactOperationError } from "./artifactOperationError.js"; @@ -33,6 +34,8 @@ import { type AnalysisErrorProjection } from "./analysisErrorProjection.js"; export const analysisErrorRemediationAction = ( error: AnalysisError, ): string => { + if (error instanceof AnalysisUnsupportedTargetError) + return "Select a target supported by this operation or choose an operation supporting the reported target format."; if (error instanceof AnalysisResourceConstraintError) return error.resource === "cpu" ? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits." @@ -125,6 +128,7 @@ const STATIC_ERROR_CATEGORIES: Readonly< AnalysisAccessDeniedError: "unavailable", AnalysisArtifactChangedError: "integrity_mismatch", AnalysisCapabilityUnavailableError: "unsupported_provider", + AnalysisUnsupportedTargetError: "unsupported_target", ProviderSelectionError: "unsupported_provider", EvidenceIntegrityError: "integrity_mismatch", AnalysisCancelledError: "cancelled", @@ -137,6 +141,7 @@ const STATIC_ERROR_CATEGORIES: Readonly< }; export const analysisErrorUserMessage = (error: AnalysisError): string => { + if (error instanceof AnalysisUnsupportedTargetError) return error.message; if (error instanceof AnalysisResourceConstraintError) return error.reason; if (error instanceof AnalysisAccessDeniedError) return "Host filesystem permissions denied read access to the selected path."; @@ -270,6 +275,7 @@ const KNOWN_ERROR_TAGS = { AnalysisArtifactChangedError: true, AnalysisOutputError: true, AnalysisCapabilityUnavailableError: true, + AnalysisUnsupportedTargetError: true, AnalysisCancelledError: true, AnalysisTimeoutError: true, AnalysisResourceConstraintError: true, diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index e0952a7f33..392b97966c 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { AnalysisCapabilityUnavailableError, + AnalysisUnsupportedTargetError, AnalysisInputError, AnalysisOutputError, AnalysisCancelledError, @@ -28,6 +29,32 @@ const retainedOutput = { truncated: true, }; +it("distinguishes unsupported target formats from unavailable provider capabilities", () => { + const projected = projectAnalysisError( + new AnalysisUnsupportedTargetError( + "inspect_evm_interface", + "/selected/runtime.hex", + "EOF-style EF00 container is unsupported.", + { capturedOutput: retainedOutput }, + ), + ); + expect(projected).toMatchObject({ + code: "unsupported_target", + category: "unsupported_target", + retryable: false, + details: { + operation: "inspect_evm_interface", + path: "/selected/runtime.hex", + reason: "EOF-style EF00 container is unsupported.", + captured_output: retainedOutput, + }, + }); + expect(projected.message).toContain("EF00"); + expect(projected.remediation.action).toContain("target format"); + expect(projected.remediation.action).not.toContain("doctor"); + expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true); +}); + it.each([ ["memory", null], ["memory", { address_space_bytes: 67108864 }], diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 848b102755..6ba8cfac37 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -11,6 +11,7 @@ import { AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisCapabilityUnavailableError, + AnalysisUnsupportedTargetError, AnalysisInputError, AnalysisOutputError, AnalysisTimeoutError, @@ -140,6 +141,7 @@ const STATIC_ERROR_CODES = { AnalysisAccessDeniedError: "access_denied", AnalysisArtifactChangedError: "artifact_changed", AnalysisCapabilityUnavailableError: "capability_unavailable", + AnalysisUnsupportedTargetError: "unsupported_target", AnalysisCancelledError: "cancelled", HopperCancelledError: "cancelled", AnalysisTimeoutError: "provider_timeout", @@ -187,6 +189,12 @@ const errorDetails = ( const requestErrorDetails = ( error: AnalysisError, ): Readonly> | undefined => { + if (error instanceof AnalysisUnsupportedTargetError) + return { + operation: error.operation, + path: error.path, + reason: error.reason, + }; if (error instanceof AnalysisResourceConstraintError) return { operation: error.operation, @@ -420,6 +428,7 @@ export interface AnalysisErrorProjection extends Readonly< | "artifact_changed" | "unreadable_output" | "capability_unavailable" + | "unsupported_target" | "provider_unavailable" | "provider_timeout" | "resource_constraint" @@ -437,6 +446,7 @@ export interface AnalysisErrorProjection extends Readonly< readonly category: | "invalid_input" | "unsupported_provider" + | "unsupported_target" | "integrity_mismatch" | "truncated" | "cancelled" diff --git a/src/domain/apple/dyldPaths.ts b/src/domain/apple/dyldPaths.ts new file mode 100644 index 0000000000..765bb15d14 --- /dev/null +++ b/src/domain/apple/dyldPaths.ts @@ -0,0 +1,110 @@ +import type { + DylibTreeEntry, + DylibTreeView, + MachoSlice, +} from "./dylibResolution.js"; + +const MAX_SYMLINK_HOPS = 32; + +export type PathLookup = + | { readonly kind: "file"; readonly path: string } + | { readonly kind: "absent" | "escapes" | "not-file" }; + +/** + * Resolve a root-relative path one segment at a time, as `open` would: + * symlinks are followed inside the root, `..` applies to the real parent, + * and any escape above the root or through an absolute link stops. + */ +export const resolveTreePath = async ( + view: DylibTreeView, + input: string, +): Promise => { + const pending = segmentsOf(input); + const resolved: string[] = []; + let last: DylibTreeEntry | undefined; + let hops = 0; + while (pending.length > 0) { + const segment = pending.shift() ?? ""; + if (segment === "..") { + if (resolved.length === 0) return { kind: "escapes" }; + resolved.pop(); + last = { kind: "directory" }; + continue; + } + const path = [...resolved, segment].join("/"); + const entry = await view.entry(path); + if (entry === undefined) return { kind: "absent" }; + if (entry.kind === "symlink") { + hops += 1; + if (hops > MAX_SYMLINK_HOPS) return { kind: "absent" }; + if (entry.target.startsWith("/")) return { kind: "escapes" }; + pending.unshift(...segmentsOf(entry.target)); + continue; + } + if (entry.kind === "file" && pending.length > 0) return { kind: "absent" }; + resolved.push(segment); + last = entry; + } + return last?.kind === "file" + ? { kind: "file", path: resolved.join("/") } + : { kind: "not-file" }; +}; + +const segmentsOf = (path: string): string[] => + path.split("/").filter((segment) => segment !== "" && segment !== "."); + +export const directoryOf = (path: string): string => { + const index = path.lastIndexOf("/"); + return index < 0 ? "" : path.slice(0, index); +}; + +export const joinPath = (directory: string, rest: string): string => + directory === "" ? rest : rest === "" ? directory : `${directory}/${rest}`; + +export type Expansion = + | { readonly scope: "target"; readonly path: string } + | { readonly scope: "outside"; readonly path: string } + | { readonly scope: "undetermined"; readonly path: string }; + +/** Expand one dyld path prefix relative to its owning image. */ +export const expandPrefix = ( + raw: string, + context: { + readonly ownerDirectory: string; + readonly executable: string | null; + }, +): Expansion => { + const variable = /^@(executable_path|loader_path)(?:\/(.*))?$/u.exec(raw); + if (variable !== null) { + const rest = variable[2] ?? ""; + if (variable[1] === "loader_path") + return { scope: "target", path: joinPath(context.ownerDirectory, rest) }; + return context.executable === null + ? { scope: "undetermined", path: raw } + : { + scope: "target", + path: joinPath(directoryOf(context.executable), rest), + }; + } + if (raw.startsWith("/")) return { scope: "outside", path: raw }; + return { scope: "undetermined", path: raw }; +}; + +/** + * Slices a process may load, best first, following dyld's graded + * architectures: an x86_64h process also accepts generic x86_64. + */ +const COMPATIBLE_ARCHITECTURES: Readonly> = { + x86_64h: ["x86_64h", "x86_64"], +}; + +export const compatibleSlice = ( + slices: readonly MachoSlice[], + process: string, +): MachoSlice | undefined => { + for (const architecture of COMPATIBLE_ARCHITECTURES[process] ?? [process]) { + const slice = slices.find((item) => item.architecture === architecture); + if (slice !== undefined) return slice; + } + return undefined; +}; diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts new file mode 100644 index 0000000000..bfd0a25b58 --- /dev/null +++ b/src/domain/apple/dylibResolution.test.ts @@ -0,0 +1,605 @@ +import { describe, expect, it } from "vitest"; + +import { + traceDylibLoading, + type DylibTreeEntry, + type DylibTreeView, + type MachoDependency, + type MachoImageFacts, + type MachoSlice, +} from "./dylibResolution.js"; + +const slice = (overrides: Partial = {}): MachoSlice => ({ + architecture: "arm64", + file_type: "dylib", + install_name: null, + dependencies: [], + rpaths: [], + dyld_environment: [], + code_signature_present: true, + ...overrides, +}); + +const dependency = ( + installName: string, + overrides: Partial = {}, +): MachoDependency => ({ + command: "LC_LOAD_DYLIB", + encoding: "dylib_command", + install_name: installName, + weak: false, + upward: false, + reexport: false, + delayed_init: false, + current_version: "1.0.0", + compatibility_version: "1.0.0", + ...overrides, +}); + +const parsed = (...slices: MachoSlice[]): MachoImageFacts => ({ + status: "parsed", + slices, +}); + +const executable = (overrides: Partial = {}): MachoImageFacts => + parsed(slice({ file_type: "execute", ...overrides })); + +/** In-memory tree: files are images (or "data"), symlinks map to targets. */ +const memoryView = ( + files: Readonly>, + symlinks: Readonly> = {}, +): DylibTreeView => { + const entries = new Map(); + const addParents = (path: string): void => { + const segments = path.split("/"); + for (let length = 1; length < segments.length; length++) + entries.set(segments.slice(0, length).join("/"), { kind: "directory" }); + }; + for (const path of Object.keys(files)) { + addParents(path); + entries.set(path, { kind: "file" }); + } + for (const [path, target] of Object.entries(symlinks)) { + addParents(path); + entries.set(path, { kind: "symlink", target }); + } + return { + entry: (path) => Promise.resolve(entries.get(path)), + image: (path) => { + const facts = files[path]; + return Promise.resolve( + facts === undefined || facts === "data" + ? { status: "not-mach-o" as const } + : facts, + ); + }, + }; +}; + +const MAIN = "Contents/MacOS/App"; + +const edgeFor = ( + trace: Awaited>, + loader: string, + installName: string, +) => { + const edge = trace.edges.find( + (candidate) => + candidate.loader === loader && candidate.install_name === installName, + ); + if (edge === undefined) + throw new Error(`missing ${loader} -> ${installName}`); + return edge; +}; + +describe("dyld path expansion", () => { + it("searches the loading image's rpaths before its loaders'", async () => { + const core = "Contents/Frameworks/Core.framework/Versions/A/Core"; + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + rpaths: ["@executable_path/../Frameworks"], + dependencies: [dependency("@rpath/Core.framework/Versions/A/Core")], + }), + [core]: parsed( + slice({ + rpaths: ["@loader_path/Libraries"], + dependencies: [dependency("@rpath/libchain.dylib")], + }), + ), + "Contents/Frameworks/libchain.dylib": parsed(slice()), + "Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib": + parsed(slice()), + }), + { roots: [MAIN] }, + ); + const chain = edgeFor(trace, core, "@rpath/libchain.dylib"); + expect(chain.via).toEqual([MAIN, core]); + expect(chain.candidates).toEqual([ + expect.objectContaining({ + source: "rpath", + rpath: "@loader_path/Libraries", + rpath_owner: core, + outcome: "resolved", + }), + ]); + expect(chain.resolution).toEqual({ + status: "resolved", + image: + "Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib", + }); + }); + + it("expands @loader_path in an rpath relative to the image that owns it", async () => { + const plugin = "Contents/PlugIns/Tool.bundle/Contents/MacOS/Tool"; + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + rpaths: ["@loader_path/../Shared"], + dependencies: [ + dependency( + `@executable_path/../PlugIns/Tool.bundle/Contents/MacOS/Tool`, + ), + ], + }), + [plugin]: parsed( + slice({ + file_type: "bundle", + dependencies: [dependency("@rpath/libshared.dylib")], + }), + ), + "Contents/Shared/libshared.dylib": parsed(slice()), + }), + { roots: [MAIN] }, + ); + expect(edgeFor(trace, plugin, "@rpath/libshared.dylib").candidates).toEqual( + [ + expect.objectContaining({ + path: "Contents/MacOS/../Shared/libshared.dylib", + rpath_owner: MAIN, + outcome: "resolved", + resolved_path: "Contents/Shared/libshared.dylib", + }), + ], + ); + }); + + it("gives each executable root its own @executable_path", async () => { + const service = "Contents/XPCServices/Fetch.xpc/Contents/MacOS/Fetch"; + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + dependencies: [dependency("@executable_path/libhere.dylib")], + }), + [service]: executable({ + dependencies: [dependency("@executable_path/libhere.dylib")], + }), + "Contents/MacOS/libhere.dylib": parsed(slice()), + }), + { roots: [MAIN, service] }, + ); + expect( + edgeFor(trace, MAIN, "@executable_path/libhere.dylib").resolution.status, + ).toBe("resolved"); + expect( + edgeFor(trace, service, "@executable_path/libhere.dylib"), + ).toMatchObject({ + candidates: [ + { + path: "Contents/XPCServices/Fetch.xpc/Contents/MacOS/libhere.dylib", + outcome: "absent", + }, + ], + resolution: { status: "unresolved", image: null }, + }); + }); + + it("follows Versions/Current inside the root and stops at escaping links", async () => { + const trace = await traceDylibLoading( + memoryView( + { + [MAIN]: executable({ + rpaths: ["@executable_path/../Frameworks"], + dependencies: [ + dependency("@rpath/Core.framework/Core"), + dependency("@rpath/Escape.framework/Escape"), + dependency("@executable_path/../../../outside.dylib"), + ], + }), + "Contents/Frameworks/Core.framework/Versions/A/Core": parsed(slice()), + }, + { + "Contents/Frameworks/Core.framework/Versions/Current": "A", + "Contents/Frameworks/Core.framework/Core": "Versions/Current/Core", + "Contents/Frameworks/Escape.framework": + "/Library/Frameworks/Escape.framework", + }, + ), + { roots: [MAIN] }, + ); + expect( + edgeFor(trace, MAIN, "@rpath/Core.framework/Core").resolution, + ).toEqual({ + status: "resolved", + image: "Contents/Frameworks/Core.framework/Versions/A/Core", + }); + for (const name of [ + "@rpath/Escape.framework/Escape", + "@executable_path/../../../outside.dylib", + ]) + expect(edgeFor(trace, MAIN, name)).toMatchObject({ + candidates: [{ outcome: "escapes-target" }], + resolution: { status: "undetermined", image: null }, + }); + }); +}); + +describe("dyld resolution outcomes", () => { + it("keeps paths outside the root undetermined and in-root fallbacks conditional", async () => { + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + rpaths: ["/usr/lib/swift", "@executable_path/../Frameworks"], + dependencies: [ + dependency("/usr/lib/libSystem.B.dylib"), + dependency("@rpath/libswiftCore.dylib"), + dependency("libleaf.dylib"), + ], + }), + "Contents/Frameworks/libswiftCore.dylib": parsed(slice()), + }), + { roots: [MAIN] }, + ); + expect(edgeFor(trace, MAIN, "/usr/lib/libSystem.B.dylib")).toMatchObject({ + candidates: [ + { outcome: "outside-target", path: "/usr/lib/libSystem.B.dylib" }, + ], + resolution: { status: "undetermined", image: null }, + }); + expect(edgeFor(trace, MAIN, "@rpath/libswiftCore.dylib")).toMatchObject({ + candidates: [ + { + outcome: "outside-target", + path: "/usr/lib/swift/libswiftCore.dylib", + }, + { outcome: "resolved" }, + ], + resolution: { + status: "conditional", + image: "Contents/Frameworks/libswiftCore.dylib", + }, + }); + expect(edgeFor(trace, MAIN, "libleaf.dylib").resolution.status).toBe( + "undetermined", + ); + }); + + it("derives unresolved and earlier-candidate findings separately for weak loads", async () => { + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + rpaths: [ + "@executable_path/../Overrides", + "@executable_path/../Frameworks", + ], + dependencies: [ + dependency("@rpath/libfound.dylib"), + dependency("@rpath/libgone.dylib", { + command: "LC_LOAD_WEAK_DYLIB", + weak: true, + }), + dependency("@rpath/libmissing.dylib"), + ], + dyld_environment: ["DYLD_LIBRARY_PATH=/tmp"], + }), + "Contents/Frameworks/libfound.dylib": parsed(slice()), + }), + { roots: [MAIN] }, + ); + expect( + trace.findings.map(({ kind, edge_index: index }) => [kind, index]), + ).toEqual([ + ["earlier-rpath-candidate-absent", 0], + ["weak-load-unresolved", 1], + ["required-load-unresolved", 2], + ["dyld-environment-present", null], + ]); + expect(trace.findings[0]?.explanation).toContain( + "Contents/MacOS/../Overrides/libfound.dylib", + ); + }); + + it("reports non-Mach-O, malformed and wrong-architecture candidates", async () => { + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + rpaths: [ + "@executable_path/a", + "@executable_path/b", + "@executable_path/c", + ], + dependencies: [dependency("@rpath/lib.dylib")], + }), + "Contents/MacOS/a/lib.dylib": "data", + "Contents/MacOS/b/lib.dylib": { + status: "malformed", + reason: "load command 0 has invalid cmdsize 0", + }, + "Contents/MacOS/c/lib.dylib": parsed(slice({ architecture: "x86_64" })), + }), + { roots: [MAIN] }, + ); + expect( + edgeFor(trace, MAIN, "@rpath/lib.dylib").candidates.map( + ({ outcome }) => outcome, + ), + ).toEqual(["not-mach-o", "malformed", "architecture-missing"]); + expect(trace.coverage).toEqual({ + status: "partial", + unparsed_images: ["Contents/MacOS/b/lib.dylib"], + roots_without_architecture: [], + }); + expect(trace.images.map(({ path }) => path)).toEqual([ + MAIN, + "Contents/MacOS/b/lib.dylib", + "Contents/MacOS/c/lib.dylib", + ]); + }); +}); + +describe("dyld load order", () => { + it("loads each image once per process and reuses matching install names", async () => { + const a = "Contents/Frameworks/libA.dylib"; + const b = "Contents/Frameworks/libB.dylib"; + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + rpaths: ["@executable_path/../Frameworks"], + dependencies: [ + dependency("@rpath/libA.dylib"), + dependency("@rpath/libB.dylib"), + ], + }), + [a]: parsed( + slice({ + install_name: "@rpath/libA.dylib", + dependencies: [ + dependency("@rpath/libB.dylib"), + dependency("@rpath/libA.dylib"), + ], + }), + ), + [b]: parsed( + slice({ + install_name: "@rpath/libB.dylib", + dependencies: [dependency("@rpath/libA.dylib")], + }), + ), + }), + { roots: [MAIN] }, + ); + expect( + trace.edges.map( + ({ loader, install_name: name }) => + `${loader.split("/").at(-1)}>${name}`, + ), + ).toEqual([ + "App>@rpath/libA.dylib", + "App>@rpath/libB.dylib", + "libA.dylib>@rpath/libB.dylib", + "libA.dylib>@rpath/libA.dylib", + "libB.dylib>@rpath/libA.dylib", + ]); + expect(edgeFor(trace, b, "@rpath/libA.dylib").candidates).toEqual([ + expect.objectContaining({ source: "already-loaded", resolved_path: a }), + ]); + expect(edgeFor(trace, a, "@rpath/libA.dylib").install_name_matches).toBe( + true, + ); + }); + + it("leaves @executable_path undetermined for a library root and filters architectures", async () => { + const library = "Contents/Frameworks/libroot.dylib"; + const trace = await traceDylibLoading( + memoryView({ + [library]: parsed( + slice({ dependencies: [dependency("@executable_path/x.dylib")] }), + ), + [MAIN]: executable(), + }), + { roots: [library, MAIN], architecture: "x86_64" }, + ); + expect(trace.roots).toEqual([]); + expect(trace.coverage.roots_without_architecture).toEqual([library, MAIN]); + const arm = await traceDylibLoading( + memoryView({ + [library]: parsed( + slice({ dependencies: [dependency("@executable_path/x.dylib")] }), + ), + }), + { roots: [library] }, + ); + expect(arm.edges[0]?.candidates).toEqual([ + expect.objectContaining({ + source: "executable_path", + outcome: "undetermined", + }), + ]); + }); + + it("stops when cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + traceDylibLoading(memoryView({ [MAIN]: executable() }), { + roots: [MAIN], + signal: controller.signal, + }), + ).rejects.toThrow(); + }); +}); + +describe("dyld slice compatibility and coverage", () => { + it("lets an x86_64h process load generic x86_64 but not arm64e load arm64", async () => { + const files = { + [MAIN]: parsed( + slice({ + architecture: "x86_64h", + file_type: "execute", + dependencies: [dependency("@executable_path/libgeneric.dylib")], + }), + slice({ + architecture: "arm64e", + file_type: "execute", + dependencies: [dependency("@executable_path/libgeneric.dylib")], + }), + ), + "Contents/MacOS/libgeneric.dylib": parsed( + slice({ architecture: "x86_64" }), + slice({ architecture: "arm64" }), + ), + }; + const trace = await traceDylibLoading(memoryView(files), { roots: [MAIN] }); + expect( + trace.edges.map(({ architecture, candidates, resolution }) => [ + architecture, + candidates[0]?.outcome, + resolution.status, + ]), + ).toEqual([ + ["x86_64h", "resolved", "resolved"], + ["arm64e", "architecture-missing", "unresolved"], + ]); + }); + + it("keeps unsupported candidates undeterminable rather than malformed", async () => { + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + dependencies: [dependency("@executable_path/libbig.dylib")], + }), + "Contents/MacOS/libbig.dylib": { + status: "unsupported", + reason: "big-endian Mach-O images are not supported", + }, + }), + { roots: [MAIN] }, + ); + expect(trace.edges[0]).toMatchObject({ + candidates: [{ outcome: "unsupported" }], + resolution: { status: "undetermined", image: null }, + }); + expect( + trace.images.find(({ path }) => path.endsWith("libbig.dylib")), + ).toMatchObject({ + parse_status: "unsupported", + }); + }); + + it("reports unclassified Mach-O files as partial coverage", async () => { + const broken = "Contents/Helpers/broken"; + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable(), + [broken]: { + status: "malformed", + reason: "load command 0 has invalid cmdsize 0", + }, + }), + { roots: [MAIN], unclassified: [broken] }, + ); + expect(trace.coverage).toEqual({ + status: "partial", + unparsed_images: [broken], + roots_without_architecture: [], + }); + expect(trace.images.find(({ path }) => path === broken)?.reason).toBe( + "load command 0 has invalid cmdsize 0", + ); + }); +}); + +describe("lazily loaded dependencies", () => { + it("resolves lazy loads without traversing them or reporting launch failure", async () => { + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + rpaths: ["@executable_path/../Frameworks"], + dependencies: [ + dependency("@rpath/liblazy.dylib", { + command: "LC_LAZY_LOAD_DYLIB", + }), + dependency("@rpath/libmissing.dylib", { + command: "LC_LAZY_LOAD_DYLIB", + }), + ], + }), + "Contents/Frameworks/liblazy.dylib": parsed( + slice({ dependencies: [dependency("@rpath/libdeep.dylib")] }), + ), + }), + { roots: [MAIN] }, + ); + expect(trace.edges.map(({ loader }) => loader)).toEqual([MAIN, MAIN]); + expect(trace.edges[0]?.resolution.status).toBe("resolved"); + expect(trace.findings.map(({ kind }) => kind)).toEqual([ + "lazy-load-unresolved", + ]); + }); +}); + +describe("conditional loads", () => { + it("carries a conditional fallback's uncertainty to its dependents and reuses", async () => { + const VENDOR = "Contents/Frameworks/libvendor.dylib"; + const OTHER = "Contents/Frameworks/libother.dylib"; + const trace = await traceDylibLoading( + memoryView({ + [MAIN]: executable({ + rpaths: ["/opt/vendor/lib", "@executable_path/../Frameworks"], + dependencies: [ + dependency("@rpath/libvendor.dylib"), + dependency("@executable_path/../Frameworks/libother.dylib"), + ], + }), + [VENDOR]: parsed( + slice({ + install_name: "@rpath/libvendor.dylib", + dependencies: [ + dependency("@loader_path/libgone.dylib"), + dependency("@loader_path/libweak.dylib", { weak: true }), + dependency("@loader_path/liblazy.dylib", { + command: "LC_LAZY_LOAD_DYLIB", + }), + ], + }), + ), + [OTHER]: parsed( + slice({ dependencies: [dependency("@rpath/libvendor.dylib")] }), + ), + }), + { roots: [MAIN] }, + ); + expect(edgeFor(trace, MAIN, "@rpath/libvendor.dylib")).toMatchObject({ + resolution: { status: "conditional", image: VENDOR }, + loader_conditional: false, + }); + expect(edgeFor(trace, VENDOR, "@loader_path/libgone.dylib")).toMatchObject({ + resolution: { status: "unresolved" }, + loader_conditional: true, + }); + expect(edgeFor(trace, OTHER, "@rpath/libvendor.dylib")).toMatchObject({ + candidates: [{ source: "already-loaded" }], + resolution: { status: "conditional", image: VENDOR }, + loader_conditional: false, + }); + // Required, weak and lazy findings below the fallback are all qualified. + for (const kind of [ + "required-load-unresolved", + "weak-load-unresolved", + "lazy-load-unresolved", + ]) + expect( + trace.findings.find((finding) => finding.kind === kind)?.explanation, + ).toContain(`${VENDOR} loads only conditionally; if it loads,`); + }); +}); diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts new file mode 100644 index 0000000000..241a50be84 --- /dev/null +++ b/src/domain/apple/dylibResolution.ts @@ -0,0 +1,514 @@ +import { z } from "zod"; + +import { + compatibleSlice, + directoryOf, + expandPrefix, + joinPath, + resolveTreePath, + type Expansion, +} from "./dyldPaths.js"; +import { digestSchema } from "../digests.js"; +import { + DYLIB_RESOLUTION_LIMITATIONS, + deriveFindings, +} from "./dylibResolutionFindings.js"; + +/** Normalized path below the analyzed root: no `.`/`..` or empty segments. */ +const ROOT_RELATIVE_PATH = + /^(?!\/)(?!(?:.*\/)?\.{1,2}(?:\/|$))(?!.*\/\/)(?!.*\/$)[^\\\0]+$/u; +const rootRelativePathSchema = z.string().min(1).regex(ROOT_RELATIVE_PATH); + +/** Caller selection of process roots and one CPU slice. */ +export const dylibResolutionInputSchema = z.strictObject({ + roots: z.array(rootRelativePathSchema).min(1).optional(), + architecture: z.enum(["arm64", "arm64e", "x86_64"]).optional(), +}); + +const dependencyCommandSchema = z.enum([ + "LC_LOAD_DYLIB", + "LC_LOAD_WEAK_DYLIB", + "LC_REEXPORT_DYLIB", + "LC_LAZY_LOAD_DYLIB", + "LC_LOAD_UPWARD_DYLIB", +]); + +const dependencySchema = z.strictObject({ + command: dependencyCommandSchema, + encoding: z.enum(["dylib_command", "dylib_use_command"]), + install_name: z.string(), + weak: z.boolean(), + upward: z.boolean(), + reexport: z.boolean(), + delayed_init: z.boolean(), + current_version: z.string(), + compatibility_version: z.string(), +}); + +/** Observed dylib-loading facts of one Mach-O slice. */ +export const machoSliceSchema = z.strictObject({ + architecture: z.string().min(1), + file_type: z.enum(["execute", "dylib", "bundle", "other"]), + install_name: z.string().nullable(), + dependencies: z.array(dependencySchema), + rpaths: z.array(z.string()), + dyld_environment: z.array(z.string()), + code_signature_present: z.boolean(), +}); + +export type MachoDependency = z.infer; +export type MachoSlice = z.infer; + +/** Header parse of one file; malformed and unsupported images keep their reason. */ +export type MachoImageFacts = + | { readonly status: "parsed"; readonly slices: readonly MachoSlice[] } + | { + readonly status: "malformed" | "unsupported"; + readonly reason: string; + } + | { readonly status: "not-mach-o" }; + +/** One observed path below the analyzed root. Symlink targets are not followed by the view. */ +export type DylibTreeEntry = + | { readonly kind: "file" | "directory" } + | { readonly kind: "symlink"; readonly target: string }; + +/** Read-only, lazily probed view of the analyzed root. Paths are root-relative. */ +export interface DylibTreeView { + entry(path: string): Promise; + image(path: string): Promise; +} + +const candidateSchema = z.strictObject({ + path: z.string(), + source: z.enum([ + "literal", + "executable_path", + "loader_path", + "rpath", + "already-loaded", + ]), + rpath: z.string().nullable(), + rpath_owner: z.string().nullable(), + outcome: z.enum([ + "resolved", + "absent", + "not-mach-o", + "malformed", + "unsupported", + "architecture-missing", + "outside-target", + "escapes-target", + "undetermined", + ]), + resolved_path: z.string().nullable(), +}); + +const edgeSchema = z.strictObject({ + root: z.string(), + architecture: z.string(), + loader: z.string(), + via: z.array(z.string()), + ...dependencySchema.shape, + candidates: z.array(candidateSchema), + resolution: z.strictObject({ + status: z.enum(["resolved", "conditional", "unresolved", "undetermined"]), + image: z.string().nullable(), + }), + install_name_matches: z.boolean().nullable(), + /** + * The loader was reached through a conditional edge, so it might not load; + * this edge describes what happens only if it does. + */ + loader_conditional: z.boolean(), +}); + +const findingSchema = z.strictObject({ + kind: z.enum([ + "required-load-unresolved", + "weak-load-unresolved", + "lazy-load-unresolved", + "earlier-rpath-candidate-absent", + "dyld-environment-present", + ]), + edge_index: z.number().int().nonnegative().nullable(), + image: z.string().nullable(), + basis: z.literal("derived"), + explanation: z.string().min(1), +}); + +const imageSchema = z.strictObject({ + path: z.string(), + sha256: digestSchema, + parse_status: z.enum(["parsed", "malformed", "unsupported"]), + reason: z.string().nullable(), + slices: z.array(machoSliceSchema.omit({ dependencies: true })), +}); + +/** Static dyld load-path resolution for every selected process root. */ +export const dylibResolutionResultSchema = z.strictObject({ + root_path: z.string(), + target_sha256: digestSchema, + roots: z.array( + z.strictObject({ image: z.string(), architecture: z.string() }), + ), + images: z.array(imageSchema), + edges: z.array(edgeSchema), + findings: z.array(findingSchema), + coverage: z.strictObject({ + status: z.enum(["complete", "partial"]), + unparsed_images: z.array(z.string()), + roots_without_architecture: z.array(z.string()), + }), + limitations: z.array(z.string().min(1)), +}); + +export type DylibResolutionResult = z.infer; +type Candidate = z.infer; +export type DylibEdge = z.infer; +export type DylibFinding = z.infer; +type Edge = DylibEdge; + +/** Result before the adapter adds file digests and the analyzed root. */ +export type DylibTrace = Omit< + DylibResolutionResult, + "root_path" | "target_sha256" | "images" +> & { + readonly images: readonly (Omit< + DylibResolutionResult["images"][number], + "sha256" + > & { readonly sha256?: never })[]; +}; + +interface LoadedImage { + readonly slice: MachoSlice; + /** Images from the process root to this image; the rpath stack, outermost first. */ + readonly chain: readonly string[]; + /** Some edge on the chain was conditional: an unknown earlier candidate may load instead. */ + readonly conditional: boolean; +} + +interface ProcessContext { + readonly view: DylibTreeView; + readonly root: string; + readonly architecture: string; + readonly executable: string | null; + readonly loaded: Map; + readonly byInstallName: Map; + readonly images: Map; +} + +interface CandidateTemplate { + readonly expansion: Expansion; + readonly source: Candidate["source"]; + readonly rpath: string | null; + readonly rpathOwner: string | null; +} + +const candidateTemplates = ( + dependency: MachoDependency, + loader: string, + context: ProcessContext, +): CandidateTemplate[] => { + const name = dependency.install_name; + if (!name.startsWith("@rpath/")) { + const source = name.startsWith("@executable_path") + ? "executable_path" + : name.startsWith("@loader_path") + ? "loader_path" + : "literal"; + return [ + { + expansion: expandPrefix(name, { + ownerDirectory: directoryOf(loader), + executable: context.executable, + }), + source, + rpath: null, + rpathOwner: null, + }, + ]; + } + const rest = name.slice("@rpath/".length); + const owners = [...(context.loaded.get(loader)?.chain ?? [loader])].reverse(); + return owners.flatMap((owner) => + (context.loaded.get(owner)?.slice.rpaths ?? []).map((rpath) => { + const base = expandPrefix(rpath, { + ownerDirectory: directoryOf(owner), + executable: context.executable, + }); + const expansion: Expansion = { + scope: base.scope, + path: + base.scope === "outside" + ? `${base.path.replace(/\/+$/u, "")}/${rest}` + : joinPath(base.path, rest), + }; + return { expansion, source: "rpath" as const, rpath, rpathOwner: owner }; + }), + ); +}; + +const evaluateCandidate = async ( + template: CandidateTemplate, + context: ProcessContext, +): Promise => { + const base = { + path: template.expansion.path, + source: template.source, + rpath: template.rpath, + rpath_owner: template.rpathOwner, + }; + if (template.expansion.scope === "outside") + return { ...base, outcome: "outside-target", resolved_path: null }; + if (template.expansion.scope === "undetermined") + return { ...base, outcome: "undetermined", resolved_path: null }; + const lookup = await resolveTreePath(context.view, template.expansion.path); + if (lookup.kind === "escapes") + return { ...base, outcome: "escapes-target", resolved_path: null }; + if (lookup.kind !== "file") + return { ...base, outcome: "absent", resolved_path: null }; + const facts = await imageFacts(context, lookup.path); + const outcome: Candidate["outcome"] = + facts.status !== "parsed" + ? facts.status + : compatibleSlice(facts.slices, context.architecture) === undefined + ? "architecture-missing" + : "resolved"; + return { ...base, outcome, resolved_path: lookup.path }; +}; + +const imageFacts = async ( + context: ProcessContext, + path: string, +): Promise => { + const known = context.images.get(path); + if (known !== undefined) return known; + const facts = await context.view.image(path); + context.images.set(path, facts); + return facts; +}; + +const resolution = (candidates: readonly Candidate[]): Edge["resolution"] => { + let external = false; + for (const candidate of candidates) { + if (candidate.outcome === "resolved") + return { + status: external ? "conditional" : "resolved", + image: candidate.resolved_path, + }; + // dyld might load these; REA cannot decide them from the analyzed root. + if ( + candidate.outcome === "outside-target" || + candidate.outcome === "escapes-target" || + candidate.outcome === "unsupported" || + candidate.outcome === "undetermined" + ) + external = true; + } + return { status: external ? "undetermined" : "unresolved", image: null }; +}; + +const resolveDependency = async ( + dependency: MachoDependency, + loader: string, + context: ProcessContext, +): Promise => { + const current = context.loaded.get(loader); + const chain = current?.chain ?? [loader]; + const loaded = context.byInstallName.get(dependency.install_name); + const candidates: Candidate[] = []; + if (loaded !== undefined) + candidates.push({ + path: dependency.install_name, + source: "already-loaded", + rpath: null, + rpath_owner: null, + outcome: "resolved", + resolved_path: loaded, + }); + else + for (const template of candidateTemplates(dependency, loader, context)) { + const candidate = await evaluateCandidate(template, context); + candidates.push(candidate); + // dyld stops at the first loadable candidate. + if (candidate.outcome === "resolved") break; + } + const searched = resolution(candidates); + // Reusing an image that itself loads only conditionally is conditional too. + const resolved: Edge["resolution"] = + loaded !== undefined && + context.loaded.get(loaded)?.conditional === true && + searched.status === "resolved" + ? { ...searched, status: "conditional" } + : searched; + const image = + resolved.image === null ? undefined : context.images.get(resolved.image); + const slice = + image?.status === "parsed" + ? compatibleSlice(image.slices, context.architecture) + : undefined; + return { + root: context.root, + architecture: context.architecture, + loader, + via: [...chain], + ...dependency, + candidates, + resolution: resolved, + install_name_matches: + slice === undefined || slice.install_name === null + ? null + : slice.install_name === dependency.install_name, + loader_conditional: current?.conditional ?? false, + }; +}; + +/** + * Follow dyld4's dependents-first order: resolve every dependency of one + * image, then recurse into the images it newly loaded, in load-command order. + */ +const traceProcess = async ( + context: ProcessContext, + rootSlice: MachoSlice, + signal?: AbortSignal, +): Promise => { + const edges: Edge[] = []; + context.loaded.set(context.root, { + slice: rootSlice, + chain: [context.root], + conditional: false, + }); + if (rootSlice.install_name !== null) + context.byInstallName.set(rootSlice.install_name, context.root); + const pending = [context.root]; + while (pending.length > 0) { + signal?.throwIfAborted(); + const loader = pending.pop() ?? ""; + const current = context.loaded.get(loader); + if (current === undefined) continue; + const created: string[] = []; + for (const dependency of current.slice.dependencies) { + const edge = await resolveDependency(dependency, loader, context); + edges.push(edge); + // dyld loads LC_LAZY_LOAD_DYLIB images on first use, not at launch. + if (dependency.command === "LC_LAZY_LOAD_DYLIB") continue; + const image = edge.resolution.image; + if (image === null || context.loaded.has(image)) continue; + const facts = context.images.get(image); + const slice = + facts?.status === "parsed" + ? compatibleSlice(facts.slices, context.architecture) + : undefined; + if (slice === undefined) continue; + context.loaded.set(image, { + slice, + chain: [...current.chain, image], + // dyld may load an unknown earlier candidate instead of this fallback. + conditional: + current.conditional || edge.resolution.status === "conditional", + }); + if ( + slice.install_name !== null && + !context.byInstallName.has(slice.install_name) + ) + context.byInstallName.set(slice.install_name, image); + created.push(image); + } + pending.push(...created.reverse()); + } + return edges; +}; + +/** Trace dyld load paths from each process root through the analyzed root. */ +export const traceDylibLoading = async ( + view: DylibTreeView, + request: { + readonly roots: readonly string[]; + /** Mach-O files whose root role could not be classified because they did not parse. */ + readonly unclassified?: readonly string[]; + readonly architecture?: string; + readonly signal?: AbortSignal; + }, +): Promise => { + const images = new Map(); + for (const path of request.unclassified ?? []) + images.set(path, await view.image(path)); + const roots: DylibTrace["roots"][number][] = []; + const edges: Edge[] = []; + const withoutArchitecture: string[] = []; + for (const root of request.roots) { + const facts = await view.image(root); + images.set(root, facts); + if (facts.status !== "parsed") continue; + const slices = facts.slices.filter( + ({ architecture }) => + request.architecture === undefined || + architecture === request.architecture, + ); + if (slices.length === 0) withoutArchitecture.push(root); + for (const slice of slices) { + roots.push({ image: root, architecture: slice.architecture }); + edges.push( + ...(await traceProcess( + { + view, + root, + architecture: slice.architecture, + executable: slice.file_type === "execute" ? root : null, + loaded: new Map(), + byInstallName: new Map(), + images, + }, + slice, + request.signal, + )), + ); + } + } + const reported = [...images.entries()] + .filter(([, facts]) => facts.status !== "not-mach-o") + .sort(([left], [right]) => compare(left, right)); + const unparsed = reported + .filter(([, facts]) => facts.status !== "parsed") + .map(([path]) => path); + return { + roots, + images: reported.map(([path, facts]) => ({ + path, + parse_status: + facts.status === "not-mach-o" ? "unsupported" : facts.status, + reason: + facts.status === "parsed" || facts.status === "not-mach-o" + ? null + : facts.reason, + slices: + facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [], + })), + edges, + findings: deriveFindings(edges, roots, images), + coverage: { + status: + unparsed.length === 0 && withoutArchitecture.length === 0 + ? "complete" + : "partial", + unparsed_images: unparsed, + roots_without_architecture: withoutArchitecture, + }, + limitations: DYLIB_RESOLUTION_LIMITATIONS, + }; +}; + +const withoutDependencies = ( + slice: MachoSlice, +): Omit => ({ + architecture: slice.architecture, + file_type: slice.file_type, + install_name: slice.install_name, + rpaths: slice.rpaths, + dyld_environment: slice.dyld_environment, + code_signature_present: slice.code_signature_present, +}); + +const compare = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; diff --git a/src/domain/apple/dylibResolutionFindings.ts b/src/domain/apple/dylibResolutionFindings.ts new file mode 100644 index 0000000000..109ad0f7f0 --- /dev/null +++ b/src/domain/apple/dylibResolutionFindings.ts @@ -0,0 +1,93 @@ +import type { + DylibEdge as Edge, + DylibFinding as Finding, + DylibTrace, + MachoImageFacts, +} from "./dylibResolution.js"; + +export const deriveFindings = ( + edges: readonly Edge[], + roots: DylibTrace["roots"], + images: ReadonlyMap, +): Finding[] => { + const findings: Finding[] = []; + edges.forEach((edge, index) => { + if (edge.resolution.status === "unresolved") + findings.push(unresolvedFinding(edge, index)); + const resolvedAt = edge.candidates.findIndex( + ({ outcome }) => outcome === "resolved", + ); + const earlier = edge.candidates + .slice(0, Math.max(resolvedAt, 0)) + .filter( + ({ source, outcome }) => source === "rpath" && outcome === "absent", + ) + .map(({ path }) => path); + if (resolvedAt > 0 && earlier.length > 0) + findings.push({ + kind: "earlier-rpath-candidate-absent", + edge_index: index, + image: edge.loader, + basis: "derived", + explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`, + }); + }); + for (const { image, architecture } of roots) { + const facts = images.get(image); + const environment = + facts?.status === "parsed" + ? (facts.slices.find((slice) => slice.architecture === architecture) + ?.dyld_environment ?? []) + : []; + if (environment.length > 0) + findings.push({ + kind: "dyld-environment-present", + edge_index: null, + image, + basis: "derived", + explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); search paths they add are not modeled.`, + }); + } + return findings; +}; + +const unresolvedFinding = (edge: Edge, index: number): Finding => { + const base = { + edge_index: index, + image: edge.loader, + basis: "derived", + } as const; + const missing = `No candidate for ${edge.command === "LC_LAZY_LOAD_DYLIB" ? "lazily loaded " : edge.weak ? "weak dependency " : ""}${edge.install_name} exists in the analyzed root`; + // Below a conditional fallback, every consequence applies only if it loads. + const when = edge.loader_conditional + ? `. ${edge.loader} loads only conditionally; if it loads, ` + : "; "; + if (edge.command === "LC_LAZY_LOAD_DYLIB") + return { + ...base, + kind: "lazy-load-unresolved", + explanation: `${missing}${when}dyld loads it on first use, which would fail unless the image is supplied elsewhere.`, + }; + return edge.weak + ? { + ...base, + kind: "weak-load-unresolved", + explanation: `${missing}${when}dyld continues without it.`, + } + : { + ...base, + kind: "required-load-unresolved", + explanation: `${missing}${when}dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`, + }; +}; + +export const DYLIB_RESOLUTION_LIMITATIONS = [ + "LC_LAZY_LOAD_DYLIB dependencies are resolved but not traversed, because dyld loads them only on first use.", + "Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.", + "Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.", + "Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.", + "Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack and conditionality of the first chain; a later request whose install name matches an already loaded image reuses it.", + "@loader_path uses each image's symlink-resolved path within the analyzed root.", + "Code-signing checks that can reject a found image, such as library validation and the hardened runtime, are not evaluated.", + "Dylib compatibility versions are not compared. Apple's linker documentation describes a load-time rejection when an image's compatibility version is older than the loader requires, but dyld on macOS 26 was observed to load such an image.", +]; diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 54046be6b8..76c94f348f 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -12,6 +12,7 @@ export class ArtifactOperationError extends AnalysisError { | "decode_interface_builder" | "inspect_asset_catalog" | "inspect_keyed_archive" + | "trace_dylib_resolution" | "export_web_scripts" | "trace_web_module_imports" | "trace_web_source_location" diff --git a/src/domain/evm/evmInterface.test.ts b/src/domain/evm/evmInterface.test.ts new file mode 100644 index 0000000000..659f838ba5 --- /dev/null +++ b/src/domain/evm/evmInterface.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import { evmInterfaceSchema } from "./evmInterface.js"; +import { evmInterfaceFixture } from "../../../tests/fixtures/evm/interface.js"; + +it("preserves original carrier and decoded byte identity as distinct observations", () => { + const fixture = evmInterfaceFixture(); + expect(evmInterfaceSchema.parse(fixture)).toEqual(fixture); + expect(fixture.artifact.sha256).not.toBe(fixture.bytecode.sha256); +}); +it.each(["byte-count", "body-offset", "selector", "authenticity"])( + "rejects false interface evidence: %s", + (problem) => { + const value = evmInterfaceFixture(); + const raw = { + ...value, + ...(problem === "authenticity" + ? { + bytecode: { + ...value.bytecode, + deployment_authenticity: "verified", + }, + } + : {}), + }; + if (problem === "byte-count") raw.bytecode.bytes++; + if (problem === "body-offset" || problem === "selector") + raw.functions.push({ + selector: problem === "selector" ? "bad" : "0xaabbccdd", + bytecode_offset: problem === "body-offset" ? 5 : 0, + dispatch: "abi", + inferred_arguments: null, + inferred_state_mutability: null, + }); + expect(evmInterfaceSchema.safeParse(raw).success).toBe(false); + }, +); diff --git a/src/domain/evm/evmInterface.ts b/src/domain/evm/evmInterface.ts new file mode 100644 index 0000000000..75ada1f971 --- /dev/null +++ b/src/domain/evm/evmInterface.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +const byteOffset = z.number().int().nonnegative(); +const sha256 = z.string().regex(/^[a-f0-9]{64}$/); + +/** Select the carrier representation explicitly; no RPC or deployed-contract lookup is implied. */ +export const inspectEvmInterfaceInputSchema = z.strictObject({ + path: z + .string() + .min(1) + .describe("Absolute path to caller-selected local bytecode carrier"), + encoding: z + .enum(["raw", "hex"]) + .describe( + "Raw bytes or UTF-8 hex with optional 0x prefix and outer ASCII whitespace", + ), +}); + +/** Recovered interface candidates are inferences, independently of observed byte identity. */ +export const evmInterfaceSchema = z + .strictObject({ + artifact: z.strictObject({ + path: z.string().min(1), + sha256, + bytes: byteOffset, + encoding: z.enum(["raw", "hex"]), + }), + bytecode: z.strictObject({ + sha256, + bytes: byteOffset, + hex: z.string().regex(/^(?:[0-9a-f]{2})*$/), + digest_algorithm: z.literal("sha256"), + kind: z.literal("unknown"), + deployment_authenticity: z.literal("unknown"), + hardfork: z.literal("unknown"), + }), + evidence_kind: z.literal("inferred"), + functions: z.array( + z.strictObject({ + selector: z.string().regex(/^0x[0-9a-f]{8}$/), + bytecode_offset: byteOffset, + dispatch: z.enum(["abi", "fallback"]), + inferred_arguments: z.string().nullable(), + inferred_state_mutability: z.string().nullable(), + }), + ), + discovery_completeness: z.literal("unknown"), + runtime_execution: z.literal("not-performed"), + diagnostics: z.strictObject({ + stdout: z.string(), + stderr: z.string(), + truncated: z.boolean(), + }), + limitations: z.array(z.string()), + }) + .superRefine((value, context) => { + if (value.bytecode.hex.length !== value.bytecode.bytes * 2) + context.addIssue({ + code: "custom", + path: ["bytecode"], + message: + "Selected byte count does not match the bytecode hex representation.", + }); + for (const [index, fn] of value.functions.entries()) + if (fn.bytecode_offset >= value.bytecode.bytes) + context.addIssue({ + code: "custom", + path: ["functions", index, "bytecode_offset"], + message: "Inferred body offset lies outside the selected bytecode.", + }); + }); + +export type InspectEvmInterfaceInput = z.infer< + typeof inspectEvmInterfaceInputSchema +>; +export type EvmInterface = z.infer; diff --git a/src/evm/EvmBytecodeCarrier.test.ts b/src/evm/EvmBytecodeCarrier.test.ts new file mode 100644 index 0000000000..b8d485f993 --- /dev/null +++ b/src/evm/EvmBytecodeCarrier.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from "vitest"; +import { + decodeEvmBytecodeCarrier, + EvmCarrierFailure, +} from "./EvmBytecodeCarrier.js"; + +it.each(["6000FF", "0x6000ff", " \t0X6000FF\r\n"])( + "decodes explicit hex without changing selected bytes: %j", + (text) => { + expect( + Buffer.from(decodeEvmBytecodeCarrier(Buffer.from(text), "hex")).toString( + "hex", + ), + ).toBe("6000ff"); + }, +); +it.each(["0x1", "60 00", "0xzz", "\uFEFF6000", "\u00a06000"])( + "rejects malformed carrier rather than guessing bytecode: %j", + (text) => { + expect(() => decodeEvmBytecodeCarrier(Buffer.from(text), "hex")).toThrow( + EvmCarrierFailure, + ); + }, +); +it("distinguishes raw bytes, empty code and malformed UTF-8", () => { + const bytes = Uint8Array.from([0xff, 0x00]); + const decoded = decodeEvmBytecodeCarrier(bytes, "raw"); + expect(decoded).toEqual(bytes); + expect(decoded).not.toBe(bytes); + expect(() => decodeEvmBytecodeCarrier(bytes, "hex")).toThrow("valid UTF-8"); + expect(decodeEvmBytecodeCarrier(Buffer.from("0x"), "hex")).toEqual( + new Uint8Array(), + ); +}); diff --git a/src/evm/EvmBytecodeCarrier.ts b/src/evm/EvmBytecodeCarrier.ts new file mode 100644 index 0000000000..368b674aff --- /dev/null +++ b/src/evm/EvmBytecodeCarrier.ts @@ -0,0 +1,26 @@ +/** Expected carrier encoding failure, independent of the analysis engine. */ +export class EvmCarrierFailure extends Error {} + +/** Decode only the explicitly selected representation; source carrier bytes are retained separately. */ +export const decodeEvmBytecodeCarrier = ( + bytes: Uint8Array, + encoding: "raw" | "hex", +): Uint8Array => { + if (encoding === "raw") return Uint8Array.from(bytes); + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch (cause: unknown) { + throw new EvmCarrierFailure("Hex carrier must be valid UTF-8.", { cause }); + } + const code = text + .replace(/^[\x09-\x0d\x20]+|[\x09-\x0d\x20]+$/g, "") + .replace(/^0x/i, ""); + if (!/^(?:[a-fA-F0-9]{2})*$/.test(code)) + throw new EvmCarrierFailure( + "Hex carrier requires complete hexadecimal byte pairs, an optional 0x prefix and outer ASCII whitespace; interior separators are invalid.", + ); + return Uint8Array.from(Buffer.from(code, "hex")); +}; diff --git a/src/evm/EvmWorkerLimits.test.ts b/src/evm/EvmWorkerLimits.test.ts new file mode 100644 index 0000000000..fcbf8197a8 --- /dev/null +++ b/src/evm/EvmWorkerLimits.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "vitest"; +import { selectEvmWorkerLimits } from "./EvmWorkerLimits.js"; + +const limits = ( + soft = "unlimited", + hard = "unlimited", +) => `Limit Soft Limit Hard Limit Units +Max address space ${soft} ${hard} bytes +Max cpu time unlimited unlimited seconds +Max file size unlimited unlimited bytes +`; +it("uses configured maxima without equating virtual space and RSS", () => { + expect(selectEvmWorkerLimits(limits())).toEqual({ + ok: true, + value: { + addressSpaceBytes: 3221225472, + cpuSeconds: 30, + fileSizeBytes: 16777216, + }, + }); +}); +it.each([ + ["1073741824", "2147483648", 1073741824], + ["2147483648", "2147483648", 2147483648], + ["0", "0", 0], + ["9223372036854775807", "9223372036854775807", 3221225472], +])( + "preserves tighter soft/hard boundaries without integer loss: %s/%s", + (soft, hard, expected) => { + expect(selectEvmWorkerLimits(limits(soft, hard))).toMatchObject({ + ok: true, + value: { addressSpaceBytes: expected }, + }); + }, +); +it("independently preserves CPU and file output restrictions", () => { + expect( + selectEvmWorkerLimits( + limits() + .replace("time unlimited unlimited", "time 5 10") + .replace("size unlimited unlimited", "size 4096 8192"), + ), + ).toMatchObject({ ok: true, value: { cpuSeconds: 5, fileSizeBytes: 4096 } }); +}); +it.each([ + ["not-text", null], + ["soft-unlimited-hard-finite", limits("unlimited", "2147483648")], + ["soft-exceeds-hard", limits("3", "2")], + ["missing-row", limits().replace("Max file size", "Max other size")], + ["duplicate-row", limits() + "Max cpu time 1 1 seconds\n"], + ["wrong-unit", limits().replace("bytes", "pages")], + ["oversized", "x".repeat(65537)], +])("rejects malformed/ambiguous resource observations: %s", (_label, input) => { + expect(selectEvmWorkerLimits(input).ok).toBe(false); +}); diff --git a/src/evm/EvmWorkerLimits.ts b/src/evm/EvmWorkerLimits.ts new file mode 100644 index 0000000000..069152e2d3 --- /dev/null +++ b/src/evm/EvmWorkerLimits.ts @@ -0,0 +1,58 @@ +import { err, ok, type Result } from "../domain/result.js"; +import { EVM_INTERFACE_LIMITS } from "./EvmoleRelease.js"; + +/** Selected worker soft limits to configure; inherited hard limits are left unchanged. */ +export interface EvmWorkerLimits { + readonly addressSpaceBytes: number; + readonly cpuSeconds: number; + readonly fileSizeBytes: number; +} + +/** Read only the three selected Linux limits, retaining tighter soft/hard boundaries. */ +export const selectEvmWorkerLimits = ( + raw: unknown, +): Result => { + if (typeof raw !== "string" || Buffer.byteLength(raw) > 64 * 1024) + return err( + "Linux resource limits must be text within the 64 KiB parsing budget.", + ); + const selected: number[] = []; + for (const [name, units, maximum] of [ + ["Max address space", "bytes", EVM_INTERFACE_LIMITS.addressSpaceBytes], + ["Max cpu time", "seconds", 30], + ["Max file size", "bytes", EVM_INTERFACE_LIMITS.outputBytes], + ] as const) { + const entries = raw + .split("\n") + .filter((line) => line.startsWith(name + " ")); + if (entries.length !== 1) + return err(`Linux resource limits must report exactly one ${name} row.`); + const match = new RegExp( + `^${name}\\s+(unlimited|[0-9]+)\\s+(unlimited|[0-9]+)\\s+${units}\\s*$`, + ).exec(entries[0] ?? ""); + if (match === null) + return err(`Malformed inherited Linux resource limit: ${name}.`); + let effective = BigInt(maximum); + const soft = match[1]; + const hard = match[2]; + if (soft === undefined || hard === undefined) + return err(`Missing ${name} values.`); + if ( + hard !== "unlimited" && + (soft === "unlimited" || BigInt(soft) > BigInt(hard)) + ) + return err(`Inherited ${name} soft limit exceeds its hard limit.`); + for (const value of [soft, hard]) + if (value !== "unlimited" && BigInt(value) < effective) + effective = BigInt(value); + selected.push(Number(effective)); + } + const [addressSpaceBytes, cpuSeconds, fileSizeBytes] = selected; + if ( + addressSpaceBytes === undefined || + cpuSeconds === undefined || + fileSizeBytes === undefined + ) + return err("Required Linux resource limits were not selected."); + return ok({ addressSpaceBytes, cpuSeconds, fileSizeBytes }); +}; diff --git a/src/evm/EvmoleFailures.ts b/src/evm/EvmoleFailures.ts new file mode 100644 index 0000000000..5690a079ab --- /dev/null +++ b/src/evm/EvmoleFailures.ts @@ -0,0 +1,205 @@ +import type { EvmWorkerLimits } from "./EvmWorkerLimits.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + AnalysisError, + type AnalysisCapturedOutput, +} from "../domain/analysisErrorBase.js"; +import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCapabilityUnavailableError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import { + EVMOLE_PROVIDER_IDENTITY, + EVM_INTERFACE_LIMITS, + EVM_FILE_SIZE_FAILURE_EXIT, +} from "./EvmoleRelease.js"; + +/** Correlate a reserved exit with the owned worker's actual failure branch. */ +export interface EvmFileSizeFailureEvidence { + readonly verified: boolean; + readonly failure: string | null; +} + +/** Keep carrier read, engine and owned lifecycle failures distinct. */ +export const evmInterfaceFailure = ( + cause: unknown, + phase: "configuration" | "artifact-read" | "worker", + path: string, + workerLimits?: EvmWorkerLimits, + limiter = "/usr/bin/prlimit", + fileFailure?: EvmFileSizeFailureEvidence, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (cause instanceof OwnedCommandFailure) { + const outputOptions = + cause.snapshot === null + ? undefined + : { capturedOutput: capturedEvmOutput(cause.snapshot) }; + if (cause.cleanupFailure !== null) + return new ProviderCleanupError( + EVMOLE_PROVIDER_IDENTITY.id, + cause.resources, + { + reason: cause.cleanupFailure, + ...(cause.snapshot === null + ? {} + : { captured_output: { ...capturedEvmOutput(cause.snapshot) } }), + previous_error: { + failure_kind: cause.reason, + message: cause.message, + exit_code: cause.snapshot?.exitCode ?? null, + signal: cause.snapshot?.signal ?? null, + stdout: cause.snapshot?.stdout.text ?? null, + stderr: cause.snapshot?.stderr.text ?? null, + }, + }, + { operation: "inspect_evm_interface", cause }, + ); + if (cause.reason === "cancelled") + return new AnalysisCancelledError("inspect_evm_interface", outputOptions); + if (cause.reason === "timeout") + return new AnalysisTimeoutError( + "inspect_evm_interface", + EVM_INTERFACE_LIMITS.timeoutMs, + outputOptions, + ); + if (cause.reason === "output-limit") + return new AnalysisOutputError( + "inspect_evm_interface", + cause.message, + outputOptions, + ); + if ( + cause.reason === "process" && + (cause.snapshot?.signal === "SIGXCPU" || + cause.snapshot?.signal === "SIGXFSZ" || + (fileFailure?.verified === true && + cause.snapshot?.exitCode === EVM_FILE_SIZE_FAILURE_EXIT && + cause.snapshot.signal === null)) + ) + return new AnalysisResourceConstraintError( + "inspect_evm_interface", + cause.snapshot.signal === "SIGXCPU" ? "cpu" : "file-size", + (cause.snapshot.signal === null + ? "Owned EVM worker reported EFBIG while writing its reply; the exact write failure cause is unknown." + : `Owned EVM command terminated with ${cause.snapshot.signal}; the exact signal cause is unknown.`) + + " Effective worker limits are unknown. Configured soft limits are retained separately.", + workerLimits === undefined + ? null + : { + configured_soft_limits: { + address_space_bytes: workerLimits.addressSpaceBytes, + cpu_seconds: workerLimits.cpuSeconds, + file_size_bytes: workerLimits.fileSizeBytes, + }, + effective_soft_limits: null, + }, + outputOptions, + ); + } + if ( + cause instanceof Error && + "code" in cause && + "syscall" in cause && + typeof cause.syscall === "string" && + cause.syscall.startsWith("spawn ") + ) { + const reason = `Configured util-linux prlimit could not launch (${String(cause.code)}): ${limiter}. Check its executable/interpreter and host execute access.`; + return new AnalysisCapabilityUnavailableError( + EVMOLE_PROVIDER_IDENTITY.id, + "inspect_evm_interface", + reason, + { cause, userMessage: reason }, + ); + } + if (cause instanceof ArtifactReaderFailure) { + if (cause.reason === "cancelled") + return new AnalysisCancelledError("inspect_evm_interface"); + if (cause.reason === "integrity") + return new AnalysisArtifactChangedError( + "inspect_evm_interface", + path, + cause.message, + { cause }, + ); + if ( + phase === "artifact-read" && + ["limit", "path", "format"].includes(cause.reason) + ) + return new AnalysisInputError("inspect_evm_interface", { cause }, [ + { + path: ["path"], + reason: cause.reason === "limit" ? "out_of_range" : "invalid_format", + message: cause.message, + }, + ]); + } + if (phase === "artifact-read" && cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError( + "inspect_evm_interface", + path, + cause.code, + { cause }, + ); + if (cause.code === "ENOENT" || cause.code === "ENOTDIR") + return new AnalysisInputError("inspect_evm_interface", { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected bytecode carrier could not be read (${String(cause.code)}): ${path}.`, + }, + ]); + } + return new ProviderAdapterError( + EVMOLE_PROVIDER_IDENTITY.id, + "inspect_evm_interface", + { + cause, + diagnostics: { + phase, + path, + ...(workerLimits === undefined + ? {} + : { configured_resource_limits: { ...workerLimits } }), + reason: cause instanceof Error ? cause.message : String(cause), + ...(fileFailure === undefined + ? {} + : { file_size_failure_marker: { ...fileFailure } }), + ...(cause instanceof OwnedCommandFailure + ? { + failure_kind: cause.reason, + exit_code: cause.snapshot?.exitCode ?? null, + signal: cause.snapshot?.signal ?? null, + stdout: cause.snapshot?.stdout.text ?? null, + stderr: cause.snapshot?.stderr.text ?? null, + ...(cause.snapshot === null + ? {} + : { + captured_output: { ...capturedEvmOutput(cause.snapshot) }, + }), + } + : {}), + }, + }, + ); +}; + +/** Preserve retained worker diagnostics with their observed truncation status. */ +export const capturedEvmOutput = ( + snapshot: ProviderProcessSnapshot, +): AnalysisCapturedOutput => ({ + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + truncated: snapshot.diagnosticTruncated === true, +}); diff --git a/src/evm/EvmoleInterfaceAdapter.test.ts b/src/evm/EvmoleInterfaceAdapter.test.ts new file mode 100644 index 0000000000..a8b53f2801 --- /dev/null +++ b/src/evm/EvmoleInterfaceAdapter.test.ts @@ -0,0 +1,66 @@ +import { expect, it } from "vitest"; +import { projectEvmoleInterface } from "./EvmoleInterfaceAdapter.js"; + +it("preserves the actual producer keys, duplicates and unknown inference absence", () => { + const raw = { + functions: [ + { selector: "aabbccdd", bytecodeOffset: 1, dispatch: "abi" }, + { + selector: "aabbccdd", + bytecodeOffset: 2, + dispatch: "fallback", + arguments: "uint256", + stateMutability: "view", + }, + ], + }; + const result = projectEvmoleInterface(raw); + expect(result.raw).toEqual(raw); + expect(result.functions).toEqual([ + { + selector: "0xaabbccdd", + bytecode_offset: 1, + dispatch: "abi", + inferred_arguments: null, + inferred_state_mutability: null, + }, + { + selector: "0xaabbccdd", + bytecode_offset: 2, + dispatch: "fallback", + inferred_arguments: "uint256", + inferred_state_mutability: "view", + }, + ]); + expect(projectEvmoleInterface({}).functions).toEqual([]); + const metadata = { + bytecodeOffset: 1, + cborLength: 4, + entries: [ + { key: "reported", value: { type: "integer", value: 9007199254740992 } }, + ], + }; + expect( + projectEvmoleInterface({ functions: [], metadata }).raw.metadata, + ).toEqual(metadata); + expect( + projectEvmoleInterface({ + functions: [], + storage: undefined, + transientStorage: undefined, + disassembled: undefined, + basicBlocks: undefined, + controlFlowGraph: undefined, + metadata: undefined, + }).functions, + ).toEqual([]); +}); +it.each([ + { functions: [{ selector: "bad", bytecodeOffset: 0, dispatch: "abi" }] }, + { + functions: [{ selector: "aabbccdd", bytecodeOffset: -1, dispatch: "abi" }], + }, + { metadata: {} }, +])("rejects producer representations outside the selected profile", (raw) => { + expect(() => projectEvmoleInterface(raw)).toThrow(); +}); diff --git a/src/evm/EvmoleInterfaceAdapter.ts b/src/evm/EvmoleInterfaceAdapter.ts new file mode 100644 index 0000000000..da62d28b9a --- /dev/null +++ b/src/evm/EvmoleInterfaceAdapter.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +const offset = z.number().int().nonnegative(); +const reportedMetadata = z.strictObject({ + bytecodeOffset: offset, + cborLength: offset, + entries: z.array( + z.strictObject({ + key: z.string(), + value: z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("string"), value: z.string() }), + z.strictObject({ type: z.literal("integer"), value: z.number() }), + z.strictObject({ type: z.literal("bytes"), value: z.string() }), + z.strictObject({ type: z.literal("bool"), value: z.boolean() }), + z.strictObject({ type: z.literal("undecoded"), value: z.string() }), + ]), + }), + ), +}); +/** Parse the exact producer representation before assigning portable field names. */ +export const evmoleInterfaceReplySchema = z.strictObject({ + // The unchanged JS binding exposes disabled facets as own undefined keys. + // Their JSON transport representation is absence; actual facet values are + // outside this interface-only profile and must still fail validation. + storage: z.undefined().optional(), + transientStorage: z.undefined().optional(), + disassembled: z.undefined().optional(), + basicBlocks: z.undefined().optional(), + controlFlowGraph: z.undefined().optional(), + // Upstream also extracts metadata when selectors are requested, regardless + // of its metadata flag. Retain its reported values without precision claims. + metadata: reportedMetadata.optional(), + functions: z + .array( + z.strictObject({ + selector: z.string().regex(/^[a-f0-9]{8}$/), + bytecodeOffset: offset, + dispatch: z.enum(["abi", "fallback"]), + arguments: z.string().optional(), + stateMutability: z.string().optional(), + }), + ) + .optional(), +}); + +/** Preserve inference strings and unknown absence; selectors use the conventional 0x display prefix. */ +export const projectEvmoleInterface = (raw: unknown) => { + const producer = evmoleInterfaceReplySchema.parse(raw); + return { + raw: producer, + functions: (producer.functions ?? []).map((fn) => ({ + selector: `0x${fn.selector}`, + bytecode_offset: fn.bytecodeOffset, + dispatch: fn.dispatch, + inferred_arguments: fn.arguments ?? null, + inferred_state_mutability: fn.stateMutability ?? null, + })), + }; +}; diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts new file mode 100644 index 0000000000..2316f44a75 --- /dev/null +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -0,0 +1,401 @@ +import { createHash, randomUUID } from "node:crypto"; +import { access, readFile, stat, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; +import { + createAnalysisExecution, + type AnalysisExecution, + type ExecutionOptions, +} from "../application/AnalysisProvider.js"; +import type { EvmInterfacePort } from "../application/evm/EvmInterfacePort.js"; +import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { OwnedCommandFailure } from "../process/OwnedCommand.js"; +import type { + AnalysisError, + AnalysisCapturedOutput, +} from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisUnsupportedTargetError, + AnalysisInputError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { InspectEvmInterfaceInput } from "../domain/evm/evmInterface.js"; +import { evmInterfaceSchema } from "../domain/evm/evmInterface.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { runOwnedCommand } from "../process/OwnedCommand.js"; +import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { + evmInterfaceFailure, + capturedEvmOutput, + type EvmFileSizeFailureEvidence, +} from "./EvmoleFailures.js"; +import { + evmoleInterfaceReplySchema, + projectEvmoleInterface, +} from "./EvmoleInterfaceAdapter.js"; +import { + EVMOLE_PROVIDER_IDENTITY, + EVM_INTERFACE_LIMITS, + EVM_FILE_SIZE_FAILURE_EXIT, +} from "./EvmoleRelease.js"; +import { decodeEvmBytecodeCarrier } from "./EvmBytecodeCarrier.js"; +import { + selectEvmWorkerLimits, + type EvmWorkerLimits, +} from "./EvmWorkerLimits.js"; + +const OPERATION = "inspect_evm_interface"; +const replySchema = z.discriminatedUnion("ok", [ + z.strictObject({ + ok: z.literal(true), + version: z.literal("0.9.3"), + bytecode: z.strictObject({ + sha256: z.string().regex(/^[a-f0-9]{64}$/), + bytes: z.number().int().nonnegative(), + hex: z.string().regex(/^(?:[a-f0-9]{2})*$/), + }), + raw: evmoleInterfaceReplySchema, + }), + z.strictObject({ + ok: z.literal(false), + reason: z.enum(["format", "decoder", "output-limit", "unsupported"]), + message: z.string(), + }), +]); +type Launcher = NonNullable[2]>["launcher"]; + +/** Run unchanged EVMole/WASM in an owned worker; no target, chain or network execution. */ +export class EvmoleInterfaceProvider implements EvmInterfacePort { + constructor( + readonly environment: Readonly = process.env, + readonly launcher?: Launcher, + readonly createRuntime: () => Promise< + Pick + > = () => PrivateRuntimeRoot.create({ prefix: "rea-evm-interface-" }), + ) {} + + /** Bind recovered interface candidates to both original carrier and decoded byte identity. */ + async inspect( + input: InspectEvmInterfaceInput, + options?: ExecutionOptions, + ): Promise> { + let root: Pick | undefined; + let retainedOutput: AnalysisCapturedOutput | undefined; + let phase: "configuration" | "artifact-read" | "worker" = "configuration"; + let workerLimits: EvmWorkerLimits | undefined; + const limiter = + this.environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; + let result: Result; + try { + if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); + if (process.platform !== "linux" || process.arch !== "x64") + throw new AnalysisCapabilityUnavailableError( + EVMOLE_PROVIDER_IDENTITY.id, + OPERATION, + "Initial real-verified offline EVM interface profile supports Linux x64 only.", + ); + try { + if (!isAbsolute(limiter)) + throw new Error( + "REA_EVM_PRLIMIT_COMMAND must be an absolute executable path.", + ); + await access(limiter, constants.X_OK); + if (!(await stat(limiter)).isFile()) + throw new Error( + "Configured prlimit must be a regular executable file, not a directory.", + ); + } catch (cause: unknown) { + const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${limiter}; ${cause instanceof Error ? cause.message : String(cause)}`; + throw new AnalysisCapabilityUnavailableError( + EVMOLE_PROVIDER_IDENTITY.id, + OPERATION, + reason, + { cause, userMessage: reason }, + ); + } + let inheritedLimits: string; + try { + inheritedLimits = await readFile("/proc/self/limits", "utf8"); + } catch (cause: unknown) { + const reason = `Could not read inherited Linux resource limits at /proc/self/limits: ${cause instanceof Error ? cause.message : String(cause)}`; + throw new AnalysisCapabilityUnavailableError( + EVMOLE_PROVIDER_IDENTITY.id, + OPERATION, + reason, + { cause, userMessage: reason }, + ); + } + const limits = selectEvmWorkerLimits(inheritedLimits); + if (!limits.ok) + throw new AnalysisCapabilityUnavailableError( + EVMOLE_PROVIDER_IDENTITY.id, + OPERATION, + limits.error, + { userMessage: limits.error }, + ); + workerLimits = limits.value; + phase = "artifact-read"; + const carrier = await readStableArtifact( + input.path, + EVM_INTERFACE_LIMITS.inputBytes, + options?.signal, + ); + phase = "worker"; + root = await this.createRuntime(); + const snapshotPath = join(root.path, "carrier.snapshot"); + const requestPath = join(root.path, "request.json"); + const replyPath = join(root.path, "reply.json"); + await writeFile(snapshotPath, carrier.bytes, { + flag: "wx", + mode: 0o600, + ...(options?.signal === undefined ? {} : { signal: options.signal }), + }); + await writeFile( + requestPath, + JSON.stringify({ + snapshot_path: snapshotPath, + reply_path: replyPath, + failure_marker_path: join(root.path, "resource.failure"), + encoding: input.encoding, + }), + { flag: "wx", mode: 0o600 }, + ); + const { NODE_OPTIONS: _ambientNodeOptions, ...environment } = + this.environment; + const execution = await runOwnedCommand( + { + command: limiter, + arguments: [ + `--as=${String(workerLimits.addressSpaceBytes)}:`, + `--fsize=${String(workerLimits.fileSizeBytes)}:`, + "--core=0:", + `--cpu=${String(workerLimits.cpuSeconds)}:`, + "--", + process.execPath, + "--max-old-space-size=256", + "--v8-pool-size=1", + "--disable-wasm-trap-handler", + fileURLToPath( + new URL("./EvmoleInterfaceWorker.js", import.meta.url), + ), + requestPath, + ], + cwd: root.path, + expectedCommand: null, + runId: `rea-evm-interface-${randomUUID()}`, + hostEnvironment: environment, + }, + { + timeoutMs: EVM_INTERFACE_LIMITS.timeoutMs, + diagnosticBytes: EVM_INTERFACE_LIMITS.diagnosticBytes, + }, + { + ...(options?.signal === undefined ? {} : { signal: options.signal }), + ...(this.launcher === undefined ? {} : { launcher: this.launcher }), + }, + ); + const capturedOutput = capturedEvmOutput(execution); + retainedOutput = capturedOutput; + let reply: z.output; + try { + const file = await readStableArtifact( + replyPath, + EVM_INTERFACE_LIMITS.outputBytes, + options?.signal, + ); + reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); + } catch (cause: unknown) { + if (options?.signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { capturedOutput }); + throw new AnalysisOutputError( + OPERATION, + `Owned interface worker reply failed for ${input.path}: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause, capturedOutput }, + ); + } + if (!reply.ok) { + if (reply.reason === "unsupported") + throw new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + reply.message, + { capturedOutput }, + ); + if (reply.reason === "format") + throw new AnalysisInputError(OPERATION, { capturedOutput }, [ + { + path: ["path"], + reason: "invalid_format", + message: reply.message, + }, + ]); + if (reply.reason === "output-limit") + throw new AnalysisOutputError(OPERATION, reply.message, { + capturedOutput, + }); + throw new ProviderAdapterError(EVMOLE_PROVIDER_IDENTITY.id, OPERATION, { + diagnostics: { + phase, + failure_kind: reply.reason, + reason: reply.message, + stdout: execution.stdout.text, + stderr: execution.stderr.text, + captured_output: { ...capturedOutput }, + }, + }); + } + const selectedBytes = decodeEvmBytecodeCarrier( + carrier.bytes, + input.encoding, + ); + const selectedHex = Buffer.from(selectedBytes).toString("hex"); + const decodedHash = createHash("sha256") + .update(selectedBytes) + .digest("hex"); + if ( + decodedHash !== reply.bytecode.sha256 || + selectedHex !== reply.bytecode.hex || + selectedBytes.length !== reply.bytecode.bytes + ) + throw new AnalysisOutputError( + OPERATION, + "Decoded bytecode representation does not match the selected carrier, encoding or reported SHA-256.", + { capturedOutput }, + ); + const limitations = [ + `Configured worker resource soft limits: address_space_bytes=${String(workerLimits.addressSpaceBytes)}, cpu_seconds=${String(workerLimits.cpuSeconds)}, file_size_bytes=${String(workerLimits.fileSizeBytes)}. Selected from inherited tighter limits; original hard limits are retained. Effective worker limits are not independently probed.`, + "Upstream may also report terminal metadata while recovering selectors. Its reported values are retained in raw_result, but integer precision and non-text-key coverage are unknown in the JS binding; complete original bytecode remains available.", + "Recovered selectors, argument strings and mutability are upstream inferences, not verified ABI signatures or names. Empty results do not prove the absence of callable dispatch.", + "Bytecode kind, hardfork and deployed-contract authenticity are unknown. This inspection performs no target runtime execution, chain/RPC lookup or network request.", + "Original carrier and decoded bytecode SHA-256 are distinct identities; SHA-256 is not the Ethereum Keccak code hash.", + "The owned worker has a 256 MiB JavaScript heap, 3 GiB virtual address-space limit and 30-second deadline. Virtual address space is distinct from resident memory; limits fail without partial success.", + ]; + const validated = evmInterfaceSchema.safeParse({ + artifact: { + path: input.path, + sha256: carrier.sha256, + bytes: carrier.bytes.length, + encoding: input.encoding, + }, + bytecode: { + ...reply.bytecode, + digest_algorithm: "sha256", + kind: "unknown", + deployment_authenticity: "unknown", + hardfork: "unknown", + }, + evidence_kind: "inferred", + functions: projectEvmoleInterface(reply.raw).functions, + discovery_completeness: "unknown", + runtime_execution: "not-performed", + diagnostics: { + stdout: execution.stdout.text, + stderr: execution.stderr.text, + truncated: capturedOutput.truncated, + }, + limitations, + }); + if (!validated.success) + throw new AnalysisOutputError( + OPERATION, + `Interface worker returned invalid bytecode-relative evidence: ${validated.error.issues[0]?.message ?? "schema mismatch"}`, + { capturedOutput }, + ); + const report = validated.data; + result = ok( + createAnalysisExecution(report, EVMOLE_PROVIDER_IDENTITY, { + rawResult: reply.raw, + subject: { path: input.path, format: "file", sha256: carrier.sha256 }, + locations: [{ kind: "artifact-path", path: input.path }], + limitations, + }), + ); + } catch (cause: unknown) { + let fileFailure: EvmFileSizeFailureEvidence | undefined; + if ( + cause instanceof OwnedCommandFailure && + cause.snapshot?.exitCode === EVM_FILE_SIZE_FAILURE_EXIT && + cause.snapshot.signal === null && + root !== undefined + ) { + try { + const marker = await readStableArtifact( + join(root.path, "resource.failure"), + 1, + ); + if (!marker.bytes.equals(Buffer.from("F"))) + throw new Error( + "Private worker failure marker does not match the observed exit status.", + ); + fileFailure = { verified: true, failure: null }; + } catch (markerFailure: unknown) { + fileFailure = { + verified: false, + failure: + markerFailure instanceof Error + ? markerFailure.message + : String(markerFailure), + }; + } + } + result = err( + options?.signal?.aborted && + (cause === options.signal.reason || + (cause instanceof Error && cause.name === "AbortError")) + ? new AnalysisCancelledError( + OPERATION, + retainedOutput === undefined + ? undefined + : { capturedOutput: retainedOutput }, + ) + : evmInterfaceFailure( + cause, + phase, + input.path, + workerLimits, + limiter, + fileFailure, + ), + ); + } + if (root !== undefined) { + try { + await root.close(); + } catch (cause: unknown) { + return err( + new ProviderCleanupError( + EVMOLE_PROVIDER_IDENTITY.id, + [root.path], + { + reason: cause instanceof Error ? cause.message : String(cause), + previous_error: result.ok + ? null + : projectAnalysisError(result.error), + ...(retainedOutput === undefined + ? {} + : { captured_output: { ...retainedOutput } }), + }, + { operation: OPERATION }, + ), + ); + } + } + return result.ok && options?.signal?.aborted + ? err( + new AnalysisCancelledError( + OPERATION, + retainedOutput === undefined + ? undefined + : { capturedOutput: retainedOutput }, + ), + ) + : result; + } +} diff --git a/src/evm/EvmoleInterfaceWorker.ts b/src/evm/EvmoleInterfaceWorker.ts new file mode 100644 index 0000000000..061ceb47e7 --- /dev/null +++ b/src/evm/EvmoleInterfaceWorker.ts @@ -0,0 +1,108 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { z } from "zod"; +import { + decodeEvmBytecodeCarrier, + EvmCarrierFailure, +} from "./EvmBytecodeCarrier.js"; +import { projectEvmoleInterface } from "./EvmoleInterfaceAdapter.js"; +import { + EVM_INTERFACE_LIMITS, + EVM_FILE_SIZE_FAILURE_EXIT, +} from "./EvmoleRelease.js"; + +const requestSchema = z.strictObject({ + snapshot_path: z.string(), + reply_path: z.string(), + failure_marker_path: z.string(), + encoding: z.enum(["raw", "hex"]), +}); +class UnsupportedContainer extends Error {} + +const main = async (): Promise => { + const requestPath = process.argv[2]; + if (requestPath === undefined) + throw new Error("Owned worker request path missing."); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + let reply: unknown; + try { + const bytes = decodeEvmBytecodeCarrier( + await readFile(request.snapshot_path), + request.encoding, + ); + if (bytes[0] === 0xef && bytes[1] === 0x00) + throw new UnsupportedContainer( + "Container marker EF00 is outside the verified flat-bytecode interface profile.", + ); + const modulePath = import.meta.resolve("evmole/no_tla"); + const metadata = z + .object({ version: z.literal("0.9.3") }) + .parse( + JSON.parse( + await readFile(new URL("../package.json", modulePath), "utf8"), + ), + ); + const engine = await import("evmole/no_tla"); + engine.initSync({ + module: await readFile( + new URL(import.meta.resolve("evmole/evmole_bg.wasm")), + ), + }); + const inferred = projectEvmoleInterface( + engine.contractInfo(Buffer.from(bytes).toString("hex"), { + selectors: true, + arguments: true, + stateMutability: true, + }), + ); + reply = { + ok: true, + version: metadata.version, + bytecode: { + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + hex: Buffer.from(bytes).toString("hex"), + }, + raw: inferred.raw, + }; + } catch (cause: unknown) { + reply = { + ok: false, + reason: + cause instanceof EvmCarrierFailure + ? "format" + : cause instanceof UnsupportedContainer + ? "unsupported" + : "decoder", + message: cause instanceof Error ? cause.message : String(cause), + }; + } + let encoded = JSON.stringify(reply); + if (Buffer.byteLength(encoded) > EVM_INTERFACE_LIMITS.outputBytes) + encoded = JSON.stringify({ + ok: false, + reason: "output-limit", + message: "Complete interface evidence exceeds the 16 MiB reply budget.", + }); + try { + await writeFile(request.reply_path, encoded, { flag: "wx", mode: 0o600 }); + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "EFBIG") { + try { + await writeFile(request.failure_marker_path, "F", { + flag: "wx", + mode: 0o600, + }); + } catch (markerFailure: unknown) { + process.stderr.write( + `File-size failure marker unavailable: ${markerFailure instanceof Error ? markerFailure.message : String(markerFailure)}\n`, + ); + } + process.exitCode = EVM_FILE_SIZE_FAILURE_EXIT; + } else throw cause; + } +}; + +await main(); diff --git a/src/evm/EvmoleRelease.ts b/src/evm/EvmoleRelease.ts new file mode 100644 index 0000000000..8e8ea10fc2 --- /dev/null +++ b/src/evm/EvmoleRelease.ts @@ -0,0 +1,16 @@ +/** Unchanged locked npm/WASM profile; provenance is independent of chain deployment authenticity. */ +export const EVMOLE_PROVIDER_IDENTITY = { + id: "evmole-interface", + name: "REA EVM interface adapter", + version: "evmole@0.9.3", +} as const; +/** Worker-reserved status for an observed EFBIG write without a complete reply. */ +export const EVM_FILE_SIZE_FAILURE_EXIT = 76; +/** Complete offline inspection budgets; worker JavaScript heap is bounded separately. */ +export const EVM_INTERFACE_LIMITS = { + inputBytes: 4 * 1024 * 1024, + outputBytes: 16 * 1024 * 1024, + timeoutMs: 30_000, + diagnosticBytes: 1024 * 1024, + addressSpaceBytes: 3 * 1024 * 1024 * 1024, +} as const; diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index fc03cee9fa..d56f777c43 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -5,5 +5,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "31", + skillVersion: "32", } as const; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 1a69fecac9..e0291e09f5 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -1,3 +1,6 @@ +import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; +import { createEvmInterfaceService } from "../composition/evm.js"; +import { registerEvmTools } from "./registerEvmTools.js"; import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js"; import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js"; import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; @@ -62,6 +65,7 @@ const ACTIVE_TARGET_INSTRUCTIONS = "REA analyzes the active reverse-engineering target. Use the analysis tool that answers the question directly. Search or list symbols when discovery is needed; analyze_function provides a function dossier, and focused procedure tools return individual facets."; export interface CreateServerOptions { + readonly evmInterface?: EvmInterfaceService; readonly logger?: Logger; readonly binaryLayout?: BinaryLayoutService; readonly firmwareAnalysis?: FirmwareAnalysisPort; @@ -89,6 +93,9 @@ const installSessionToolAvailability = ( const policy = sessionAvailabilityPolicy(options.availabilityPolicy, { optionalProviderLoadFailures: options.optionalProviderLoadFailures, optionalFeatures: { + evmInterfaceEnabled: + options.evmInterface !== undefined || + (process.platform === "linux" && process.arch === "x64"), webModuleResolutionEnabled: options.webModuleTrace !== undefined || isAbsolute(process.env.REA_BROWSER_EXECUTABLE ?? ""), @@ -205,6 +212,12 @@ export const createServer = ( toolLogger, recordEvidence, ); + registerEvmTools( + server, + options.evmInterface ?? createEvmInterfaceService(), + toolLogger, + recordEvidence, + ); registerFirmwareTools( server, new FirmwareAnalysisService( diff --git a/src/server/registerEvmTools.ts b/src/server/registerEvmTools.ts new file mode 100644 index 0000000000..09af2dc26c --- /dev/null +++ b/src/server/registerEvmTools.ts @@ -0,0 +1,32 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "../logger.js"; +import { logToolExecution } from "./toolLogging.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { toCallToolResult } from "./toolResult.js"; + +/** Bind the named EVM contract to shared validation and session Evidence recording. */ +export const registerEvmTools = ( + server: McpServer, + service: EvmInterfaceService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], +): void => { + const contract = toolContract("inspect_evm_interface"); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input, context) => { + const result = await logToolExecution(logger, contract.name, () => + service.inspect(input, { signal: context.mcpReq.signal }), + ); + if (!result.ok) return toCallToolResult(result, contract); + const recorded = recordEvidence?.(result.value); + return recorded !== undefined && !recorded.ok + ? toCallToolResult(recorded, contract) + : toCallToolResult(result, contract); + }, + ); +}; diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts index 0303828e5e..7339296478 100644 --- a/src/server/sessionAvailabilityPolicy.ts +++ b/src/server/sessionAvailabilityPolicy.ts @@ -10,6 +10,7 @@ export interface SessionAvailabilityDefaults { | undefined; readonly optionalFeatures?: Pick< SessionAvailability, + | "evmInterfaceEnabled" | "browserObservationEnabled" | "browserScenarioEnabled" | "electronObservationEnabled" @@ -32,6 +33,8 @@ export const sessionAvailabilityPolicy = ( const policy = configured ?? (() => ({ + evmInterfaceEnabled: + defaults.optionalFeatures?.evmInterfaceEnabled ?? false, processCaptureEnabled: platform !== "win32", binaryLayoutEnabled: defaults.optionalFeatures?.binaryLayoutEnabled ?? false, diff --git a/tests/boundary/cli/dylibResolutionCli.test.ts b/tests/boundary/cli/dylibResolutionCli.test.ts new file mode 100644 index 0000000000..1911d85517 --- /dev/null +++ b/tests/boundary/cli/dylibResolutionCli.test.ts @@ -0,0 +1,244 @@ +import { chmod, mkdir, symlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { describe, expect } from "vitest"; + +import { + FILE_TYPE, + LC, + dylibCommand, + machoImage, + rpathCommand, +} from "../../../src/artifacts/apple/MachoImage.fixture.js"; +import { dylibResolutionResultSchema } from "../../../src/domain/apple/dylibResolution.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const ENVIRONMENT = { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }; + +const writeFiles = async ( + root: string, + files: Readonly>, +): Promise => { + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content); + } +}; + +const fixtureApp = async (): Promise => { + const app = join( + await createTestTempDirectory("rea-dylib-cli-"), + "Fixture.app", + ); + await writeFiles(app, { + "Contents/Info.plist": + "CFBundleExecutableApp", + "Contents/MacOS/App": machoImage({ + commands: [ + rpathCommand("@executable_path/../Frameworks"), + dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"), + dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"), + ], + }), + "Contents/Frameworks/libcore.dylib": machoImage({ + fileType: FILE_TYPE.dylib, + commands: [dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib")], + }), + "Contents/Helpers/tool": machoImage({ + commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib")], + }), + }); + return app; +}; + +const withBrokenHelper = async (app: string): Promise => { + const broken = machoImage({ commands: [rpathCommand("x")] }); + // cmdsize 0 makes the command table unreadable. + new DataView(broken.buffer).setUint32(32 + 4, 0, true); + await writeFiles(app, { "Contents/Helpers/broken": broken }); +}; + +describe("trace-dylib-resolution CLI", () => { + cliTest("traces every executable root of an app bundle", async ({ cli }) => { + const app = await fixtureApp(); + const result = await cli.run({ + arguments: ["trace-dylib-resolution", app, "--json"], + environment: ENVIRONMENT, + }); + expect(result.exitCode, JSON.stringify(result.json)).toBe(0); + const trace = dylibResolutionResultSchema.parse( + (result.json as { normalized_result: unknown }).normalized_result, + ); + expect(trace.root_path).toBe(app); + expect(trace.roots.map(({ image }) => image)).toEqual([ + "Contents/Helpers/tool", + "Contents/MacOS/App", + ]); + expect( + trace.edges.map(({ loader, install_name: name, resolution }) => [ + loader, + name, + resolution.status, + resolution.image, + ]), + ).toEqual([ + [ + "Contents/Helpers/tool", + "@loader_path/libnone.dylib", + "unresolved", + null, + ], + [ + "Contents/MacOS/App", + "@rpath/libcore.dylib", + "resolved", + "Contents/Frameworks/libcore.dylib", + ], + ["Contents/MacOS/App", "@rpath/libgone.dylib", "unresolved", null], + ]); + expect(trace.findings.map(({ kind }) => kind)).toEqual([ + "required-load-unresolved", + "weak-load-unresolved", + ]); + expect( + trace.images.every(({ sha256 }) => /^[0-9a-f]{64}$/u.test(sha256)), + ).toBe(true); + }); + + cliTest( + "narrows roots and architecture, and rejects escaping roots", + async ({ cli }) => { + const app = await fixtureApp(); + const narrowed = await cli.run({ + arguments: [ + "trace-dylib-resolution", + app, + "--root", + "Contents/MacOS/App", + "--architecture", + "arm64", + "--json", + ], + environment: ENVIRONMENT, + }); + expect(narrowed.exitCode).toBe(0); + expect( + (narrowed.json as { normalized_result: { roots: unknown } }) + .normalized_result.roots, + ).toEqual([{ image: "Contents/MacOS/App", architecture: "arm64" }]); + // A root below a symlinked directory that points outside the bundle. + const outside = await createTestTempDirectory("rea-dylib-outside-"); + await writeFiles(outside, { Tool: machoImage({}) }); + await symlink(outside, join(app, "Contents/Linked")); + for (const root of [ + "../outside", + "Contents/Info.plist", + "Contents/Linked/Tool", + ]) { + const rejected = await cli.run({ + arguments: ["trace-dylib-resolution", app, "--root", root, "--json"], + environment: ENVIRONMENT, + }); + expect(rejected.exitCode).toBe(1); + expect(rejected.json).toMatchObject({ error: "Analysis failed" }); + } + }, + ); + + cliTest( + "resolves a standalone Mach-O against its own directory", + async ({ cli }) => { + const directory = await createTestTempDirectory("rea-dylib-standalone-"); + await writeFiles(directory, { + tool: machoImage({ + commands: [ + dylibCommand(LC.LOAD_DYLIB, "@loader_path/lib/libhelper.dylib"), + dylibCommand(LC.LOAD_DYLIB, "@loader_path/../escape.dylib"), + ], + }), + "lib/libhelper.dylib": machoImage({ fileType: FILE_TYPE.dylib }), + "unrelated.txt": "not traversed", + }); + const result = await cli.run({ + arguments: [ + "trace-dylib-resolution", + join(directory, "tool"), + "--json", + ], + environment: ENVIRONMENT, + }); + expect(result.exitCode, JSON.stringify(result.json)).toBe(0); + const trace = dylibResolutionResultSchema.parse( + (result.json as { normalized_result: unknown }).normalized_result, + ); + expect(trace.roots).toEqual([{ image: "tool", architecture: "arm64" }]); + expect(trace.edges.map(({ resolution }) => resolution)).toEqual([ + { status: "resolved", image: "lib/libhelper.dylib" }, + { status: "undetermined", image: null }, + ]); + }, + ); + + cliTest( + "reports unparsed Mach-O files found while enumerating roots", + async ({ cli }) => { + const app = await fixtureApp(); + await withBrokenHelper(app); + const result = await cli.run({ + arguments: ["trace-dylib-resolution", app, "--json"], + environment: ENVIRONMENT, + }); + expect(result.exitCode, JSON.stringify(result.json)).toBe(0); + const trace = dylibResolutionResultSchema.parse( + (result.json as { normalized_result: unknown }).normalized_result, + ); + expect(trace.coverage).toMatchObject({ + status: "partial", + unparsed_images: ["Contents/Helpers/broken"], + }); + }, + ); +}); + +describe.skipIf(process.getuid?.() === 0)( + "trace-dylib-resolution permissions", + () => { + cliTest( + "reports an unreadable dependency as a permission failure", + async ({ cli }) => { + const directory = await createTestTempDirectory("rea-dylib-denied-"); + await writeFiles(directory, { + tool: machoImage({ + commands: [ + dylibCommand(LC.LOAD_DYLIB, "@loader_path/libsecret.dylib"), + ], + }), + "libsecret.dylib": machoImage({ fileType: FILE_TYPE.dylib }), + }); + await chmod(join(directory, "libsecret.dylib"), 0o000); + try { + const result = await cli.run({ + arguments: [ + "trace-dylib-resolution", + join(directory, "tool"), + "--json", + ], + environment: ENVIRONMENT, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + details: { + reason: "unavailable", + detail: expect.stringContaining( + "Permission denied (EACCES) reading libsecret.dylib", + ), + }, + }); + } finally { + await chmod(join(directory, "libsecret.dylib"), 0o644); + } + }, + ); + }, +); diff --git a/tests/boundary/cli/productCatalog.test.ts b/tests/boundary/cli/productCatalog.test.ts index c804c93e27..3eb9f6528e 100644 --- a/tests/boundary/cli/productCatalog.test.ts +++ b/tests/boundary/cli/productCatalog.test.ts @@ -1,3 +1,4 @@ +import { EVMOLE_PROVIDER_IDENTITY } from "../../../src/evm/EvmoleRelease.js"; import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -117,6 +118,7 @@ describe("canonical product catalog", () => { IDA_PROVIDER_IDENTITY, NATIVE_MACOS_PROVIDER_IDENTITY, PWNTOOLS_PROVIDER_IDENTITY, + EVMOLE_PROVIDER_IDENTITY, ARTIFACT_GRAPH_PROVIDER, ANDROID_APPLICATION_PROVIDER, APPLE_APPLICATION_PROVIDER, diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts new file mode 100644 index 0000000000..dd0e0fd8ef --- /dev/null +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -0,0 +1,597 @@ +import { createHash } from "node:crypto"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { evmInterfaceSchema } from "../../../src/domain/evm/evmInterface.js"; +import { EvmoleInterfaceProvider } from "../../../src/evm/EvmoleInterfaceProvider.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +const unsupportedHost = process.platform !== "linux" || process.arch !== "x64"; +const requestSchema = z.strictObject({ + snapshot_path: z.string(), + reply_path: z.string(), + failure_marker_path: z.string(), + encoding: z.enum(["raw", "hex"]), +}); +const fixture = async () => { + const workspace = await createTestWorkspace("rea-evm-interface-boundary-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const path = join(workspace.root, "selected.hex"); + await writeFile(path, "0x60006000f3\n"); + return { path, root: workspace.root }; +}; + +it.runIf(!unsupportedHost).each(["SIGXCPU", "SIGXFSZ", "EFBIG"])( + "preserves %s with configured limits distinct from unknown effective limits", + async (failure) => { + const { path } = await fixture(); + let ownedPath = ""; + const provider = new EvmoleInterfaceProvider({}, async (spawn) => { + ownedPath = spawn.cwd ?? ""; + if (failure === "EFBIG") + await writeFile(join(ownedPath, "resource.failure"), "F"); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: [ + "-e", + failure === "EFBIG" + ? 'process.stdout.write("resource diagnostic", () => { process.exitCode = 76; })' + : failure === "SIGXFSZ" + ? 'process.on("SIGXFSZ", () => { process.removeAllListeners("SIGXFSZ"); process.kill(process.pid, "SIGXFSZ"); }); process.stdout.write("resource diagnostic", () => process.kill(process.pid, "SIGXFSZ")); setTimeout(() => {}, 1000);' + : `process.stdout.write("resource diagnostic", () => process.kill(process.pid, "${failure}"))`, + ], + }); + }); + const result = await provider.inspect({ path, encoding: "hex" }); + if (result.ok) throw new Error("Expected observed resource failure"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "resource_constraint", + details: { + resource: failure === "SIGXCPU" ? "cpu" : "file-size", + reported_limits: { + configured_soft_limits: { cpu_seconds: expect.any(Number) }, + effective_soft_limits: null, + }, + captured_output: { stdout: "resource diagnostic", truncated: false }, + }, + }); + expect(result.error.message).toContain( + failure === "EFBIG" ? "exact write failure cause" : "exact signal cause", + ); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost).each(["missing", "wrong"])( + "does not classify a launcher exit 76 with %s worker failure evidence", + async (scenario) => { + const { path } = await fixture(); + let ownedPath = ""; + const provider = new EvmoleInterfaceProvider({}, async (spawn) => { + ownedPath = spawn.cwd ?? ""; + if (scenario === "wrong") + await writeFile( + join(ownedPath, "resource.failure"), + Buffer.from([0xc6]), + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: [ + "-e", + 'process.stdout.write("launcher failure", () => { process.exitCode = 76; });', + ], + }); + }); + const result = await provider.inspect({ path, encoding: "hex" }); + if (result.ok) throw new Error("Expected launcher failure"); + expect(projectAnalysisError(result.error).code).toBe("execution_failure"); + expect(result.error).toMatchObject({ + diagnostics: { + file_size_failure_marker: { + verified: false, + failure: expect.any(String), + }, + captured_output: { stdout: "launcher failure", truncated: false }, + }, + }); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost)( + "retains actual diagnostic truncation when overflow and process cleanup both fail", + async () => { + const { path } = await fixture(); + let ownedPath = ""; + const provider = new EvmoleInterfaceProvider({}, async (spawn) => { + ownedPath = spawn.cwd ?? ""; + const launched = await spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: ["-e", 'process.stdout.write("x".repeat(1048577))'], + }); + return { + ...launched, + cleanup: async () => { + await launched.cleanup?.(); + throw new Error("injected post-cleanup reporting failure"); + }, + }; + }); + const result = await provider.inspect({ path, encoding: "hex" }); + if (result.ok) throw new Error("Expected compounded lifecycle failure"); + expect(result.error).toMatchObject({ + cleanupIncomplete: true, + diagnostics: { + reason: "injected post-cleanup reporting failure", + previous_error: { failure_kind: "output-limit" }, + captured_output: { truncated: true, stderr: "" }, + }, + }); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost).each([1048576, 1048577])( + "keeps complete diagnostics or rejects a valid worker reply with %s diagnostic bytes", + async (size) => { + const { path } = await fixture(); + let ownedPath = ""; + const provider = new EvmoleInterfaceProvider({}, async (spawn) => { + ownedPath = spawn.cwd ?? ""; + const requestPath = spawn.arguments.at(-1); + if (requestPath === undefined) throw new Error("Request missing"); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + const bytes = Buffer.from("60006000f3", "hex"); + await writeFile( + request.reply_path, + JSON.stringify({ + ok: true, + version: "0.9.3", + bytecode: { + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + hex: bytes.toString("hex"), + }, + raw: { functions: [] }, + }), + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: ["-e", `process.stderr.write("x".repeat(${String(size)}))`], + }); + }); + const result = await provider.inspect({ path, encoding: "hex" }); + if (size === 1048576) { + if (!result.ok) throw result.error; + const report = evmInterfaceSchema.parse(result.value.result); + expect(report.diagnostics.stderr).toHaveLength(size); + expect(report.diagnostics.truncated).toBe(false); + } else { + if (result.ok) + throw new Error("Expected explicit diagnostic budget failure"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unreadable_output", + details: { captured_output: { truncated: true } }, + }); + } + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it + .runIf(!unsupportedHost) + .each([ + "success", + "format", + "unsupported", + "wrong-version", + "wrong-decoded-carrier", + "broken-reply", + "out-of-range-body", + ])( + "binds source carrier/worker representation and releases owned files: %s", + async (scenario) => { + const { path } = await fixture(); + let ownedRoot = ""; + const provider = new EvmoleInterfaceProvider( + { NODE_OPTIONS: "--inspect=0" }, + async (spawn) => { + ownedRoot = spawn.cwd ?? ""; + expect(spawn.hostEnvironment?.NODE_OPTIONS).toBeUndefined(); + expect(spawn.arguments).toContain("--as=3221225472:"); + expect(spawn.arguments).toContain("--disable-wasm-trap-handler"); + const requestPath = spawn.arguments.at(-1); + if (requestPath === undefined) throw new Error("request missing"); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + expect(request.encoding).toBe("hex"); + expect(await readFile(request.snapshot_path, "utf8")).toBe( + "0x60006000f3\n", + ); + const hex = scenario === "wrong-decoded-carrier" ? "00" : "60006000f3"; + const bytes = Buffer.from(hex, "hex"); + await writeFile( + request.reply_path, + scenario === "broken-reply" + ? "broken" + : JSON.stringify( + ["format", "unsupported"].includes(scenario) + ? { + ok: false, + reason: scenario, + message: "Malformed hex carrier.", + } + : { + ok: true, + version: scenario === "wrong-version" ? "0.0.0" : "0.9.3", + bytecode: { + sha256: createHash("sha256") + .update(bytes) + .digest("hex"), + bytes: bytes.length, + hex, + }, + raw: { + functions: + scenario === "out-of-range-body" + ? [ + { + selector: "aabbccdd", + bytecodeOffset: 5, + dispatch: "abi", + }, + ] + : [], + }, + }, + ), + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: ["-e", 'process.stderr.write("upstream warning")'], + }); + }, + ); + const result = await provider.inspect({ path, encoding: "hex" }); + if (scenario === "success") { + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + artifact: { path, encoding: "hex", bytes: 13 }, + bytecode: { hex: "60006000f3", bytes: 5 }, + diagnostics: { stderr: "upstream warning" }, + }); + expect(result.value.rawResult).toEqual({ functions: [] }); + } else + expect(result).toMatchObject({ + ok: false, + error: { + _tag: + scenario === "format" + ? "AnalysisInputError" + : scenario === "unsupported" + ? "AnalysisUnsupportedTargetError" + : "AnalysisOutputError", + }, + }); + if (!result.ok) + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { + captured_output: { + stdout: "", + stderr: "upstream warning", + truncated: false, + }, + }, + }); + expect(await readFile(path, "utf8")).toBe("0x60006000f3\n"); + await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost).each(["cancelled", "output-limit", "process"])( + "preserves lifecycle failure and independently releases an acquired worker: %s", + async (scenario) => { + const { path } = await fixture(); + const controller = new AbortController(); + let pid: number | undefined; + let ownedRoot = ""; + const provider = new EvmoleInterfaceProvider({}, async (spawn) => { + ownedRoot = spawn.cwd ?? ""; + const launched = await spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: [ + "-e", + scenario === "cancelled" + ? 'process.stdout.write("ready\\n");setInterval(()=>undefined,1000)' + : scenario === "output-limit" + ? 'process.stdout.write("x".repeat(1048577))' + : 'process.stderr.write("worker failed");process.exit(7)', + ], + }); + pid = launched.process.pid; + if (scenario === "cancelled") { + await waitForProviderProcessReady(launched.process); + setImmediate(() => controller.abort()); + } + return launched; + }); + expect( + await provider.inspect( + { path, encoding: "hex" }, + { signal: controller.signal }, + ), + ).toMatchObject({ + ok: false, + error: { + _tag: + scenario === "cancelled" + ? "AnalysisCancelledError" + : scenario === "output-limit" + ? "AnalysisOutputError" + : "ProviderAdapterError", + }, + }); + if (pid === undefined) throw new Error("worker not acquired"); + const acquiredPid = pid; + expect(() => process.kill(acquiredPid, 0)).toThrow(); + await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost)( + "distinguishes unavailable limiter, absent carrier and prelaunch cancellation", + async () => { + const { path } = await fixture(); + expect( + await new EvmoleInterfaceProvider({ + REA_EVM_PRLIMIT_COMMAND: path + ".absent", + }).inspect({ path, encoding: "hex" }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + expect( + await new EvmoleInterfaceProvider({}).inspect({ + path: path + ".absent", + encoding: "raw", + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + const controller = new AbortController(); + controller.abort(); + expect( + await new EvmoleInterfaceProvider({}).inspect( + { path, encoding: "raw" }, + { signal: controller.signal }, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + }, +); +it.runIf(unsupportedHost)( + "reports unverified host coverage before acquiring a provider", + async () => { + expect( + await new EvmoleInterfaceProvider({}).inspect({ + path: "/selected.hex", + encoding: "hex", + }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + }, +); + +it.runIf(!unsupportedHost)( + "preserves actual snapshot-write cancellation and root cleanup", + async () => { + const { path } = await fixture(); + const controller = new AbortController(); + let ownedRoot = ""; + const provider = new EvmoleInterfaceProvider( + {}, + () => { + throw new Error("must not launch worker"); + }, + async () => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-evm-cancel-", + }); + ownedRoot = root.path; + controller.abort(); + return root; + }, + ); + expect( + await provider.inspect( + { path, encoding: "hex" }, + { signal: controller.signal }, + ), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost)( + "reports private storage denial without claiming selected carrier read denial", + async () => { + const { path } = await fixture(); + const provider = new EvmoleInterfaceProvider({}, undefined, () => + Promise.reject( + Object.assign(new Error("private root denied"), { code: "EACCES" }), + ), + ); + expect(await provider.inspect({ path, encoding: "hex" })).toMatchObject({ + ok: false, + error: { + _tag: "ProviderAdapterError", + diagnostics: { phase: "worker", reason: "private root denied" }, + }, + }); + }, +); + +it.runIf(!unsupportedHost)( + "retains original format failure when owned-root cleanup fails", + async () => { + const { path, root } = await fixture(); + const provider = new EvmoleInterfaceProvider( + {}, + async (spawn) => { + const requestPath = spawn.arguments.at(-1); + if (requestPath === undefined) throw new Error("request missing"); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + await writeFile( + request.reply_path, + JSON.stringify({ + ok: false, + reason: "format", + message: "Odd selected hex.", + }), + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: ["-e", "process.exit(0)"], + }); + }, + () => + Promise.resolve({ + path: root, + close: () => Promise.reject(new Error("root cleanup failed")), + }), + ); + expect(await provider.inspect({ path, encoding: "hex" })).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cleanupResources: [root], + diagnostics: { + reason: "root cleanup failed", + previous_error: { category: "invalid_input" }, + }, + }, + }); + }, +); + +it.runIf(!unsupportedHost).each(["late-cancellation", "cleanup-failure"])( + "retains captured warnings after a successful worker: %s", + async (scenario) => { + const { path } = await fixture(); + const controller = new AbortController(); + let ownedRoot = ""; + const provider = new EvmoleInterfaceProvider( + {}, + async (spawn) => { + const requestPath = spawn.arguments.at(-1); + if (requestPath === undefined) throw new Error("request missing"); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + const bytes = Buffer.from("60006000f3", "hex"); + await writeFile( + request.reply_path, + JSON.stringify({ + ok: true, + version: "0.9.3", + bytecode: { + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + hex: bytes.toString("hex"), + }, + raw: { functions: [] }, + }), + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: ["-e", 'process.stderr.write("upstream warning")'], + }); + }, + async () => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-evm-late-", + }); + ownedRoot = root.path; + return { + path: root.path, + close: async () => { + await root.close(); + if (scenario === "late-cancellation") controller.abort(); + else throw new Error("reported cleanup failure"); + }, + }; + }, + ); + const result = await provider.inspect( + { path, encoding: "hex" }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected lifecycle failure"); + const output = { stdout: "", stderr: "upstream warning", truncated: false }; + expect(projectAnalysisError(result.error)).toMatchObject({ + category: + scenario === "late-cancellation" ? "cancelled" : "execution_failure", + details: + scenario === "late-cancellation" + ? { captured_output: output } + : { diagnostics: { captured_output: output } }, + }); + await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])( + "identifies configured limiter constraints without blaming the carrier: %s", + async (scenario) => { + const { path, root } = await fixture(); + const limiter = + scenario === "directory" ? root : join(root, "broken-limiter"); + if (scenario === "missing-interpreter") + await writeFile(limiter, "#!/rea-missing-limiter-interpreter\n", { + mode: 0o700, + }); + let ownedRoot: string | undefined; + const provider = new EvmoleInterfaceProvider( + { REA_EVM_PRLIMIT_COMMAND: limiter }, + undefined, + async () => { + const runtime = await PrivateRuntimeRoot.create({ + prefix: "rea-evm-limiter-", + }); + ownedRoot = runtime.path; + return runtime; + }, + ); + const result = await provider.inspect({ path, encoding: "hex" }); + if (result.ok) throw new Error("Expected unavailable limiter"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "capability_unavailable", + category: "unsupported_provider", + details: { provider_id: "evmole-interface" }, + }); + expect(result.error.message).toContain(limiter); + if (ownedRoot !== undefined) + await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path, "utf8")).toBe("0x60006000f3\n"); + }, +); diff --git a/tests/boundary/mcp/dylibResolutionMcp.test.ts b/tests/boundary/mcp/dylibResolutionMcp.test.ts new file mode 100644 index 0000000000..179912a182 --- /dev/null +++ b/tests/boundary/mcp/dylibResolutionMcp.test.ts @@ -0,0 +1,195 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { + FILE_TYPE, + LC, + dylibCommand, + machoImage, + rpathCommand, +} from "../../../src/artifacts/apple/MachoImage.fixture.js"; +import { dylibResolutionResultSchema } from "../../../src/domain/apple/dylibResolution.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const withClient = async ( + verify: (client: Client) => Promise, +): Promise => { + const session = createTestBinarySession(new ArtifactProvider()); + const server = createServer(session, session); + const client = new Client({ name: "dylib-mcp-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + await verify(client); + } finally { + await client.close(); + await server.close(); + } +}; + +const structuredResult = (value: unknown): unknown => + z.object({ result: z.unknown() }).parse(value).result; + +it("traces dylib resolution for an opened app bundle and rejects a changed target", async () => { + const app = join( + await createTestTempDirectory("rea-dylib-mcp-"), + "Fixture.app", + ); + await mkdir(join(app, "Contents/MacOS"), { recursive: true }); + await mkdir(join(app, "Contents/Frameworks"), { recursive: true }); + await writeFile( + join(app, "Contents/Info.plist"), + "CFBundleExecutableApp", + ); + const executable = join(app, "Contents/MacOS/App"); + await writeFile( + executable, + machoImage({ + commands: [ + rpathCommand("@executable_path/../Frameworks"), + dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"), + ], + }), + ); + await writeFile( + join(app, "Contents/Frameworks/libcore.dylib"), + machoImage({ fileType: FILE_TYPE.dylib }), + ); + await withClient(async (client) => { + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: app }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const called = await client.callTool({ + name: "trace_dylib_resolution", + arguments: {}, + }); + expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe( + true, + ); + const trace = dylibResolutionResultSchema.parse( + structuredResult(called.structuredContent), + ); + expect(trace.edges).toEqual([ + expect.objectContaining({ + install_name: "@rpath/libcore.dylib", + resolution: { + status: "resolved", + image: "Contents/Frameworks/libcore.dylib", + }, + }), + ]); + const escaping = await client.callTool({ + name: "trace_dylib_resolution", + arguments: { roots: ["Contents/../../outside"] }, + }); + expect(escaping.isError).toBe(true); + + await writeFile(executable, machoImage({})); + const changed = await client.callTool({ + name: "trace_dylib_resolution", + arguments: {}, + }); + expect(changed.isError).toBe(true); + expect(JSON.stringify(changed.structuredContent)).toContain("integrity"); + }); +}); + +it("rejects targets that are not Mach-O", async () => { + const directory = await createTestTempDirectory("rea-dylib-mcp-plist-"); + const path = join(directory, "Info.plist"); + await writeFile(path, ""); + await withClient(async (client) => { + const opened = await client.callTool({ + name: "open_binary", + arguments: { path }, + }); + expect(opened.isError).not.toBe(true); + const called = await client.callTool({ + name: "trace_dylib_resolution", + arguments: {}, + }); + expect(called.isError).toBe(true); + expect(JSON.stringify(called.structuredContent)).toContain("unavailable"); + }); +}); + +it("traces a standalone Mach-O file whose name ends in .app", async () => { + const directory = await createTestTempDirectory("rea-dylib-mcp-file-"); + await writeFile( + join(directory, "Tool.app"), + machoImage({ + commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libcore.dylib")], + }), + ); + await writeFile( + join(directory, "libcore.dylib"), + machoImage({ fileType: FILE_TYPE.dylib }), + ); + await withClient(async (client) => { + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: join(directory, "Tool.app") }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const called = await client.callTool({ + name: "trace_dylib_resolution", + arguments: {}, + }); + expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe( + true, + ); + const trace = dylibResolutionResultSchema.parse( + structuredResult(called.structuredContent), + ); + expect(trace.edges).toEqual([ + expect.objectContaining({ + resolution: { status: "resolved", image: "libcore.dylib" }, + }), + ]); + }); +}); + +it.skipIf(process.getuid?.() === 0)( + "keeps a permission denied while canonicalizing the opened bundle", + async () => { + const parent = await createTestTempDirectory("rea-dylib-mcp-revoked-"); + const app = join(parent, "Revoked.app"); + await mkdir(join(app, "Contents/MacOS"), { recursive: true }); + await writeFile( + join(app, "Contents/Info.plist"), + "CFBundleExecutableApp", + ); + await writeFile(join(app, "Contents/MacOS/App"), machoImage({})); + await withClient(async (client) => { + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: app }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + await chmod(parent, 0o000); + try { + const called = await client.callTool({ + name: "trace_dylib_resolution", + arguments: {}, + }); + expect(called.isError).toBe(true); + expect(JSON.stringify(called.structuredContent)).toMatch( + /Permission denied \(EACCES\)/u, + ); + } finally { + await chmod(parent, 0o755); + } + }); + }, +); diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts new file mode 100644 index 0000000000..1047b34592 --- /dev/null +++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts @@ -0,0 +1,78 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it, onTestFinished } from "vitest"; +import { EvmInterfaceService } from "../../../src/application/evm/EvmInterfaceService.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { ok } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { + evmInterfaceFixture, + evmInterfaceExecution, +} from "../../fixtures/evm/interface.js"; + +it("advertises exact valid schemas and records inline EVM interface evidence without a binary target", async () => { + const value = evmInterfaceFixture(); + const service = new EvmInterfaceService({ + inspect: () => Promise.resolve(ok(evmInterfaceExecution(value))), + }); + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer(session, session, { evmInterface: service }); + const client = new Client({ name: "evm-interface-contract", version: "1" }); + onTestFinished(async () => { + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_evm_interface", + ); + if (advertised?.outputSchema === undefined) + throw new Error("EVM interface inspection must publish both schemas"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + expect( + ajv.validate(inputSchema, { path: value.artifact.path, encoding: "hex" }), + ).toBe(true); + expect( + ajv.validate(inputSchema, { + path: value.artifact.path, + encoding: "hex", + approval: true, + }), + ).toBe(false); + const response = await client.callTool({ + name: "inspect_evm_interface", + arguments: { path: value.artifact.path, encoding: "hex" }, + }); + expect(response.isError).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const parsed = toolContract("inspect_evm_interface").outputSchema.parse( + response.structuredContent, + ); + const evidence = parseEvidence(parsed.evidence); + expect(parsed.result).toEqual(value); + expect(parsed.result).toEqual(evidence.normalized_result); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + expect( + ( + await client.callTool({ + name: "inspect_evm_interface", + arguments: { path: "relative.hex", encoding: "hex" }, + }) + ).isError, + ).toBe(true); +}); diff --git a/tests/fixtures/evm/interface.ts b/tests/fixtures/evm/interface.ts new file mode 100644 index 0000000000..23bfcfa026 --- /dev/null +++ b/tests/fixtures/evm/interface.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import type { EvmInterface } from "../../../src/domain/evm/evmInterface.js"; + +/** Source-owned transport seam, independent of real engine verification. */ +export const evmInterfaceFixture = ( + path = "/artifacts/source-owned.hex", +): EvmInterface => ({ + artifact: { path, sha256: "a".repeat(64), bytes: 10, encoding: "hex" }, + bytecode: { + sha256: createHash("sha256") + .update(Buffer.from("60006000f3", "hex")) + .digest("hex"), + bytes: 5, + hex: "60006000f3", + digest_algorithm: "sha256", + kind: "unknown", + deployment_authenticity: "unknown", + hardfork: "unknown", + }, + evidence_kind: "inferred", + functions: [], + discovery_completeness: "unknown", + runtime_execution: "not-performed", + diagnostics: { stdout: "", stderr: "", truncated: false }, + limitations: ["Interface completeness remains unknown."], +}); + +/** Bind a fixture to its explicit producing seam and carrier identity. */ +export const evmInterfaceExecution = (value = evmInterfaceFixture()) => + createAnalysisExecution( + value, + { id: "fixture-evm", name: "Source-owned EVM seam", version: "1" }, + { + rawResult: { functions: [] }, + subject: { + path: value.artifact.path, + format: "file", + sha256: value.artifact.sha256, + }, + limitations: value.limitations, + locations: [{ kind: "artifact-path", path: value.artifact.path }], + }, + ); diff --git a/third_party/evmole/LICENSE b/third_party/evmole/LICENSE new file mode 100644 index 0000000000..5f31d41836 --- /dev/null +++ b/third_party/evmole/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Maxim Andreev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/evmole/README.md b/third_party/evmole/README.md new file mode 100644 index 0000000000..0b7a635150 --- /dev/null +++ b/third_party/evmole/README.md @@ -0,0 +1,22 @@ +# EVM interface upstream profile + +REA integrates unchanged [EVMole](https://github.com/cdump/evmole) as the exact +npm dependency `evmole@0.9.3`, with lockfile integrity verification. Tag `0.9.3` +resolves to commit `0409707c2efef11a9043f9b4505cad5719054a7e`. + +The JavaScript binding and WASM engine remain in the original upstream package; +REA contains only carrier validation, a worker adapter and portable contracts. +No submodule or vendored engine fork is required. No signature database, +LLM decompiler, blockchain node or RPC dependency is acquired. + +Upstream is MIT, copyright (c) 2023 Maxim Andreev. Its npm tarball omits the +license file, so REA packages the unchanged tag's [LICENSE](LICENSE) here. +The engine is initialized through the upstream `no_tla`/`initSync` interface +only inside an owned worker; importing REA services performs no WASM/file I/O. + +Initial interface analysis uses `contractInfo` with selectors, arguments and +stateMutability enabled. Selector recovery also extracts/reports terminal metadata; +its reported representation is retained with explicit unknown precision/coverage. +Other upstream outputs are separate future contracts; +in particular, CFG destination IDs are not byte addresses and terminal CBOR +metadata cannot establish a lossless integer claim through the current binding. diff --git a/vitest.config.ts b/vitest.config.ts index f882022154..0ab5bbc44b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,7 +37,7 @@ const TEST_PROJECTS = [ name: "adapters", include: [ "src/*.test.ts", - "src/{artifacts,browser,dotnet,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts", + "src/{artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts", ], pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS,