chore: merge main into feat/signature-posture
This commit is contained in:
commit
e3c15c8bbc
85 files changed
+6711
-105
No files matched your search
@@ -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
|
||||
@@ -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) |
|
||||
|
||||
+1
-1
@@ -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 والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||
|
||||
+14
-14
@@ -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 バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
|
||||
|
||||
## ロードマップ
|
||||
|
||||
|
||||
+20
-20
@@ -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 앱 동작
|
||||
|
||||
|
||||
+14
-14
@@ -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 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||
|
||||
## 路线图
|
||||
|
||||
|
||||
@@ -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`).
|
||||
@@ -27,6 +27,7 @@ graph TD
|
||||
|
||||
subgraph "Provider Layers"
|
||||
PWNTOOLS["src/native/pwntools/<br/>BYO offline ELF adapter<br/>owned Python process, unchanged upstream tables/heuristics"]
|
||||
EVM["src/evm/<br/>unchanged EVMole/WASM in bounded owned worker<br/>explicit carrier parsing and inferred interface adapter"]
|
||||
HOPPER["src/hopper/<br/>Hopper launch & Unix-socket protocol<br/>BridgeLauncher, HopperClient, protocol"]
|
||||
GHIDRA["src/ghidra/<br/>Ghidra discovery, profile & function analysis<br/>snapshot, Unix/Windows transport, bounded lifecycle"]
|
||||
IDA["src/ida/<br/>Upstream MCP contract adaptation<br/>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
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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 <app-or-mach-o>` 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 <app-or-mach-o>`
|
||||
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 <app>` reads compiled
|
||||
`Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw
|
||||
rendition fields, pagination and exact UI resource-name matches are returned.
|
||||
|
||||
+22
-11
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Generated
+13
@@ -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",
|
||||
|
||||
+7
-3
@@ -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": {
|
||||
|
||||
@@ -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 <stdio.h>
|
||||
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"),
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Result<AnalysisExecution, AnalysisError>>;
|
||||
}
|
||||
@@ -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" } });
|
||||
});
|
||||
@@ -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<Result<Evidence, AnalysisError>> {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, JsonValue>>,
|
||||
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<Record<string, JsonValue>>,
|
||||
options?: ExecutionOptions,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<string, Promise<DylibTreeEntry | undefined>>();
|
||||
readonly #images = new Map<string, Promise<MachoImageFacts>>();
|
||||
readonly #identities = new Map<string, FileIdentity>();
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly signal?: AbortSignal,
|
||||
) {}
|
||||
|
||||
entry(path: string): Promise<DylibTreeEntry | undefined> {
|
||||
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<MachoImageFacts> {
|
||||
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<DylibTreeEntry | undefined> {
|
||||
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<DylibResolutionResult> => {
|
||||
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<string, string>();
|
||||
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<string[]> => {
|
||||
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<boolean> => {
|
||||
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<string> => {
|
||||
const { signal } = options;
|
||||
const handle = await open(
|
||||
absolute,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
const unchanged = async (): Promise<void> => {
|
||||
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");
|
||||
@@ -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<Uint8Array> =>
|
||||
Promise.resolve(bytes.subarray(offset, offset + length));
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Uint8Array>;
|
||||
|
||||
/** 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<number, MachoDependency["command"]> =
|
||||
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<MachoImageFacts> => {
|
||||
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<MachoSlice[]> => {
|
||||
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<MachoSlice> => {
|
||||
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<Uint8Array> => {
|
||||
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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv>,
|
||||
): 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);
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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<NodeJS.ProcessEnv> = process.env,
|
||||
): EvmInterfaceService =>
|
||||
new EvmInterfaceService(new EvmoleInterfaceProvider(environment));
|
||||
@@ -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<Record<string, Readonly<Record<string, unknown>>>> = {
|
||||
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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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[];
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,11 @@ const sessionEvidence = effects({ mutatesSession: true });
|
||||
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
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<Record<string, ToolEffects>> = {
|
||||
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,
|
||||
|
||||
@@ -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<Record<string, z.ZodObject>> = {
|
||||
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. */
|
||||
|
||||
@@ -6,6 +6,7 @@ const ANALYSIS_ERROR_TAGS = [
|
||||
"AnalysisArtifactChangedError",
|
||||
"AnalysisOutputError",
|
||||
"AnalysisCapabilityUnavailableError",
|
||||
"AnalysisUnsupportedTargetError",
|
||||
"AnalysisCancelledError",
|
||||
"AnalysisTimeoutError",
|
||||
"AnalysisResourceConstraintError",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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<Record<string, JsonValue>> | 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"
|
||||
|
||||
@@ -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<PathLookup> => {
|
||||
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<Record<string, readonly string[]>> = {
|
||||
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;
|
||||
};
|
||||
@@ -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> = {}): 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> = {},
|
||||
): 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<MachoSlice> = {}): MachoImageFacts =>
|
||||
parsed(slice({ file_type: "execute", ...overrides }));
|
||||
|
||||
/** In-memory tree: files are images (or "data"), symlinks map to targets. */
|
||||
const memoryView = (
|
||||
files: Readonly<Record<string, MachoImageFacts | "data">>,
|
||||
symlinks: Readonly<Record<string, string>> = {},
|
||||
): DylibTreeView => {
|
||||
const entries = new Map<string, DylibTreeEntry>();
|
||||
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<ReturnType<typeof traceDylibLoading>>,
|
||||
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,`);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof dependencySchema>;
|
||||
export type MachoSlice = z.infer<typeof machoSliceSchema>;
|
||||
|
||||
/** 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<DylibTreeEntry | undefined>;
|
||||
image(path: string): Promise<MachoImageFacts>;
|
||||
}
|
||||
|
||||
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<typeof dylibResolutionResultSchema>;
|
||||
type Candidate = z.infer<typeof candidateSchema>;
|
||||
export type DylibEdge = z.infer<typeof edgeSchema>;
|
||||
export type DylibFinding = z.infer<typeof findingSchema>;
|
||||
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<string, LoadedImage>;
|
||||
readonly byInstallName: Map<string, string>;
|
||||
readonly images: Map<string, MachoImageFacts>;
|
||||
}
|
||||
|
||||
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<Candidate> => {
|
||||
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<MachoImageFacts> => {
|
||||
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<Edge> => {
|
||||
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<Edge[]> => {
|
||||
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<DylibTrace> => {
|
||||
const images = new Map<string, MachoImageFacts>();
|
||||
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<MachoSlice, "dependencies"> => ({
|
||||
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;
|
||||
@@ -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<string, MachoImageFacts>,
|
||||
): 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.",
|
||||
];
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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<typeof evmInterfaceSchema>;
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
@@ -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"));
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<EvmWorkerLimits, string> => {
|
||||
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 });
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -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<Parameters<typeof runOwnedCommand>[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<NodeJS.ProcessEnv> = process.env,
|
||||
readonly launcher?: Launcher,
|
||||
readonly createRuntime: () => Promise<
|
||||
Pick<PrivateRuntimeRoot, "path" | "close">
|
||||
> = () => 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<Result<AnalysisExecution, AnalysisError>> {
|
||||
let root: Pick<PrivateRuntimeRoot, "path" | "close"> | 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<AnalysisExecution, AnalysisError>;
|
||||
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<typeof replySchema>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, string | Uint8Array>>,
|
||||
): Promise<void> => {
|
||||
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<string> => {
|
||||
const app = join(
|
||||
await createTestTempDirectory("rea-dylib-cli-"),
|
||||
"Fixture.app",
|
||||
);
|
||||
await writeFiles(app, {
|
||||
"Contents/Info.plist":
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
|
||||
"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<void> => {
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
@@ -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<void>,
|
||||
): Promise<void> => {
|
||||
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"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
|
||||
);
|
||||
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, "<plist><dict/></plist>");
|
||||
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"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
|
||||
);
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = 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);
|
||||
});
|
||||
Vendored
+44
@@ -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 }],
|
||||
},
|
||||
);
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+22
@@ -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.
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user