Compare commits

..
Author SHA1 Message Date
morluto 8f947c859c fix(apple): qualify rpath findings for conditional searches 2026-10-08 23:52:43 +08:00
morluto a446f2a413 chore: sync branch with latest main 2026-10-08 23:52:43 +08:00
morluto 5be3eeb1e6 fix(apple): distinguish dyld logging from path overrides 2026-10-08 23:46:06 +08:00
morluto 0cbef126a5 chore: merge main and reconcile CI projections 2026-10-08 23:45:07 +08:00
morluto 4adca806f9 fix(ci): restore generated projections in test shards 2026-10-08 23:32:34 +08:00
morluto 22e6110acb fix(apple): preserve modern cache subtype metadata 2026-10-08 23:26:33 +08:00
morluto 8289ede5db chore: synchronize dyld cache branch with latest main guidance 2026-10-08 23:16:56 +08:00
morluto e4d61d401d fix(apple): distinguish cache loadability and propagate search uncertainty 2026-10-08 23:16:28 +08:00
4e98543e90 fix(apple): admit only compatible dyld subcache mappings (#1090)
UUID, architecture, and cache VM offset are one admission decision, so a
matching UUID cannot publish contradictory mappings. A shared-cache trace
hit also requires the mapped bytes to parse as a Mach-O the process can load.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-08 22:51:06 +08:00
morluto 03bbecd941 chore: merge main into feat/dyld-shared-cache 2026-10-08 21:26:50 +08:00
morluto 2645851adf chore: merge main into feat/dyld-shared-cache 2026-10-08 21:22:08 +08:00
morluto d39543145e fix(apple): validate complete image mappings with indexed VM lookup 2026-10-08 21:13:43 +08:00
morluto b23f8288b4 fix(apple): validate cache file kinds and combined VM ranges 2026-10-08 20:52:20 +08:00
morluto cb8d0f37f4 chore: merge main into feat/dyld-shared-cache 2026-10-08 19:57:47 +08:00
morluto 6b99236c36 fix(apple): disclose untraversed cache dependencies in trace results 2026-10-08 19:55:51 +08:00
morluto fb999f84b8 refactor(apple): normalize host paths at the filesystem boundary 2026-10-08 19:09:40 +08:00
morluto fe3cac8aa8 fix(apple): normalize simulator cache platforms without losing raw fields 2026-10-08 18:19:37 +08:00
morluto 17f2bc7b0d fix(apple): discover cache companions from canonical main file 2026-10-08 17:46:43 +08:00
morluto f4f82179d9 fix(apple): preserve selected cache path in trace evidence 2026-10-08 17:22:52 +08:00
morluto 0f6dd6966c chore: merge main into feat/dyld-shared-cache 2026-10-08 17:02:53 +08:00
morluto 92fcab2c68 fix(apple): retain caller-selected cache path in results 2026-10-08 16:51:39 +08:00
morluto dbc7ebc2b6 fix(apple): recheck cache identity around digest reads 2026-10-08 16:36:44 +08:00
morluto d4ba8f661b fix(apple): map Intel version-min to simulators; fail truncated cache hashes 2026-10-08 16:23:05 +08:00
github-actions[bot] 24c8e13909 chore: regenerate generated metadata 2026-10-08 08:12:50 +00:00
morluto e1597a6f3d fix(apple): reject empty caches and bind digests to resolution traces 2026-10-08 16:06:51 +08:00
morluto d5921db039 chore: merge main into feat/dyld-shared-cache 2026-10-08 15:53:51 +08:00
morluto 663f4c4dc5 fix(apple): decode legacy cache-type polarity by header generation 2026-10-08 15:47:47 +08:00
morluto d081ffbdce fix(apple): harden dyld cache reads, mappings, and resolution findings 2026-10-08 15:36:31 +08:00
Kaoru0822andClaude Opus 5.5 cd622e0e95 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Bring in #927's merge of main (#1037 at skill version 30):
- README_ja/ar/ko/zh: keep the merged tables and count
  inspect_dyld_shared_cache in application workflows (13 -> 14).
- Skill: #927 now uses version 31, so this branch moves to 32.
- Regenerate the product catalog, conformance manifest, skill digest and
  package metadata (138 tools).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:33:22 +00:00
Kaoru0822andClaude Opus 5.5 f2f83c7499 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Bring in #927's merge of main (#964 at skill version 29):
- README_ja/ar/ko/zh: keep the merged tables and count
  inspect_dyld_shared_cache in application workflows (13 -> 14).
- Skill: #927 now uses version 30, so this branch moves to 31.
- Regenerate the product catalog, conformance manifest, skill digest and
  package metadata (137 tools).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:25:40 +00:00
Kaoru0822andClaude Opus 5.5 d180d8667d Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Bring in #927's merge of main and resolve conflicts:
- README.md: main's simplified English README has no tool-family table.
- README_ja/ar/ko/zh: keep main's reworded tables and count
  inspect_dyld_shared_cache in application workflows (13 -> 14).
- Skill: #927 now uses version 29, so this branch moves to 30.
- Regenerate the product catalog, conformance manifest, skill digest and
  package metadata (136 tools). The MCP tool catalog is now built rather
  than committed (#997), so it is removed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:52:57 +00:00
Kaoru0822 3d6a31ed41 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache 2026-10-07 14:49:09 +00:00
Kaoru0822andClaude Opus 5.5 09344aa33d chore(apple): drop an unused DylibCandidate re-export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:21:12 +00:00
Kaoru0822 8e53f794f1 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
# Conflicts:
#	README.md
#	README_ar.md
#	README_ja.md
#	README_ko.md
#	README_zh.md
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
2026-10-07 14:13:29 +00:00
Kaoru0822andClaude Opus 5.5 858aa9fd7d fix(apple): hash the dyld cache through the handle its header was read from
main_file_sha256 now comes from the main cache file's open handle, so a path
replaced during inspection cannot attach another file's digest to the parsed
header and image list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:35:58 +00:00
Kaoru0822 ed40d2e001 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache 2026-10-07 13:33:40 +00:00
Kaoru0822andClaude Opus 5.5 3b4969e3dc Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Carry #952's Apple ownership into the cache work: the cache reader and
fixture move to src/artifacts/apple/, the cache facts, platforms and
resolution schema to src/domain/apple/, the cache service to
src/application/apple/, and the lane to scripts/verify/apple/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:32:48 +00:00
Kaoru0822andClaude Opus 5.5 1d943c5eec Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Carry conditional-load propagation, parsed-image digest binding, and
segment-wise root resolution into the shared-cache trace. Also:

- Make cache compatibility directional: an arm64e cache serves arm64
  processes and an x86_64h cache serves x86_64 ones, not the reverse.
- Require the complete 24-byte LC_BUILD_VERSION with its declared tool
  records, and the 16-byte LC_VERSION_MIN_* command, before recording a
  platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:00:59 +00:00
Kaoru0822andClaude Opus 5.5 305474df6c fix(apple): gate shared-cache answers on platform and verified subcaches
- Use a supplied cache for a process only when it serves the process's CPU
  family and one of its LC_BUILD_VERSION or LC_VERSION_MIN platforms matches
  the cache's platform or alternate platform; slices now report platforms.
  A limitation names each root the cache was not used for, and why.
- Treat an image listed in the cache but mapped in a missing or
  UUID-mismatched subcache as undetermined, list it in
  coverage.unverified_shared_cache_images, and mark coverage partial.
- Keep a cache hit conditional when an earlier candidate is undeterminable.
- Open a development cache's named subcaches beside its base name.
- Describe absolute paths as not evaluated only when no cache is supplied.
- Preflight dyld_info in verify:dyld-shared-cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:36:43 +00:00
Kaoru0822 202661f83b Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
2026-10-07 12:26:24 +00:00
Kaoru0822 22beae9768 Merge remote-tracking branch 'origin/feat/dylib-resolution' into feat/dyld-shared-cache
# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/domain/dylibResolution.test.ts
#	src/domain/dylibResolution.ts
#	src/domain/dylibResolutionFindings.ts
#	src/generatedMcpToolCatalog.ts
2026-10-07 11:52:10 +00:00
Kaoru0822andClaude Opus 5.5 ade11fa02d feat(apple): inspect dyld shared caches and resolve system paths
macOS 11 and later ship system libraries only inside the dyld shared
cache. trace_dylib_resolution therefore left every /usr/lib and /System
load undetermined, and REA had no way to see what a cache contains.

inspect_dyld_shared_cache (CLI inspect-dyld-shared-cache) reads a cache
file set selected by path, without an open target. Its pure TypeScript
reader parses modern and legacy dyld_cache_header layouts and reports:
- UUID, architecture, platform, OS version, Mac Catalyst alternate,
  cache type, shared region, max slide;
- every VM mapping;
- each subcache by file suffix, checked by UUID. A missing or
  mismatched subcache makes coverage partial;
- the complete image list.
For each requested install path it reads that image's load commands
through the cache mappings with the existing Mach-O reader.

trace_dylib_resolution accepts an optional shared_cache. Absolute
candidates found in a cache that serves the process's CPU family resolve
with the new outcome and status `shared-cache`. That status also takes
precedence over later in-bundle fallbacks such as a back-deployed
libswiftCore. The result names the cache UUID and OS version it used.

verify:dyld-shared-cache checks every image in the host cache (3,649 on
macOS 26.6), with dependencies and their re-export, weak, upward and
delay-init attributes, against `dyld_info -all_dyld_cache
-linked_dylibs`. It also checks CLI/MCP parity, and that each
shared-cache edge in the fixture trace is a library dyld actually loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:43:15 +00:00
67 changed files with 3643 additions and 3971 deletions
+1 -1
View File
@@ -317,7 +317,7 @@ jobs:
- run: npm run verify:keyed-archive
- run: npm run verify:asset-catalog
- run: npm run verify:macos-bundle
- run: npm run verify:code-signature
- run: npm run verify:dyld-shared-cache
- name: Native installer and compiled NIB hierarchy regressions
run: npm run test:focused -- tests/boundary/filesystem/installScript.test.ts tests/boundary/filesystem/nibNativeHierarchy.test.ts tests/boundary/filesystem/interfaceBuilderBundle.test.ts
- name: Real binary metadata and Interface Builder integration
+14 -14
View File
@@ -213,20 +213,20 @@ rea setup
## منصة أدوات التحقيق
| عائلة الأدوات | العدد | الاستخدام |
| ---------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
| أدوات الملفات الثنائية | 10 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة وسجلات الأعطال على Linux |
| مخطط المخرجات | 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 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
| عائلة الأدوات | العدد | الاستخدام |
| ---------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
| أدوات الملفات الثنائية | 10 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة وسجلات الأعطال على Linux |
| مخطط المخرجات | 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 ومواضع البرامج وأحداث سياقات التنفيذ |
| سير عمل التطبيقات | 14 | تصدير النصوص البرمجية الملتقطة، فحص ذاكرة dyld المشتركة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
## خطة العمل
+14 -14
View File
@@ -236,20 +236,20 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して
## 調査ツールカタログ
| ツール分類 | 数 | 用途 |
| -------------------- | --: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
| ネイティブバイナリ | 10 | 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 バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
| ツール分類 | 数 | 用途 |
| -------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
| ネイティブバイナリ | 10 | 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 の対象検出、スクリプトの位置、実行コンテキストのイベント |
| アプリワークフロー | 14 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、dyld 共有キャッシュの検査、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 |
| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
## ロードマップ
+1 -1
View File
@@ -215,7 +215,7 @@ REA는 1~5단계의 바이너리 분석을 처리합니다. 6단계는 에이전
| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
| 앱 워크플로 | 14 | 캡처한 웹 스크립트 내보내기, dyld 공유 캐시 검사, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
## 로드맵
+1 -1
View File
@@ -214,7 +214,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
| 应用工作流 | 14 | 捕获的网站脚本导出、dyld 共享缓存检查、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
## 路线图
+67 -7
View File
@@ -153,15 +153,16 @@ Resolution follows dyld:
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
`outside-target`, `shared-cache`, `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. |
| Status | Meaning |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. |
| `conditional` | An image inside the root, or in the supplied shared cache, resolves, but an earlier candidate that REA cannot decide (for example `/opt/vendor/lib` or `/usr/lib/swift` without a cache) 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. |
| `shared-cache` | An absolute install path, or an `@rpath` candidate outside the root, was found in the dyld shared cache passed as `shared_cache` (CLI `--shared-cache`). dyld loads the cached image, so later in-root candidates are unused. |
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
@@ -185,3 +186,62 @@ cache rather than on disk, so REA does not check them against the host.
`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`).
With `shared_cache`, absolute candidates are looked up in that cache's image
list. A process uses the cache only when both of these hold:
- the cache serves its CPU family (an arm64e cache serves arm64 and arm64e);
- one of its `LC_BUILD_VERSION` or `LC_VERSION_MIN_*` platforms (reported per
slice as `platforms`) matches the cache's platform or alternate platform.
An iOS cache extracted from an IPSW is therefore not used for a macOS
executable. A limitation names each process root the cache was not used for,
and why. The result's `shared_cache` names the cache path, UUID, architecture
and OS version. Cached images are not traversed.
An image listed in the cache but mapped in a missing or UUID-mismatched
subcache is `undetermined`. It is listed in
`coverage.unverified_shared_cache_images` and makes coverage partial. Without a
cache, system paths stay `undetermined`.
## dyld shared cache
`inspect_dyld_shared_cache` reads a dyld shared cache file set selected by path.
On current macOS this is
`/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e`;
a cache extracted from an IPSW also works. It runs without an open target.
```sh
rea inspect-dyld-shared-cache /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e --image /usr/lib/libSystem.B.dylib --json
```
```json
{
"name": "inspect_dyld_shared_cache",
"arguments": {
"cache_path": "/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e",
"images": ["/usr/lib/libSystem.B.dylib"]
}
}
```
The result reports:
- the magic, architecture, UUID, platform and OS version, any alternate platform
(Mac Catalyst), cache type, the development/production subtype for multi-caches
(including its raw ID when unknown), shared region and maximum slide;
- every VM mapping, with the file that holds it;
- every subcache by file suffix, with its UUID checked against the main cache.
A missing or mismatched subcache makes coverage `partial`, and images mapped
there are `unmapped`;
- the complete image list with addresses.
For each requested install path, the tool reads that image's load commands
(install name, dependencies with their re-export, weak, upward and delayed-init
flags, and rpaths) through the cache mappings. A path the cache does not
contain is `absent`. Older single-file caches are read from their legacy header
fields.
Nothing is executed, slid or extracted. Slide info, local symbols and the
cache's code signature are not parsed. The Evidence subject is the main cache
file, identified by its digest.
+6 -14
View File
@@ -98,20 +98,12 @@ an XML graph golden. `verify:asset-catalog` compiles source-owned colors with
`actool`, invokes real `assetutil`, then checks CLI/MCP results, exact catalog
digest, every raw metadata field, pagination and malformed input rejection.
Neither artifact workflow requires Hopper or launches it. Both run in macOS CI.
`verify:code-signature` needs only macOS with Command Line Tools. It re-signs
copies of a clang-built fixture with real `codesign`: ad hoc, hardened
runtime, hardened runtime with relaxing entitlements, and `library,restrict`
flags. It checks that `inspect-signature` decodes those flags and derives the
expected security facets. It also checks a platform binary, which must report a
platform identifier and pass the `anchor apple` origin check, and bundles with an added unsealed file, at the top level
and inside a nested XPC service, which must fail deep strict verification. CLI and stdio MCP results must match.
The default lane explicitly reports real stapled-ticket coverage as unverified:
its source-built ad hoc fixtures cannot receive genuine Apple notarization tickets.
To verify ticket producer output through CLI and MCP, supply an existing stapled app:
`npm run verify:code-signature -- --stapled-app "/Applications/Firefox.app"`.
This optional subcase preflights `xcrun stapler`, validates the selected app with
Apple's tool, and checks that both REA surfaces return the same ticket digest and
`present` framing observation. It does not install an app or submit anything for notarization.
`verify:dyld-shared-cache` reads the host's dyld shared cache. It checks that
the image list, and every image's dependencies with their attributes, equal
`dyld_info -all_dyld_cache -linked_dylibs`, and that `inspect-dyld-shared-cache`
agrees between CLI and stdio MCP. It then traces the clang-built fixture app
with `--shared-cache` and checks that every `shared-cache` edge names a library
dyld actually loads under `DYLD_PRINT_LIBRARIES`.
`verify:macos-bundle` needs only macOS with Command Line Tools. It compiles a
source-owned app with `clang`: a versioned framework, XPC services, an app
extension, a login item, a privileged helper, launchd plists, and a helper tool,
+1 -1
View File
@@ -142,7 +142,7 @@
"verify:apple-dispatch": "npm run build:cached && node scripts/verify/apple/dispatch.mjs",
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
"verify:keyed-archive": "npm run build:cached && node scripts/verify/apple/keyed-archive.mjs",
"verify:code-signature": "npm run build:cached && node scripts/verify/apple/code-signature.mjs",
"verify:dyld-shared-cache": "npm run build:cached && node scripts/verify/apple/dyld-shared-cache.mjs",
"verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
+9 -6
View File
@@ -38,7 +38,7 @@ export async function artifactCliEvidence(command, target, arguments_ = []) {
return evidence;
}
/** Run a real stdio MCP subprocess, owning and closing its transport. */
/** Run a real stdio MCP subprocess, owning and closing its transport; open `target` unless it is null. */
export async function withArtifactMcp(target, verify) {
const transport = new StdioClientTransport({
command: process.execPath,
@@ -49,11 +49,14 @@ export async function withArtifactMcp(target, verify) {
const client = new Client({ name: "artifact-real-e2e", version: "1" });
try {
await client.connect(transport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: target },
});
assert.notEqual(opened.isError, true, JSON.stringify(opened));
// Target-free tools pass `null` and need no open session.
if (target !== null) {
const opened = await client.callTool({
name: "open_binary",
arguments: { path: target },
});
assert.notEqual(opened.isError, true, JSON.stringify(opened));
}
await verify(client);
} finally {
await client.close();
+5 -2
View File
@@ -243,7 +243,8 @@ export const providerCatalog = (sources) => {
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
({ name }) =>
name !== "project_android_application_graph" &&
name !== "project_apple_application_graph",
name !== "project_apple_application_graph" &&
name !== "inspect_dyld_shared_cache",
),
},
{
@@ -255,7 +256,9 @@ export const providerCatalog = (sources) => {
{
identity: sources.artifactProviders.APPLE_APPLICATION_PROVIDER,
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
({ name }) => name === "project_apple_application_graph",
({ name }) =>
name === "project_apple_application_graph" ||
name === "inspect_dyld_shared_cache",
),
},
{
-291
View File
@@ -1,291 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
copyFile,
lstat,
mkdir,
mkdtemp,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
} from "../../lib/artifact-e2e.mjs";
import {
buildMacosBundleFixture,
preflightMacosBundleFixture,
} from "../../fixtures/apple/macos-bundle.mjs";
const exec = promisify(execFile);
const args = process.argv.slice(2);
if (args.length !== 0 && (args.length !== 2 || args[0] !== "--stapled-app"))
throw new Error("Usage: verify:code-signature [--stapled-app PATH]");
const stapledApp = args[1];
if (stapledApp !== undefined) {
try {
await exec("/usr/bin/xcrun", ["--find", "stapler"]);
} catch (cause) {
throw new Error("Real stapled-ticket verification requires xcrun stapler", {
cause,
});
}
}
await preflightMacosBundleFixture();
const root = await mkdtemp(join(tmpdir(), "rea-code-signature-"));
const facets = (signature) =>
Object.fromEntries(
signature.security_facets.map(({ facet, state }) => [facet, state]),
);
/** Re-sign a copy of one fixture executable ad hoc with the given options. */
const signedCopy = async (source, name, options) => {
const path = join(root, name);
await copyFile(source, path);
await exec("/usr/bin/xcrun", [
"codesign",
"--force",
"--sign",
"-",
...options,
path,
]);
return path;
};
const ENTITLEMENTS = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.cs.disable-library-validation</key><true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key><true/>
<key>com.apple.security.get-task-allow</key><true/>
<key>com.apple.security.cs.allow-jit</key><true/>
</dict></plist>
`;
try {
const { app } = await buildMacosBundleFixture(root);
let realTicket = {
status: "unverified",
reason:
"No known stapled app supplied; use --stapled-app PATH to exercise producer output.",
};
if (stapledApp !== undefined) {
const appPath = await realpath(stapledApp);
const oracle = await exec("/usr/bin/xcrun", [
"stapler",
"validate",
appPath,
]);
const observed = await artifactCli("inspect-signature", appPath);
assert.equal(
observed.stapled_ticket.status,
"present",
JSON.stringify(observed.stapled_ticket),
);
assert.equal(observed.stapled_ticket.reason, null);
assert.match(observed.stapled_ticket.sha256, /^[a-f0-9]{64}$/u);
await withArtifactMcp(appPath, async (client) => {
const viaMcp = await artifactMcpResult(client, "inspect_signature");
assert.deepEqual(viaMcp.stapled_ticket, observed.stapled_ticket);
});
realTicket = {
status: "verified",
selected_app: stapledApp,
ticket_sha256: observed.stapled_ticket.sha256,
size: observed.stapled_ticket.size,
oracle: "xcrun stapler validate",
stdout: oracle.stdout,
stderr: oracle.stderr,
};
}
const plain = await artifactCli("inspect-signature", app);
assert.deepEqual(plain.code_directory.flags.names, ["adhoc"]);
assert.equal(plain.verification.status, "valid");
// The opened bundle is reported by its canonical path.
assert.equal(plain.verification.path, await realpath(app));
assert.equal(plain.stapled_ticket.status, "absent");
assert.equal(plain.sealed_resources.status, "sealed");
assert.deepEqual(facets(plain), {
"library-validation": "not-enforced",
"dyld-environment-variables": "honored",
"debugger-attach": "allowed",
"executable-memory": "unrestricted",
"app-sandbox": "not-sandboxed",
});
// Deep verification reports the fixture's real nested code, all on disk.
assert.ok(plain.verification.validated_nested_code.length > 0);
const checkNestedProgress = async (signature) => {
for (const path of signature.verification.validated_nested_code) {
try {
await lstat(path);
} catch {
assert.ok(
signature.limitations.some((limitation) =>
limitation.includes(JSON.stringify(path)),
),
`Unconfirmed progress pathname must retain its limitation: ${path}`,
);
}
}
};
await checkNestedProgress(plain);
await withArtifactMcp(app, async (client) => {
const viaMcp = await artifactMcpResult(client, "inspect_signature");
assert.equal(viaMcp.verification.status, plain.verification.status);
assert.equal(viaMcp.verification.path, plain.verification.path);
await checkNestedProgress(viaMcp);
// Separate live codesign runs can interleave progress records differently.
// Compare the artifact's signature posture; check each capture independently.
const semantic = ({ provenance, verification, limitations, ...posture }) =>
posture;
assert.deepEqual(semantic(viaMcp), semantic(plain));
});
const tool = join(app, "Contents/Helpers/rea-tool");
const hardened = await artifactCli(
"inspect-signature",
await signedCopy(tool, "hardened", ["--options", "runtime"]),
);
assert.deepEqual(hardened.code_directory.flags.names, ["adhoc", "runtime"]);
assert.equal(hardened.stapled_ticket.status, "not-applicable");
assert.deepEqual(facets(hardened), {
"library-validation": "enforced",
"dyld-environment-variables": "ignored",
"debugger-attach": "blocked",
"executable-memory": "restricted",
"app-sandbox": "not-sandboxed",
});
const entitlements = join(root, "relaxed.entitlements");
await writeFile(entitlements, ENTITLEMENTS);
const relaxed = await artifactCli(
"inspect-signature",
await signedCopy(tool, "relaxed", [
"--options",
"runtime",
"--entitlements",
entitlements,
]),
);
assert.deepEqual(facets(relaxed), {
"library-validation": "disabled",
"dyld-environment-variables": "honored",
"debugger-attach": "allowed",
"executable-memory": "jit-allowed",
"app-sandbox": "not-sandboxed",
});
const flagged = await artifactCli(
"inspect-signature",
await signedCopy(tool, "flagged", ["--options", "library,restrict"]),
);
assert.deepEqual(flagged.code_directory.flags.names, [
"adhoc",
"restrict",
"library-validation",
]);
assert.equal(facets(flagged)["library-validation"], "enforced");
assert.equal(facets(flagged)["dyld-environment-variables"], "ignored");
const platform = await artifactCli("inspect-signature", "/usr/bin/true");
assert.notEqual(platform.code_directory.platform_identifier, null);
assert.equal(platform.verification.status, "valid");
assert.equal(facets(platform)["library-validation"], "enforced");
// The platform rules rest on a real `anchor apple` requirement check.
assert.ok(
platform.provenance.some(({ command }) =>
command.includes("-R=anchor apple"),
),
);
// Negative fixtures contain only the code needed for each rejection. A large
// nested-code graph can interleave codesign progress, making that capture
// inconclusive independently of the invalidity these checks exercise.
const minimalApp = async (name, includeNested) => {
const bundle = join(root, `${name}.app`);
const contents = join(bundle, "Contents");
await mkdir(join(contents, "MacOS"), { recursive: true });
await copyFile(tool, join(contents, "MacOS", "Fixture"));
await writeFile(
join(contents, "Info.plist"),
`<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict><key>CFBundleExecutable</key><string>Fixture</string>
<key>CFBundleIdentifier</key><string>com.example.rea.signature</string>
<key>CFBundlePackageType</key><string>APPL</string></dict></plist>`,
);
if (includeNested) {
await mkdir(join(contents, "XPCServices"));
await exec("/usr/bin/ditto", [
join(app, "Contents/XPCServices/Svc.xpc"),
join(contents, "XPCServices/Svc.xpc"),
]);
}
await exec("/usr/bin/xcrun", [
"codesign",
"--force",
"--deep",
"--sign",
"-",
bundle,
]);
assert.equal(
(await artifactCli("inspect-signature", bundle)).verification.status,
"valid",
);
return bundle;
};
const nested = await minimalApp("Nested", true);
await writeFile(
join(nested, "Contents/XPCServices/Svc.xpc/Contents/added.txt"),
"unsealed",
);
const nestedTamper = await artifactCli("inspect-signature", nested);
assert.equal(nestedTamper.verification.status, "invalid");
assert.ok(
nestedTamper.verification.diagnostics.some((line) =>
line.includes("Svc.xpc"),
),
JSON.stringify(nestedTamper.verification),
);
const mainTamper = await minimalApp("Tampered", false);
await mkdir(join(mainTamper, "Contents/Resources"));
await writeFile(join(mainTamper, "Contents/Resources/added.txt"), "unsealed");
const tampered = await artifactCli("inspect-signature", mainTamper);
assert.equal(tampered.verification.status, "invalid");
assert.ok(
tampered.verification.diagnostics.some((line) => line.includes("added")),
JSON.stringify(tampered.verification),
);
process.stdout.write(
`${JSON.stringify({
ok: true,
mocked: false,
cli: true,
stdio_mcp: true,
variants: [
"adhoc",
"runtime",
"runtime+entitlements",
"library,restrict",
],
platform_identifier: platform.code_directory.platform_identifier,
tampered_verification: tampered.verification.status,
nested_tamper_verification: nestedTamper.verification.status,
real_stapled_ticket: realTicket,
})}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
+222
View File
@@ -0,0 +1,222 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { access, mkdtemp, open, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { DyldSharedCache } from "../../../dist/artifacts/apple/DyldSharedCacheReader.js";
import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
} from "../../lib/artifact-e2e.mjs";
import {
buildMacosBundleFixture,
preflightMacosBundleFixture,
} from "../../fixtures/apple/macos-bundle.mjs";
const exec = promisify(execFile);
await preflightMacosBundleFixture();
try {
await exec("/usr/bin/xcrun", ["--find", "dyld_info"]);
} catch (cause) {
throw new Error(
"dyld shared cache verification requires dyld_info from Command Line Tools (xcrun --find dyld_info failed)",
{ cause },
);
}
/** The cache this host's dyld uses for native processes. */
const hostCache = async () => {
const names =
process.arch === "arm64"
? ["dyld_shared_cache_arm64e"]
: ["dyld_shared_cache_x86_64h", "dyld_shared_cache_x86_64"];
for (const directory of [
"/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld",
"/System/Library/dyld",
])
for (const name of names)
try {
await access(join(directory, name));
return join(directory, name);
} catch {
// Try the next documented location.
}
throw new Error("No dyld shared cache was found in the documented locations");
};
/** `dyld_info -all_dyld_cache -linked_dylibs`: path -> "attributes|load path" lines. */
const dyldInfoImages = async () => {
const { stdout } = await exec(
"/usr/bin/xcrun",
["dyld_info", "-all_dyld_cache", "-linked_dylibs"],
{ maxBuffer: 256 * 1024 * 1024 },
);
const images = new Map();
let current;
for (const line of stdout.split("\n")) {
const head = /^(\/.+) \[(\S+)\]:$/u.exec(line);
if (head !== null) {
current = [];
images.set(head[1], current);
continue;
}
const dependency = /^\s{8}(.*?)\s*(\/\S.*)$/u.exec(line);
if (dependency !== null && current !== undefined)
current.push(
`${dependency[1].split(/\s+/u).filter(Boolean).sort().join(",")}|${dependency[2]}`,
);
}
return images;
};
const attributes = (dependency) =>
[
dependency.reexport ? "re-export" : null,
dependency.weak ? "weak-link" : null,
dependency.upward ? "upward" : null,
dependency.delayed_init ? "delay-init" : null,
]
.filter((value) => value !== null)
.sort()
.join(",");
const cachePath = await hostCache();
const root = await mkdtemp(join(tmpdir(), "rea-dyld-cache-"));
try {
const expected = await dyldInfoImages();
const cache = await DyldSharedCache.open(cachePath);
let compared = 0;
try {
assert.deepEqual(
cache.images.map(({ path }) => path).sort(),
[...expected.keys()].sort(),
"image list differs from dyld_info",
);
for (const [path, dependencies] of expected) {
const read = await cache.imageFacts(path);
assert.equal(read?.facts.status, "parsed", path);
assert.deepEqual(
read.facts.slices[0].dependencies.map(
(dependency) =>
`${attributes(dependency)}|${dependency.install_name}`,
),
dependencies,
`${path} dependencies differ from dyld_info`,
);
compared += 1;
}
} finally {
await cache.close();
}
const inspected = await artifactCli("inspect-dyld-shared-cache", cachePath, [
"--image",
"/usr/lib/libSystem.B.dylib",
]);
assert.equal(inspected.images_total, expected.size);
assert.equal(inspected.coverage.status, "complete");
assert.equal(inspected.inspected_images[0].status, "parsed");
// Check the producer header directly, independently of REA's header projection.
const handle = await open(cachePath, "r");
try {
const header = Buffer.alloc(0x1cc);
const { bytesRead } = await handle.read(header, 0, header.length, 0);
const multi = header.readBigUInt64LE(0x68) === 2n;
const hasSubtype =
bytesRead === header.length && header.readUInt32LE(0x10) >= header.length;
const subtype = multi && hasSubtype ? header.readUInt32LE(0x1c8) : null;
assert.deepEqual(
inspected.cache_subtype,
subtype === null
? null
: {
id: subtype,
name:
subtype === 0
? "development"
: subtype === 1
? "production"
: null,
},
"multi-cache subtype differs from the producer header",
);
} finally {
await handle.close();
}
await withArtifactMcp(null, async (client) => {
const viaMcp = await artifactMcpResult(
client,
"inspect_dyld_shared_cache",
{
cache_path: cachePath,
images: ["/usr/lib/libSystem.B.dylib"],
},
);
assert.deepEqual(viaMcp, inspected);
});
const { app } = await buildMacosBundleFixture(root);
const trace = await artifactCli("trace-dylib-resolution", app, [
"--shared-cache",
cachePath,
]);
assert.equal(trace.shared_cache.uuid, inspected.uuid);
const main = "Contents/MacOS/MacFixture";
const cached = trace.edges.filter(
({ root: edgeRoot, resolution }) =>
edgeRoot === main && resolution.status === "shared-cache",
);
assert.ok(
cached.some(
({ install_name: name }) => name === "/usr/lib/libSystem.B.dylib",
),
);
assert.ok(
trace.edges.every(({ install_name: name, resolution }) =>
name.startsWith("/usr/lib/") || name.startsWith("/System/")
? resolution.status === "shared-cache"
: true,
),
"a system install path was not found in the host cache",
);
const { stderr } = await exec(join(app, main), [], {
env: { PATH: "/usr/bin:/bin", DYLD_PRINT_LIBRARIES: "1" },
});
const loaded = new Set(
stderr
.split("\n")
.map((line) => /^dyld\[\d+\]: <[0-9A-F-]+> (.+)$/u.exec(line)?.[1])
.filter((path) => path !== undefined),
);
for (const { resolution } of cached)
assert.ok(
loaded.has(resolution.image),
`${resolution.image} was not loaded by dyld`,
);
process.stdout.write(
`${JSON.stringify({
ok: true,
mocked: false,
cli: true,
stdio_mcp: true,
cache: {
architecture: inspected.architecture,
os_version: inspected.os_version,
images: inspected.images_total,
type: inspected.cache_type,
subtype: inspected.cache_subtype,
},
images_matching_dyld_info: compared,
trace_shared_cache_edges: cached.length,
dyld_loaded_every_cached_edge: true,
host: await realpath(cachePath),
})}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -91,12 +91,6 @@ ASAR, or DMG inputs when the artifact graph and findings help answer the
question. It returns the complete artifact graph inline. Cite graph manifest
IDs when using them.
`inspect_signature` decodes CodeDirectory flags, runs local strict
verification, reports a stapled notarization ticket, and derives security
facets: library validation, `DYLD_*` handling, debugger attach, executable
memory, and sandbox. Cite each facet's `evidence`; system policy such as SIP and
AMFI is not evaluated.
For an IPA or a macOS `.app`, ZIP, or DMG, pass the inventory Evidence to
`project_apple_application_graph`. It reports application roots and nested
bundles by path convention: app extensions, XPC services, frameworks, login
@@ -109,8 +103,10 @@ 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.
`architecture`. Pass `shared_cache` (the host cache or one from an IPSW) to
resolve system paths to `shared-cache`; without it they stay undetermined.
Use `inspect_dyld_shared_cache` with that cache path to list its images and read
a cached library's dependencies without an open target.
Use `extract_artifact` when materialized files are needed. It takes no arguments
and materializes all regular files into a fresh temporary directory chosen by
@@ -0,0 +1,195 @@
import { resolve } from "node:path";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { DyldSharedCache } from "../../artifacts/apple/DyldSharedCacheReader.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import {
DYLD_SHARED_CACHE_LIMITATIONS,
dyldSharedCacheResultSchema,
inspectDyldSharedCacheInputSchema,
type DyldSharedCacheResult,
type InspectedCacheImage,
} from "../../domain/apple/dyldSharedCache.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import { APPLE_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
const OPERATION = "inspect_dyld_shared_cache" as const;
/** Read one requested image through the cache, keeping absence explicit. */
const inspectImage = async (
cache: DyldSharedCache,
path: string,
): Promise<InspectedCacheImage> => {
const image = cache.find(path);
const base = { path, reason: null, slices: [] };
if (image === undefined)
return { ...base, status: "absent", address: null, file: null };
const address = `0x${image.address.toString(16)}`;
const read = await cache.imageFacts(path);
if (read === undefined)
return {
...base,
status: "unmapped",
address,
file: null,
reason: "The image address lies in no readable cache mapping.",
};
const { facts, file } = read;
if (facts.status === "parsed")
return {
...base,
status: "parsed",
address,
file,
slices: [...facts.slices],
};
return {
...base,
status: facts.status === "not-mach-o" ? "malformed" : facts.status,
address,
file,
reason:
facts.status === "not-mach-o"
? "The cached image is not a Mach-O image."
: facts.reason,
};
};
const failure = (cause: unknown): AnalysisError => {
if (cause instanceof ArtifactReaderFailure)
return new ArtifactOperationError(
OPERATION,
cause.reason,
undefined,
cause.message,
);
if (cause instanceof Error && "code" in cause) {
if (cause.code === "EISDIR")
return new ArtifactOperationError(
OPERATION,
"path",
undefined,
cause.message,
);
if (cause.code === "ENOENT" || cause.code === "ENOTDIR")
return new ArtifactOperationError(
OPERATION,
"path",
undefined,
"The cache file does not exist.",
);
if (cause.code === "EACCES" || cause.code === "EPERM")
return new ArtifactOperationError(
OPERATION,
"unavailable",
undefined,
"The cache file is not readable.",
);
}
if (cause instanceof Error && cause.name === "AbortError")
return new ArtifactOperationError(OPERATION, "cancelled");
return new ArtifactOperationError(
OPERATION,
"io",
undefined,
cause instanceof Error ? cause.message : String(cause),
);
};
/** Inspect a dyld shared cache file set selected by path. */
export const inspectDyldSharedCache = async (
rawInput: unknown,
signal?: AbortSignal,
): Promise<Result<DyldSharedCacheResult, AnalysisError>> => {
const parsed = inspectDyldSharedCacheInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(new AnalysisInputError(OPERATION, { cause: parsed.error }));
// Keep the caller-selected spelling for result and Evidence metadata; only
// filesystem access uses the resolved path.
const selectedPath = parsed.data.cache_path;
const cachePath = resolve(selectedPath);
let cache: DyldSharedCache | undefined;
try {
cache = await DyldSharedCache.open(cachePath, signal);
const inspected: InspectedCacheImage[] = [];
for (const path of parsed.data.images ?? []) {
signal?.throwIfAborted();
inspected.push(await inspectImage(cache, path));
}
const unreadable = cache.header.subcaches
.filter(({ status }) => status !== "present")
.map(({ suffix }) => suffix);
// Hash through the open handles the header was parsed from, so a path
// replacement mid-inspection cannot attribute new bytes to old facts.
const mainSha = await cache.mainSha256(signal);
const subcacheSha = await cache.subcacheSha256(signal);
return ok(
dyldSharedCacheResultSchema.parse({
cache_path: selectedPath,
// The header and image list were read through this same handle.
main_file_sha256: mainSha,
...cache.header,
subcache_sha256: subcacheSha,
images_total: cache.images.length,
images: cache.images.map(({ path, address }) => ({
path,
address: `0x${address.toString(16)}`,
})),
inspected_images: inspected,
coverage: {
status: unreadable.length === 0 ? "complete" : "partial",
unreadable_subcaches: unreadable,
},
limitations: [
...DYLD_SHARED_CACHE_LIMITATIONS,
...(unreadable.length === 0
? []
: [
`Subcaches ${unreadable.join(", ")} are missing or do not match the main cache UUID; images mapped there are unmapped.`,
]),
],
}),
);
} catch (cause: unknown) {
return err(failure(cause));
} finally {
await cache?.close();
}
};
/** Inspect a dyld shared cache and wrap the observation as Evidence. */
export const inspectDyldSharedCacheEvidence = async (
rawInput: unknown,
signal?: AbortSignal,
): Promise<Result<Evidence, AnalysisError>> => {
const inspected = await inspectDyldSharedCache(rawInput, signal);
if (!inspected.ok) return inspected;
const result = inspected.value;
return ok(
createEvidence(
{
path: result.cache_path,
sha256: result.main_file_sha256,
format: "file",
},
APPLE_APPLICATION_PROVIDER,
{
operation: OPERATION,
parameters: {
cache_path: result.cache_path,
images: result.inspected_images.map(({ path }) => path),
},
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "observed",
authority: "shipped-artifact",
environment: null,
limitations: result.limitations,
},
),
);
};
@@ -0,0 +1,138 @@
/** Synthetic dyld shared caches laid out field by field for tests. */
export interface CacheFixtureImage {
readonly path: string;
readonly bytes: Uint8Array;
/** Place the image in the `.01` subcache instead of the main file. */
readonly inSubcache?: boolean;
}
export interface CacheFixture {
readonly main: Uint8Array;
readonly subcaches: readonly {
readonly suffix: string;
readonly bytes: Uint8Array;
}[];
}
const MAIN_BASE = 0x180000000n;
const SUB_BASE = 0x190000000n;
const MODERN_HEADER = 0x228;
const LEGACY_HEADER = 0x98;
const uuid = (seed: number): Buffer =>
Buffer.from(
Array.from({ length: 16 }, (_, index) => (seed * 31 + index) & 0xff),
);
const align = (value: number): number => Math.ceil(value / 0x1000) * 0x1000;
const writeHeader = (
buffer: Buffer,
options: {
readonly headerSize: number;
readonly uuidSeed: number;
readonly architecture: string;
},
): void => {
buffer.write(`dyld_v1 ${options.architecture}`.padEnd(15, " "), 0, "latin1");
buffer.writeUInt32LE(options.headerSize, 0x10);
buffer.writeUInt32LE(1, 0x14);
uuid(options.uuidSeed).copy(buffer, 0x58);
};
const writeMapping = (
buffer: Buffer,
at: number,
address: bigint,
size: number,
): void => {
buffer.writeBigUInt64LE(address, at);
buffer.writeBigUInt64LE(BigInt(size), at + 8);
buffer.writeBigUInt64LE(0n, at + 16);
buffer.writeUInt32LE(5, at + 24);
buffer.writeUInt32LE(5, at + 28);
};
/** Build a main cache (modern or legacy header) plus an optional `.01` subcache. */
export const dyldCacheFixture = (
images: readonly CacheFixtureImage[],
options: {
readonly legacy?: boolean;
readonly architecture?: string;
readonly subcacheUuidSeed?: number;
} = {},
): CacheFixture => {
const architecture = options.architecture ?? "arm64e";
const legacy = options.legacy === true;
const headerSize = legacy ? LEGACY_HEADER : MODERN_HEADER;
const sub = images.filter(({ inSubcache }) => inSubcache === true);
const main = images.filter(({ inSubcache }) => inSubcache !== true);
const mappingTable = headerSize;
const imageTable = mappingTable + 32;
const subTable = imageTable + images.length * 32;
const pathsAt = subTable + (sub.length > 0 && !legacy ? 56 : 0);
const paths = images.map(({ path }) => Buffer.from(`${path}\0`));
let cursor = align(
pathsAt + paths.reduce((total, path) => total + path.length, 0),
);
const mainOffsets = main.map((image) => {
const offset = cursor;
cursor = align(cursor + image.bytes.length);
return offset;
});
const mainFile = Buffer.alloc(cursor);
writeHeader(mainFile, { headerSize, uuidSeed: 1, architecture });
writeMapping(mainFile, mappingTable, MAIN_BASE, mainFile.length);
if (legacy) {
mainFile.writeUInt32LE(imageTable, 0x18);
mainFile.writeUInt32LE(images.length, 0x1c);
} else {
mainFile.writeUInt32LE(1, 0xd8);
mainFile.writeBigUInt64LE(MAIN_BASE, 0xe0);
mainFile.writeBigUInt64LE(0x20000000n, 0xe8);
mainFile.writeBigUInt64LE(0x10000000n, 0xf0);
mainFile.writeUInt32LE(0x001a0600, 0x16c);
mainFile.writeUInt32LE(imageTable, 0x1c0);
mainFile.writeUInt32LE(images.length, 0x1c4);
}
let pathCursor = pathsAt;
let subCursor = MODERN_HEADER + 32;
const subOffsets = new Map<string, number>();
for (const image of sub) {
subCursor = align(subCursor);
subOffsets.set(image.path, subCursor);
subCursor += image.bytes.length;
}
images.forEach((image, index) => {
const row = imageTable + index * 32;
const mainIndex = main.indexOf(image);
const address =
mainIndex >= 0
? MAIN_BASE + BigInt(mainOffsets[mainIndex] ?? 0)
: SUB_BASE + BigInt(subOffsets.get(image.path) ?? 0);
mainFile.writeBigUInt64LE(address, row);
mainFile.writeUInt32LE(pathCursor, row + 24);
const path = paths[index] ?? Buffer.alloc(1);
path.copy(mainFile, pathCursor);
pathCursor += path.length;
if (mainIndex >= 0)
Buffer.from(image.bytes).copy(mainFile, mainOffsets[mainIndex] ?? 0);
});
if (sub.length === 0 || legacy) return { main: mainFile, subcaches: [] };
mainFile.writeUInt32LE(subTable, 0x188);
mainFile.writeUInt32LE(1, 0x18c);
uuid(2).copy(mainFile, subTable);
mainFile.writeBigUInt64LE(SUB_BASE - MAIN_BASE, subTable + 16);
mainFile.write(".01", subTable + 24, "latin1");
const subFile = Buffer.alloc(align(subCursor));
writeHeader(subFile, {
headerSize: MODERN_HEADER,
uuidSeed: options.subcacheUuidSeed ?? 2,
architecture,
});
writeMapping(subFile, MODERN_HEADER, SUB_BASE, subFile.length);
for (const image of sub)
Buffer.from(image.bytes).copy(subFile, subOffsets.get(image.path) ?? 0);
return { main: mainFile, subcaches: [{ suffix: ".01", bytes: subFile }] };
};
@@ -0,0 +1,780 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { open, realpath, type FileHandle } from "node:fs/promises";
import { applePlatform } from "../../domain/apple/applePlatforms.js";
import type { MachoImageFacts } from "../../domain/apple/dylibResolution.js";
import type {
DyldCacheMapping,
DyldCacheSubcache,
DyldSharedCacheHeader,
} from "../../domain/apple/dyldSharedCache.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import { readMachoImage } from "./MachoLoadCommandReader.js";
const MAGIC_PREFIX = "dyld_v1";
const DEVELOPMENT_EXTENSION = ".development";
/** Header fields are valid only below `mappingOffset`; real headers are under 1 KiB. */
const MAX_HEADER_BYTES = 64 * 1024;
const MAX_IMAGES = 200_000;
const MAX_MAPPINGS = 64;
const MAX_SUBCACHES = 256;
const MAX_PATH_BYTES = 4096;
const BLOCK_BYTES = 64 * 1024;
const HASH_CHUNK_BYTES = 1024 * 1024;
/** Byte offsets of `dyld_cache_header` fields in Apple's open-source dyld. */
const FIELD = {
mappingOffset: 0x10,
mappingCount: 0x14,
imagesOffsetOld: 0x18,
imagesCountOld: 0x1c,
uuid: 0x58,
cacheType: 0x68,
platform: 0xd8,
formatBits: 0xdc,
sharedRegionStart: 0xe0,
sharedRegionSize: 0xe8,
maxSlide: 0xf0,
osVersion: 0x16c,
altPlatform: 0x170,
altOsVersion: 0x174,
subCacheArrayOffset: 0x188,
subCacheArrayCount: 0x18c,
symbolFileUuid: 0x190,
imagesOffset: 0x1c0,
imagesCount: 0x1c4,
cacheSubType: 0x1c8,
} as const;
const hex = (value: bigint | number): string => `0x${value.toString(16)}`;
const uuidText = (bytes: Buffer): string => {
const text = bytes.toString("hex").toUpperCase();
return `${text.slice(0, 8)}-${text.slice(8, 12)}-${text.slice(12, 16)}-${text.slice(16, 20)}-${text.slice(20)}`;
};
const safeNumber = (value: bigint, label: string): number => {
if (value > BigInt(Number.MAX_SAFE_INTEGER))
throw new ArtifactReaderFailure(
"format",
`dyld cache ${label} exceeds the safe range`,
);
return Number(value);
};
/** Block-cached positional reads over one cache file. */
class CacheFile {
readonly #blocks = new Map<number, Buffer>();
private constructor(
readonly suffix: string,
readonly handle: FileHandle,
readonly size: number,
/** Device, inode, size, and nanosecond change times of the file as opened. */
readonly identity: string,
) {}
static async open(path: string, suffix: string): Promise<CacheFile> {
const handle = await open(
await realpath(path),
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
try {
const metadata = await handle.stat({ bigint: true });
if (!metadata.isFile())
throw new ArtifactReaderFailure(
"path",
`Dyld cache path is not a regular file: ${path}`,
);
return new CacheFile(
suffix,
handle,
safeNumber(metadata.size, "file size"),
CacheFile.identify(metadata),
);
} catch (cause: unknown) {
await handle.close();
throw cause;
}
}
static identify(metadata: {
readonly dev: bigint;
readonly ino: bigint;
readonly size: bigint;
readonly mtimeNs: bigint;
readonly ctimeNs: bigint;
}): string {
return [
metadata.dev,
metadata.ino,
metadata.size,
metadata.mtimeNs,
metadata.ctimeNs,
].join(":");
}
/** Whether the open file still matches the identity recorded at open. */
async unchanged(): Promise<boolean> {
return (
CacheFile.identify(await this.handle.stat({ bigint: true })) ===
this.identity
);
}
async read(offset: number, length: number): Promise<Buffer> {
if (offset < 0 || offset + length > this.size)
throw new ArtifactReaderFailure(
"format",
`dyld cache read at ${hex(offset)} extends beyond ${this.suffix === "" ? "the main file" : `subcache ${this.suffix}`}`,
);
if (length > BLOCK_BYTES) {
const bytes = Buffer.alloc(length);
await this.readInto(bytes, 0, length, offset);
return bytes;
}
const chunks: Buffer[] = [];
for (let position = offset; position < offset + length;) {
const index = Math.floor(position / BLOCK_BYTES);
let block = this.#blocks.get(index);
if (block === undefined) {
const start = index * BLOCK_BYTES;
block = Buffer.alloc(Math.min(BLOCK_BYTES, this.size - start));
await this.readInto(block, 0, block.length, start);
this.#blocks.set(index, block);
}
const from = position - index * BLOCK_BYTES;
const take = Math.min(block.length - from, offset + length - position);
chunks.push(block.subarray(from, from + take));
position += take;
}
return chunks.length === 1
? (chunks[0] ?? Buffer.alloc(0))
: Buffer.concat(chunks);
}
/** Positional read that retries short reads; slow filesystems may return partial data. */
private async readInto(
target: Buffer,
targetOffset: number,
length: number,
position: number,
): Promise<void> {
let done = 0;
while (done < length) {
const { bytesRead } = await this.handle.read(
target,
targetOffset + done,
length - done,
position + done,
);
if (bytesRead === 0)
throw new ArtifactReaderFailure(
"format",
`dyld cache read at ${hex(position)} is truncated`,
);
done += bytesRead;
}
}
async cString(offset: number): Promise<string> {
const bytes = await this.read(
offset,
Math.min(MAX_PATH_BYTES, this.size - offset),
);
const end = bytes.indexOf(0);
if (end < 0)
throw new ArtifactReaderFailure(
"format",
`dyld cache string at ${hex(offset)} is unterminated`,
);
return bytes.toString("utf8", 0, end);
}
}
interface CacheRegion {
readonly file: CacheFile;
readonly address: number;
readonly size: number;
readonly fileOffset: number;
}
/** Validated disjoint regions are sorted once; all VM lookups use logarithmic search. */
const cacheRegionAt = (
regions: readonly CacheRegion[],
address: number,
): CacheRegion | undefined => {
let low = 0;
let high = regions.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
const region = regions[middle];
if (region !== undefined && region.address <= address) low = middle + 1;
else high = middle;
}
const region = regions[low - 1];
return region !== undefined && address < region.address + region.size
? region
: undefined;
};
interface ParsedHeader {
readonly magic: string;
readonly uuid: string;
readonly mappings: readonly {
readonly address: bigint;
readonly size: bigint;
readonly fileOffset: bigint;
readonly maxProt: number;
readonly initProt: number;
}[];
readonly header: Buffer;
readonly length: number;
}
const has = (parsed: ParsedHeader, offset: number, width: number): boolean =>
parsed.length >= offset + width;
const readHeader = async (file: CacheFile): Promise<ParsedHeader> => {
if (file.size < 0x20)
throw new ArtifactReaderFailure(
"format",
"File is too short to be a dyld shared cache",
);
const prefix = await file.read(0, 0x20);
const magic = prefix.toString("latin1", 0, 16).replace(/\0+$/u, "");
if (!magic.startsWith(MAGIC_PREFIX))
throw new ArtifactReaderFailure(
"format",
"File is not a dyld shared cache",
);
const mappingOffset = prefix.readUInt32LE(FIELD.mappingOffset);
const mappingCount = prefix.readUInt32LE(FIELD.mappingCount);
if (
mappingOffset < 0x20 ||
mappingOffset > MAX_HEADER_BYTES ||
mappingCount === 0 ||
mappingCount > MAX_MAPPINGS
)
throw new ArtifactReaderFailure(
"format",
"dyld cache header has an invalid mapping table",
);
const header = await file.read(0, mappingOffset);
const table = await file.read(mappingOffset, mappingCount * 32);
const mappings = Array.from({ length: mappingCount }, (_, index) => ({
address: table.readBigUInt64LE(index * 32),
size: table.readBigUInt64LE(index * 32 + 8),
fileOffset: table.readBigUInt64LE(index * 32 + 16),
maxProt: table.readUInt32LE(index * 32 + 24),
initProt: table.readUInt32LE(index * 32 + 28),
}));
const parsed = { magic, uuid: "", mappings, header, length: mappingOffset };
return {
...parsed,
uuid: has(parsed, FIELD.uuid, 16)
? uuidText(header.subarray(FIELD.uuid, FIELD.uuid + 16))
: "",
};
};
const cacheArchitecture = (magic: string): string =>
magic.slice(MAGIC_PREFIX.length).trim();
/** Lowest mapping address: the subcache base `cacheVMOffset` is measured from. */
const mappingBase = (parsed: ParsedHeader): bigint => {
const first = parsed.mappings[0];
if (first === undefined)
throw new ArtifactReaderFailure("format", "dyld cache has no mappings");
return parsed.mappings.reduce(
(lowest, mapping) => (mapping.address < lowest ? mapping.address : lowest),
first.address,
);
};
const version = (value: number): string | null =>
value === 0
? null
: `${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`;
/** dyld_cache_header.formatBits simulator bit follows the 8-bit version and disk flag. */
const SIMULATOR_CACHE_FLAG = 1 << 9;
const SIMULATOR_PLATFORMS: ReadonlyMap<number, number> = new Map([
[2, 7],
[3, 8],
[4, 9],
[11, 12],
]);
const cachePlatform = (id: number | null, simulator: boolean | null) =>
id === null
? null
: applePlatform(
simulator === true ? (SIMULATOR_PLATFORMS.get(id) ?? id) : id,
);
/**
* Decode `cacheType` according to header generation. Modern headers
* (those carrying `cacheSubType`) use `0 = development`, `1 = production`;
* older single-file headers use the historical `1 = development`,
* `0 = optimized` polarity.
*/
const decodeCacheType = (
cacheType: number | null,
modern: boolean,
): DyldSharedCacheHeader["cache_type"] => {
if (cacheType === null) return null;
if (modern)
return cacheType === 0
? "development"
: cacheType === 1
? "production"
: cacheType === 2
? "multi-cache"
: null;
return cacheType === 1
? "development"
: cacheType === 0
? "production"
: null;
};
/** A main dyld shared cache file and the subcaches it names. */
export class DyldSharedCache {
readonly #byPath: ReadonlyMap<
string,
{ readonly path: string; readonly address: number }
>;
private constructor(
readonly header: DyldSharedCacheHeader,
readonly images: readonly {
readonly path: string;
readonly address: number;
}[],
private readonly files: readonly CacheFile[],
private readonly regions: readonly CacheRegion[],
) {
this.#byPath = new Map(images.map((image) => [image.path, image]));
}
/** Open the main cache file and every subcache beside it. */
static async open(
path: string,
signal?: AbortSignal,
): Promise<DyldSharedCache> {
// Inspection and tracing share this boundary: companion discovery follows
// the actual main cache file, while adapters keep the selected path spelling.
const canonicalPath = await realpath(path);
const main = await CacheFile.open(canonicalPath, "");
const files = [main];
try {
const parsed = await readHeader(main);
const { named, entries: subcaches } =
await DyldSharedCache.#subcacheEntries(main, parsed);
// With named suffixes, dyld strips a development main file's extension
// first: dyld_shared_cache_arm64e.development -> dyld_shared_cache_arm64e.01.development.
const base =
named && canonicalPath.endsWith(DEVELOPMENT_EXTENSION)
? canonicalPath.slice(0, -DEVELOPMENT_EXTENSION.length)
: canonicalPath;
const statuses: DyldCacheSubcache[] = [];
const parsedFiles = [{ file: main, parsed }];
for (const entry of subcaches) {
signal?.throwIfAborted();
let file: CacheFile;
try {
file = await CacheFile.open(`${base}${entry.suffix}`, entry.suffix);
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
cause.code === "ENOENT"
) {
statuses.push({ ...entry, status: "missing", observed_uuid: null });
continue;
}
throw cause;
}
files.push(file);
const subParsed = await readHeader(file);
// UUID, architecture, and VM offset are one admission decision.
// A later overlap or image-address check cannot repair a companion
// that already contradicts the main header.
const status = DyldSharedCache.#admitSubcache(parsed, entry, subParsed);
statuses.push({
...entry,
status,
observed_uuid: subParsed.uuid,
});
if (status === "present") parsedFiles.push({ file, parsed: subParsed });
}
const header = DyldSharedCache.#summary(parsed, parsedFiles, statuses);
const images = await DyldSharedCache.#images(main, parsed, signal);
const regions = parsedFiles.flatMap(({ file, parsed: item }) =>
item.mappings.map((mapping) => {
const fileOffset = safeNumber(
mapping.fileOffset,
"mapping file offset",
);
const size = safeNumber(mapping.size, "mapping size");
const address = safeNumber(mapping.address, "mapping address");
// A truncated file that declares mappings beyond its bytes cannot
// map those addresses; drop the unverified extent.
if (
fileOffset < 0 ||
size <= 0 ||
fileOffset + size > file.size ||
!Number.isSafeInteger(address + size)
)
throw new ArtifactReaderFailure(
"format",
`dyld cache mapping in ${file.suffix === "" ? "the main file" : `subcache ${file.suffix}`} extends beyond its file`,
);
return {
file,
address,
size,
fileOffset,
};
}),
);
const ordered = regions.toSorted(
(left, right) => left.address - right.address,
);
for (let index = 1; index < ordered.length; index++) {
const previous = ordered[index - 1];
const current = ordered[index];
if (
previous !== undefined &&
current !== undefined &&
current.address < previous.address + previous.size
)
throw new ArtifactReaderFailure(
"format",
`Dyld cache VM mappings overlap at ${hex(current.address)} between ${previous.file.suffix || "main"} and ${current.file.suffix || "main"}`,
);
}
if (statuses.every(({ status }) => status === "present")) {
const unmapped = images.find(
({ address }) => cacheRegionAt(ordered, address) === undefined,
);
if (unmapped !== undefined)
throw new ArtifactReaderFailure(
"format",
`Complete dyld cache has an unmapped image address ${hex(unmapped.address)}: ${unmapped.path}`,
);
}
return new DyldSharedCache(header, images, files, ordered);
} catch (cause: unknown) {
await Promise.allSettled(files.map(({ handle }) => handle.close()));
throw cause;
}
}
static async #subcacheEntries(
main: CacheFile,
parsed: ParsedHeader,
): Promise<{
/** Entries carry explicit file suffixes (dyld_subcache_entry v2). */
readonly named: boolean;
readonly entries: {
readonly suffix: string;
readonly uuid: string;
readonly vm_offset: string;
}[];
}> {
if (!has(parsed, FIELD.subCacheArrayCount, 4))
return { named: false, entries: [] };
const offset = parsed.header.readUInt32LE(FIELD.subCacheArrayOffset);
const count = parsed.header.readUInt32LE(FIELD.subCacheArrayCount);
// Headers that include cacheSubType use entries with an explicit file suffix.
const named = has(parsed, FIELD.cacheSubType, 4);
if (count === 0) return { named, entries: [] };
if (count > MAX_SUBCACHES)
throw new ArtifactReaderFailure(
"format",
"dyld cache lists too many subcaches",
);
const size = named ? 56 : 24;
const table = await main.read(offset, count * size);
const entries = Array.from({ length: count }, (_, index) => {
const base = index * size;
const suffix = named
? table.toString("latin1", base + 24, base + 56).replace(/\0.*$/su, "")
: `.${index + 1}`;
if (!/^\.[A-Za-z0-9.]+$/u.test(suffix))
throw new ArtifactReaderFailure(
"format",
"dyld subcache has an unsafe file suffix",
);
return {
suffix,
uuid: uuidText(table.subarray(base, base + 16)),
vm_offset: hex(table.readBigUInt64LE(base + 16)),
};
});
return { named, entries };
}
/**
* Whether a companion file is the subcache the main header named.
* UUID, magic, and `cacheVMOffset` are checked together; failure to match
* the UUID leaves the cache partial, while a matching UUID that contradicts
* the main header is a format error and contributes no mappings.
*/
static #admitSubcache(
main: ParsedHeader,
entry: {
readonly suffix: string;
readonly uuid: string;
readonly vm_offset: string;
},
sub: ParsedHeader,
): "present" | "uuid-mismatch" {
if (sub.uuid !== entry.uuid) return "uuid-mismatch";
if (sub.magic !== main.magic)
throw new ArtifactReaderFailure(
"format",
`dyld subcache ${entry.suffix} architecture ${cacheArchitecture(sub.magic)} does not match the main cache architecture ${cacheArchitecture(main.magic)}`,
);
const mainBase = mappingBase(main);
const actual = mappingBase(sub);
const expected = mainBase + BigInt(entry.vm_offset);
if (actual !== expected)
throw new ArtifactReaderFailure(
"format",
`dyld subcache ${entry.suffix} mapping base ${hex(actual)} disagrees with cache VM offset ${entry.vm_offset} from main base ${hex(mainBase)}`,
);
return "present";
}
static #summary(
parsed: ParsedHeader,
files: readonly {
readonly file: CacheFile;
readonly parsed: ParsedHeader;
}[],
subcaches: readonly DyldCacheSubcache[],
): DyldSharedCacheHeader {
const header = parsed.header;
const u32 = (offset: number): number | null =>
has(parsed, offset, 4) ? header.readUInt32LE(offset) : null;
const u64 = (offset: number): string | null =>
has(parsed, offset, 8) ? hex(header.readBigUInt64LE(offset)) : null;
const platform = u32(FIELD.platform);
const altPlatform = u32(FIELD.altPlatform);
const formatBits = u32(FIELD.formatBits);
const simulator =
formatBits === null ? null : (formatBits & SIMULATOR_CACHE_FLAG) !== 0;
const cacheType = has(parsed, FIELD.cacheType, 8)
? Number(header.readBigUInt64LE(FIELD.cacheType))
: null;
const cacheSubType = cacheType === 2 ? u32(FIELD.cacheSubType) : null;
const symbols = has(parsed, FIELD.symbolFileUuid, 16)
? header.subarray(FIELD.symbolFileUuid, FIELD.symbolFileUuid + 16)
: undefined;
const mappings: DyldCacheMapping[] = files.flatMap(
({ file, parsed: item }) =>
item.mappings.map((mapping) => ({
file: file.suffix,
address: hex(mapping.address),
size: hex(mapping.size),
file_offset: hex(mapping.fileOffset),
max_protection: mapping.maxProt,
initial_protection: mapping.initProt,
})),
);
return {
magic: parsed.magic,
architecture: cacheArchitecture(parsed.magic),
uuid: parsed.uuid,
platform: cachePlatform(platform, simulator),
header_platform: platform === null ? null : applePlatform(platform),
header_alt_platform:
altPlatform === null || altPlatform === 0
? null
: applePlatform(altPlatform),
simulator,
os_version: version(u32(FIELD.osVersion) ?? 0),
alt_platform:
altPlatform === null || altPlatform === 0
? null
: cachePlatform(altPlatform, simulator),
alt_os_version: version(u32(FIELD.altOsVersion) ?? 0),
cache_type: decodeCacheType(
cacheType,
has(parsed, FIELD.cacheSubType, 4),
),
cache_subtype:
cacheSubType !== null
? {
id: cacheSubType,
name:
cacheSubType === 0
? "development"
: cacheSubType === 1
? "production"
: null,
}
: null,
shared_region:
u64(FIELD.sharedRegionStart) === null
? null
: {
start: u64(FIELD.sharedRegionStart) ?? "0x0",
size: u64(FIELD.sharedRegionSize) ?? "0x0",
},
max_slide: u64(FIELD.maxSlide),
mappings,
subcaches: [...subcaches],
symbols_file_uuid:
symbols === undefined || symbols.every((byte) => byte === 0)
? null
: uuidText(symbols),
};
}
static async #images(
main: CacheFile,
parsed: ParsedHeader,
signal?: AbortSignal,
): Promise<{ readonly path: string; readonly address: number }[]> {
const modern = has(parsed, FIELD.imagesCount, 4);
const offset = parsed.header.readUInt32LE(
modern ? FIELD.imagesOffset : FIELD.imagesOffsetOld,
);
const count = parsed.header.readUInt32LE(
modern ? FIELD.imagesCount : FIELD.imagesCountOld,
);
if (count > MAX_IMAGES)
throw new ArtifactReaderFailure(
"limit",
`dyld cache lists more than ${MAX_IMAGES} images`,
);
const table = await main.read(offset, count * 32);
const images: { path: string; address: number }[] = [];
for (let index = 0; index < count; index++) {
if (index % 512 === 0) signal?.throwIfAborted();
images.push({
address: safeNumber(table.readBigUInt64LE(index * 32), "image address"),
path: await main.cString(table.readUInt32LE(index * 32 + 24)),
});
}
return images;
}
/** Locate an image by its exact install path. */
find(
path: string,
): { readonly path: string; readonly address: number } | undefined {
return this.#byPath.get(path);
}
/**
* Whether an install path is listed and its address lies in a mapping of an
* admitted file. This is address coverage: `imageFacts` has to parse a
* compatible Mach-O before the path is a shared-cache load.
*/
locate(path: string): "mapped" | "unverified" | "absent" {
const image = this.find(path);
if (image === undefined) return "absent";
return this.#region(image.address) === undefined ? "unverified" : "mapped";
}
#region(address: number) {
return cacheRegionAt(this.regions, address);
}
/** Parse one cached image's load commands through the cache's VM mappings. */
async imageFacts(
path: string,
): Promise<
{ readonly file: string; readonly facts: MachoImageFacts } | undefined
> {
const image = this.find(path);
if (image === undefined) return undefined;
const region = this.#region(image.address);
if (region === undefined) return undefined;
const base = region.fileOffset + (image.address - region.address);
const available = region.size - (image.address - region.address);
const facts = await readMachoImage(
async (offset, length) =>
region.file.read(
base + offset,
Math.max(0, Math.min(length, available - offset)),
),
available,
);
return { file: region.file.suffix, facts };
}
/** Digest of the main file through the handle its header was parsed from. */
async mainSha256(signal?: AbortSignal): Promise<string> {
const main = this.files[0];
if (main === undefined)
throw new ArtifactReaderFailure("format", "dyld cache has no main file");
return this.hashFile(main, signal);
}
/** SHA-256 of every readable subcache, keyed by suffix, through their open handles. */
async subcacheSha256(signal?: AbortSignal): Promise<Record<string, string>> {
const out: Record<string, string> = {};
for (const file of this.files.slice(1)) {
signal?.throwIfAborted();
out[file.suffix] = await this.hashFile(file, signal);
}
return out;
}
private async hashFile(
file: CacheFile,
signal?: AbortSignal,
): Promise<string> {
// In-place rewrites through the same inode keep this handle valid, so a
// digest is bound to its bytes only when the identity is unchanged.
if (!(await file.unchanged()))
throw new ArtifactReaderFailure(
"integrity",
"dyld cache file changed before its digest was read",
);
const hash = createHash("sha256");
const buffer = Buffer.alloc(HASH_CHUNK_BYTES);
let position = 0;
for (;;) {
signal?.throwIfAborted();
if (position >= file.size) break;
const { bytesRead } = await file.handle.read(
buffer,
0,
Math.min(buffer.length, file.size - position),
position,
);
// A file truncated after open must not yield the surviving prefix's
// digest as the identity of the recorded size.
if (bytesRead === 0)
throw new ArtifactReaderFailure(
"integrity",
"dyld cache file changed while its digest was read",
);
hash.update(buffer.subarray(0, bytesRead));
position += bytesRead;
}
if (position !== file.size)
throw new ArtifactReaderFailure(
"integrity",
"dyld cache file changed while its digest was read",
);
if (!(await file.unchanged()))
throw new ArtifactReaderFailure(
"integrity",
"dyld cache file changed while its digest was read",
);
return hash.digest("hex");
}
async close(): Promise<void> {
await Promise.allSettled(this.files.map(({ handle }) => handle.close()));
}
}
+69 -5
View File
@@ -1,7 +1,7 @@
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 { join, relative, resolve, sep } from "node:path";
import { resolveTreePath } from "../../domain/apple/dyldPaths.js";
import {
@@ -9,12 +9,18 @@ import {
dylibResolutionResultSchema,
traceDylibLoading,
type DylibResolutionResult,
type DylibSharedCacheView,
type DylibTreeEntry,
type DylibTreeView,
type MachoImageFacts,
} from "../../domain/apple/dylibResolution.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { DyldSharedCache } from "./DyldSharedCacheReader.js";
import {
filesystemDyldLinkTarget,
filesystemDyldLookupPath,
} from "./FilesystemDyldPaths.js";
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
const HASH_CHUNK_BYTES = 1024 * 1024;
@@ -61,11 +67,14 @@ class FilesystemTreeView implements DylibTreeView {
async #readEntry(path: string): Promise<DylibTreeEntry | undefined> {
cancelled(this.signal);
const absolute = join(this.root, path);
const absolute = filesystemDyldLookupPath(this.root, path);
try {
const metadata = await lstat(absolute);
if (metadata.isSymbolicLink())
return { kind: "symlink", target: await readlink(absolute) };
return {
kind: "symlink",
target: filesystemDyldLinkTarget(await readlink(absolute)),
};
if (metadata.isDirectory()) return { kind: "directory" };
return metadata.isFile() ? { kind: "file" } : undefined;
} catch (cause: unknown) {
@@ -85,11 +94,18 @@ export const traceDylibResolution = async (options: {
readonly signal?: AbortSignal;
}): Promise<DylibResolutionResult> => {
const parsed = dylibResolutionInputSchema.safeParse(options.parameters);
if (!parsed.success)
if (!parsed.success) {
const issue = parsed.error.issues[0];
const at =
issue !== undefined && issue.path.length > 0
? ` (${issue.path.join(".")} ${issue.message})`
: "";
throw new ArtifactReaderFailure(
"path",
"roots must be normalized paths relative to the analyzed root",
`dylib resolution parameters are invalid${at}`,
);
}
let cache: DyldSharedCache | undefined;
// Canonicalization is inside the translated region: a revoked permission
// keeps its reason and path.
let root = options.rootPath;
@@ -99,6 +115,7 @@ export const traceDylibResolution = async (options: {
.split(sep)
.join("/");
const view = new FilesystemTreeView(root, options.signal);
cache = await openSharedCache(parsed.data.shared_cache, options.signal);
const { roots: requested, unclassified } =
parsed.data.roots !== undefined
? { roots: parsed.data.roots, unclassified: [] }
@@ -112,6 +129,7 @@ export const traceDylibResolution = async (options: {
...(parsed.data.architecture === undefined
? {}
: { architecture: parsed.data.architecture }),
...(cache === undefined ? {} : { sharedCache: sharedCacheView(cache) }),
...(options.signal === undefined ? {} : { signal: options.signal }),
});
// Each digest must describe the same file whose headers were parsed.
@@ -137,10 +155,24 @@ export const traceDylibResolution = async (options: {
"integrity",
`Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`,
);
// Bind the trace to the cache bytes that produced it: a replaced cache
// can retain header UUID/architecture while changing resolutions.
const cacheIdentity =
cache === undefined || parsed.data.shared_cache === undefined
? null
: {
path: parsed.data.shared_cache,
uuid: cache.header.uuid,
architecture: cache.header.architecture,
os_version: cache.header.os_version,
main_file_sha256: await cache.mainSha256(options.signal),
subcache_sha256: await cache.subcacheSha256(options.signal),
};
return dylibResolutionResultSchema.parse({
...trace,
root_path: options.rootPath,
target_sha256: options.targetSha256,
shared_cache: cacheIdentity,
images: trace.images.map((image) => ({
...image,
sha256: digests.get(image.path),
@@ -161,9 +193,41 @@ export const traceDylibResolution = async (options: {
{ cause },
);
throw cause;
} finally {
await cache?.close();
}
};
const openSharedCache = async (
path: string | undefined,
signal?: AbortSignal,
): Promise<DyldSharedCache | undefined> => {
if (path === undefined) return undefined;
try {
return await DyldSharedCache.open(resolve(path), signal);
} catch (cause: unknown) {
if (missing(cause))
throw new ArtifactReaderFailure(
"path",
"shared_cache does not name an existing dyld shared cache file",
{ cause },
);
throw cause;
}
};
const sharedCacheView = (cache: DyldSharedCache): DylibSharedCacheView => ({
architecture: cache.header.architecture,
platforms: [cache.header.platform, cache.header.alt_platform].filter(
(platform) => platform !== null,
),
unavailableSubcaches: cache.header.subcaches
.filter(({ status }) => status !== "present")
.map(({ suffix, status }) => `${suffix} (${status})`),
lookup: (path) => cache.locate(path),
image: async (path) => (await cache.imageFacts(path))?.facts,
});
/**
* 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
@@ -0,0 +1,53 @@
import { expect, it } from "vitest";
import { resolveTreePath } from "../../domain/apple/dyldPaths.js";
import type { DylibTreeEntry } from "../../domain/apple/dylibResolution.js";
import {
filesystemDyldLinkTarget,
filesystemDyldLookupPath,
} from "./FilesystemDyldPaths.js";
it("normalizes Windows relative links before containment checks without probing an escaped path", async () => {
const requested: string[] = [];
const result = await resolveTreePath(
{
entry: async (path: string): Promise<DylibTreeEntry | undefined> => {
requested.push(path);
return path === "link"
? {
kind: "symlink",
target: filesystemDyldLinkTarget("..\\outside.dylib", "win32"),
}
: undefined;
},
image: async () => ({ status: "not-mach-o" }),
},
"link",
);
expect(result).toEqual({ kind: "escapes" });
expect(requested).toEqual(["link"]);
});
it("keeps POSIX backslash bytes and preserves Windows dot segments for segment-wise traversal", () => {
expect(filesystemDyldLinkTarget("..\\outside.dylib", "darwin")).toBe(
"..\\outside.dylib",
);
expect(filesystemDyldLinkTarget("nested\\..\\target", "win32")).toBe(
"nested/../target",
);
expect(filesystemDyldLinkTarget("C:\\outside", "win32")).toBe("/C:/outside");
expect(filesystemDyldLinkTarget("C:/literal-posix-directory", "darwin")).toBe(
"C:/literal-posix-directory",
);
expect(() => filesystemDyldLinkTarget("C:drive-relative", "win32")).toThrow(
"Windows drive working directory",
);
});
it("does not let Windows reinterpret a literal backslash-bearing loader name as traversal", () => {
expect(() =>
filesystemDyldLookupPath("C:\\root", "..\\outside.dylib", "win32"),
).toThrow("cannot be represented on Windows");
expect(
filesystemDyldLookupPath("/root", "literal\\name.dylib", "darwin"),
).toBe("/root/literal\\name.dylib");
});
@@ -0,0 +1,39 @@
import { posix, win32 } from "node:path";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
/**
* Convert host filesystem separators into the tree view's POSIX traversal
* namespace. Dot segments stay intact so they apply after symlink expansion;
* POSIX backslash filename bytes are never reinterpreted as separators.
*/
export const filesystemDyldLinkTarget = (
reported: string,
platform: NodeJS.Platform = process.platform,
): string => {
if (platform !== "win32") return reported;
const portable = reported.replaceAll("\\", "/");
if (win32.isAbsolute(reported))
return portable.startsWith("/") ? portable : `/${portable}`;
if (/^[A-Za-z]:/u.test(reported))
throw new ArtifactReaderFailure(
"unavailable",
`Drive-relative symlink target depends on a Windows drive working directory: ${reported}`,
);
return portable;
};
/** Map a portable lookup to the host without allowing extra native separators to escape it. */
export const filesystemDyldLookupPath = (
root: string,
lookup: string,
platform: NodeJS.Platform = process.platform,
): string => {
if (platform === "win32" && lookup.includes("\\"))
throw new ArtifactReaderFailure(
"unavailable",
`Literal backslashes in a POSIX dyld lookup cannot be represented on Windows: ${lookup}. Analyze this tree on a POSIX host.`,
);
return platform === "win32"
? win32.join(root, lookup)
: posix.join(root, lookup);
};
+13
View File
@@ -10,6 +10,7 @@ export const LC = {
RPATH: 0x8000001c,
DYLD_ENVIRONMENT: 0x27,
CODE_SIGNATURE: 0x1d,
BUILD_VERSION: 0x32,
} as const;
export const CPU = {
@@ -75,6 +76,18 @@ export const codeSignatureCommand = (): Uint8Array => {
return bytes;
};
/** `build_version_command` for a `PLATFORM_*` value, with no tools. */
export const buildVersionCommand = (platform: number): Uint8Array => {
const bytes = new Uint8Array(24);
const view = new DataView(bytes.buffer);
view.setUint32(0, LC.BUILD_VERSION, true);
view.setUint32(4, 24, true);
view.setUint32(8, platform, true);
view.setUint32(12, 0x000f0000, true);
view.setUint32(16, 0x001a0000, 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 };
@@ -4,6 +4,7 @@ import {
CPU,
FILE_TYPE,
LC,
buildVersionCommand,
codeSignatureCommand,
dyldEnvironmentCommand,
dylibCommand,
@@ -40,6 +41,9 @@ describe("Mach-O load command reader", () => {
rpathCommand("/usr/lib/swift"),
dyldEnvironmentCommand("DYLD_FRAMEWORK_PATH=@executable_path"),
codeSignatureCommand(),
// A zippered image builds for macOS and Mac Catalyst.
buildVersionCommand(1),
buildVersionCommand(6),
],
});
const facts = await read(image);
@@ -54,6 +58,10 @@ describe("Mach-O load command reader", () => {
rpaths: ["@loader_path/Frameworks", "/usr/lib/swift"],
dyld_environment: ["DYLD_FRAMEWORK_PATH=@executable_path"],
code_signature_present: true,
platforms: [
{ id: 1, name: "macos" },
{ id: 6, name: "maccatalyst" },
],
});
expect(
slice?.dependencies.map(
@@ -166,6 +174,37 @@ describe("Mach-O load command reader", () => {
});
describe("Mach-O load command reader inputs", () => {
it("reads platforms from LC_VERSION_MIN_* and keeps unknown ids", async () => {
const versionMin = new Uint8Array(16);
const view = new DataView(versionMin.buffer);
view.setUint32(0, 0x25, true);
view.setUint32(4, 16, true);
const facts = await read(
machoImage({ commands: [versionMin, buildVersionCommand(99)] }),
);
expect(facts.status === "parsed" && facts.slices[0]?.platforms).toEqual([
{ id: 2, name: "ios" },
{ id: 99, name: null },
]);
// Legacy version-min commands on Intel slices denote simulators.
const intel = await read(
machoImage({ cpu: CPU.x86_64, commands: [versionMin] }),
);
expect(intel.status === "parsed" && intel.slices[0]?.platforms).toEqual([
{ id: 7, name: "ios-simulator" },
]);
// A 16-byte LC_BUILD_VERSION, and one that declares a tool it lacks.
const truncated = buildVersionCommand(1).slice(0, 16);
new DataView(truncated.buffer).setUint32(4, 16, true);
const missingTool = buildVersionCommand(1);
new DataView(missingTool.buffer).setUint32(20, 1, true);
for (const command of [truncated, missingTool])
expect(await read(machoImage({ commands: [command] }))).toEqual({
status: "malformed",
reason: "load command 0 is too short for a platform version",
});
});
it("names 32-bit and non-Mach-O inputs without guessing", async () => {
const i386 = await read(
machoImage({ cpu: { type: 7, subtype: 3 }, wide: false }),
+52 -1
View File
@@ -1,3 +1,4 @@
import { applePlatform } from "../../domain/apple/applePlatforms.js";
import type {
MachoDependency,
MachoImageFacts,
@@ -34,6 +35,14 @@ const LC_LOAD_UPWARD_DYLIB = 0x80000023;
const LC_RPATH = 0x8000001c;
const LC_DYLD_ENVIRONMENT = 0x27;
const LC_CODE_SIGNATURE = 0x1d;
const LC_BUILD_VERSION = 0x32;
/** LC_VERSION_MIN_* commands imply their platform. */
const VERSION_MIN_PLATFORMS: ReadonlyMap<number, number> = new Map([
[0x24, 1],
[0x25, 2],
[0x2f, 3],
[0x30, 4],
]);
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
const DYLIB_USE_MARKER = 0x1a741800;
@@ -242,6 +251,7 @@ const decodeCommands = (
rpaths: [],
dyld_environment: [],
code_signature_present: false,
platforms: [],
};
let offset = 0;
for (let index = 0; index < header.commandCount; index++) {
@@ -258,9 +268,16 @@ const decodeCommands = (
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
);
const body = bytes.subarray(offset, offset + size);
decodeCommand(command, body, index, slice);
decodeCommand(command, body, index, slice, header.cpuType);
offset += size;
}
// A header that declares fewer commands than its sizeofcmds could hide
// dependency loads in the unconsumed bytes.
if (offset !== bytes.byteLength)
throw new MachoFormatIssue(
"malformed",
`load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`,
);
return slice;
};
@@ -269,6 +286,7 @@ const decodeCommand = (
body: Uint8Array,
index: number,
slice: MachoSlice,
cpuType: number,
): void => {
if (command === LC_ID_DYLIB) {
slice.install_name = commandString(body, 24, index);
@@ -292,6 +310,39 @@ const decodeCommand = (
slice.code_signature_present = true;
return;
}
const implied = VERSION_MIN_PLATFORMS.get(command);
if (command === LC_BUILD_VERSION || implied !== undefined) {
// version_min_command is 16 bytes; build_version_command is 24 bytes
// followed by ntools 8-byte build_tool_version records.
const required =
implied !== undefined
? 16
: body.byteLength < 24
? 24
: 24 + viewOf(body).getUint32(20, true) * 8;
if (body.byteLength < required)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for a platform version`,
);
const declared = implied ?? viewOf(body).getUint32(8, true);
// Legacy LC_VERSION_MIN_* commands on Intel slices denote simulator
// platforms; Apple's decoder branches on the CPU type for these commands.
const intel = cpuType === 7 || cpuType === (7 | 0x01000000);
const id =
implied !== undefined && intel
? declared === 2
? 7
: declared === 3
? 8
: declared === 4
? 9
: declared
: declared;
if (!slice.platforms.some((platform) => platform.id === id))
slice.platforms.push(applePlatform(id));
return;
}
const name = DEPENDENCY_COMMANDS.get(command);
if (name !== undefined)
slice.dependencies.push(decodeDependency(name, body, index));
+35
View File
@@ -3,6 +3,7 @@ import { Cli, z } from "incur";
import { projectAndroidApplicationEvidence } from "../application/android/AndroidApplicationService.js";
import { projectAppleApplicationEvidence } from "../application/apple/AppleApplicationService.js";
import { inspectDyldSharedCacheEvidence } from "../application/apple/DyldSharedCacheService.js";
import {
compareApplicationVersionsEvidenceValidated,
compareJavaScriptExportShapesEvidenceValidated,
@@ -83,6 +84,7 @@ export const registerApplicationCommands = (
workflow: compareJavaScriptExportShapesEvidenceValidated,
});
registerObligationLedgerCommand(cli, logger);
registerDyldSharedCacheCommand(cli, logger);
registerCoverageCommand(cli, logger);
registerJsonCommand({
cli,
@@ -114,6 +116,39 @@ export const registerApplicationCommands = (
});
};
/** `inspect-dyld-shared-cache <cache> [--image <install path>]...` */
const registerDyldSharedCacheCommand = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.inspectDyldSharedCache, {
description:
"Inspect a dyld shared cache file set and, optionally, cached images",
args: z.object({
cachePath: z.string().describe("Main dyld shared cache file"),
}),
options: z.object({
image: z
.array(z.string())
.optional()
.describe("Install path of a cached image to inspect (repeatable)"),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.inspectDyldSharedCache, async () => {
const result = await inspectDyldSharedCacheEvidence({
cache_path: args.cachePath,
...(options.image === undefined ? {} : { images: options.image }),
});
return result.ok
? jsonValueSchema.parse(result.value)
: {
error: "Application workflow failed",
...projectAnalysisError(result.error),
};
}),
});
};
const registerObligationLedgerCommand = (
cli: CliInstance,
logger: Logger,
+9
View File
@@ -80,6 +80,12 @@ const registerDylibResolutionCommand = (
.enum(["arm64", "arm64e", "x86_64"])
.optional()
.describe("Trace only this slice of each root"),
sharedCache: z
.string()
.optional()
.describe(
"Main dyld shared cache file used to answer system install paths",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.traceDylibResolution, () =>
@@ -91,6 +97,9 @@ const registerDylibResolutionCommand = (
...(options.architecture === undefined
? {}
: { architecture: options.architecture }),
...(options.sharedCache === undefined
? {}
: { shared_cache: options.sharedCache }),
},
logger,
),
+1
View File
@@ -30,6 +30,7 @@ export const CLI_COMMANDS = Object.freeze({
decodeInterfaceBuilder: "decode-interface-builder",
inspectKeyedArchive: "inspect-keyed-archive",
traceDylibResolution: "trace-dylib-resolution",
inspectDyldSharedCache: "inspect-dyld-shared-cache",
inspectNativeDataType: "inspect-native-data-type",
inspectNativeLoadImage: "inspect-native-load-image",
readBytes: "read-bytes",
+26
View File
@@ -1,4 +1,8 @@
import { applicationVersionComparisonResultSchema } from "../domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
import {
dyldSharedCacheResultSchema,
inspectDyldSharedCacheInputSchema,
} from "../domain/apple/dyldSharedCache.js";
import { applicationFeatureTraceResultSchema } from "../domain/javascript/javascriptFeatureTraceSchemas.js";
import { javaScriptSemanticTraceResultSchema } from "../domain/javascript/javascriptSemanticTraceSchemas.js";
import { javaScriptExportShapeComparisonResultSchema } from "../domain/javascript/javascriptExportShapeComparisonSchemas.js";
@@ -295,6 +299,25 @@ export const APPLICATION_TOOL_CONTRACTS = [
},
],
},
{
name: "inspect_dyld_shared_cache",
...toolContractMetadata("inspect_dyld_shared_cache"),
description:
"Inspect an Apple dyld shared cache file set selected by path, such as /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e or one extracted from an IPSW. Reports the cache UUID, architecture, platform and OS version, VM mappings, every subcache with UUID verification, and the complete image list. For each requested install path, it reads that image's load commands (install name, dependencies, rpaths) through the cache mappings. Pure file parsing: nothing is executed, slid, or extracted.",
kind: "application",
inputSchema: inspectDyldSharedCacheInputSchema,
outputSchema: evidenceResultOf(dyldSharedCacheResultSchema),
examples: [
{
title: "Inspect the host's arm64e shared cache and libSystem",
input: {
cache_path:
"/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e",
images: ["/usr/lib/libSystem.B.dylib"],
},
},
],
},
] as const satisfies readonly ToolContract[];
/** Resolve one named application contract without relying on array position. */
@@ -325,6 +348,9 @@ export function applicationToolContract(
export function applicationToolContract(
name: "project_apple_application_graph",
): (typeof APPLICATION_TOOL_CONTRACTS)[8];
export function applicationToolContract(
name: "inspect_dyld_shared_cache",
): (typeof APPLICATION_TOOL_CONTRACTS)[9];
export function applicationToolContract(
name: (typeof APPLICATION_TOOL_CONTRACTS)[number]["name"],
): (typeof APPLICATION_TOOL_CONTRACTS)[number] {
+1 -1
View File
@@ -92,7 +92,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [
),
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.",
"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. Absolute or system paths outside the analyzed root stay outside-target unless shared_cache names a dyld shared cache for the process's CPU family and platform; listed images then resolve to shared-cache. Select roots or one architecture to narrow large bundles.",
dylibResolutionInputSchema,
),
] as const satisfies readonly ToolContract[];
+1
View File
@@ -285,6 +285,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
evaluate_reconstruction_coverage: effects(),
project_android_application_graph: evidence,
project_apple_application_graph: evidence,
inspect_dyld_shared_cache: evidence,
open_binary: effects({ mutatesSession: true, launchesProcess: true }),
close_binary: effects({
mutatesSession: true,
+35
View File
@@ -0,0 +1,35 @@
import { z } from "zod";
/** `PLATFORM_*` values from Apple's `mach-o/loader.h`. */
const APPLE_PLATFORM_NAMES: Readonly<Record<number, string>> = {
1: "macos",
2: "ios",
3: "tvos",
4: "watchos",
5: "bridgeos",
6: "maccatalyst",
7: "ios-simulator",
8: "tvos-simulator",
9: "watchos-simulator",
10: "driverkit",
11: "visionos",
12: "visionos-simulator",
};
/** A reported platform number and its `loader.h` name, when known. */
export const applePlatformSchema = z.strictObject({
id: z.number().int(),
name: z.string().nullable(),
});
export type ApplePlatform = z.infer<typeof applePlatformSchema>;
/** Name a reported platform number without discarding unknown values. */
export const applePlatform = (id: number): ApplePlatform => ({
id,
name: APPLE_PLATFORM_NAMES[id] ?? null,
});
/** Display one platform, falling back to its number. */
export const applePlatformLabel = ({ id, name }: ApplePlatform): string =>
name ?? `platform ${id}`;
+11
View File
@@ -37,6 +37,7 @@ export const resolveTreePath = async (
if (entry.kind === "symlink") {
hops += 1;
if (hops > MAX_SYMLINK_HOPS) return { kind: "absent" };
// Tree providers convert host link syntax to this POSIX namespace.
if (entry.target.startsWith("/")) return { kind: "escapes" };
pending.unshift(...segmentsOf(entry.target));
continue;
@@ -108,3 +109,13 @@ export const compatibleSlice = (
}
return undefined;
};
/**
* Whether a cache serves a process architecture. Compatibility is directional:
* an arm64e cache also serves arm64 processes and an x86_64h cache also serves
* x86_64 ones, but a generic cache lacks the arm64e or x86_64h ABI.
*/
export const cacheServes = (cache: string, process: string): boolean =>
cache === process ||
(cache === "arm64e" && process === "arm64") ||
(cache === "x86_64h" && process === "x86_64");
+102
View File
@@ -0,0 +1,102 @@
import { z } from "zod";
import { applePlatformSchema } from "./applePlatforms.js";
import { digestSchema } from "../digests.js";
import { machoSliceSchema } from "./dylibResolution.js";
const hexSchema = z.string().regex(/^0x[0-9a-f]+$/u);
const uuidSchema = z
.string()
.regex(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/u);
/** One cached image selected by its exact install path. */
export const inspectDyldSharedCacheInputSchema = z.strictObject({
cache_path: z.string().min(1),
images: z.array(z.string().min(1)).min(1).optional(),
});
const mappingSchema = z.object({
file: z.string(),
address: hexSchema,
size: hexSchema,
file_offset: hexSchema,
max_protection: z.number().int(),
initial_protection: z.number().int(),
});
const subcacheSchema = z.object({
suffix: z.string(),
uuid: uuidSchema,
vm_offset: hexSchema,
status: z.enum(["present", "missing", "uuid-mismatch"]),
observed_uuid: uuidSchema.nullable(),
});
const headerSchema = z.object({
magic: z.string(),
architecture: z.string(),
uuid: z.string(),
platform: applePlatformSchema.nullable(),
/** Producer platform fields before simulator-family normalization. */
header_platform: applePlatformSchema.nullable().default(null),
header_alt_platform: applePlatformSchema.nullable().default(null),
simulator: z.boolean().nullable().default(null),
os_version: z.string().nullable(),
alt_platform: applePlatformSchema.nullable(),
alt_os_version: z.string().nullable(),
cache_type: z.enum(["development", "production", "multi-cache"]).nullable(),
cache_subtype: z
.object({
id: z.number().int().nonnegative(),
name: z.enum(["development", "production"]).nullable(),
})
.nullable()
.default(null)
.describe(
"Multi-cache subtype from the producer header: 0 is development, 1 is production; unknown values retain their ID with a null name. Null when absent or not applicable.",
),
shared_region: z.object({ start: hexSchema, size: hexSchema }).nullable(),
max_slide: hexSchema.nullable(),
mappings: z.array(mappingSchema),
subcaches: z.array(subcacheSchema),
symbols_file_uuid: uuidSchema.nullable(),
});
const inspectedImageSchema = z.object({
path: z.string(),
status: z.enum(["parsed", "malformed", "unsupported", "absent", "unmapped"]),
address: hexSchema.nullable(),
file: z.string().nullable(),
reason: z.string().nullable(),
slices: z.array(machoSliceSchema),
});
/** Header, mappings, subcaches, and the complete image list of one cache. */
export const dyldSharedCacheResultSchema = z.object({
cache_path: z.string(),
main_file_sha256: digestSchema,
...headerSchema.shape,
images_total: z.number().int().nonnegative(),
images: z.array(z.object({ path: z.string(), address: hexSchema })),
inspected_images: z.array(inspectedImageSchema),
/** SHA-256 of every readable subcache backing inspected images, keyed by suffix. */
subcache_sha256: z.record(z.string(), digestSchema).default({}),
coverage: z.object({
status: z.enum(["complete", "partial"]),
unreadable_subcaches: z.array(z.string()),
}),
limitations: z.array(z.string()),
});
export type DyldCacheMapping = z.infer<typeof mappingSchema>;
export type DyldCacheSubcache = z.infer<typeof subcacheSchema>;
export type DyldSharedCacheHeader = z.infer<typeof headerSchema>;
export type DyldSharedCacheResult = z.infer<typeof dyldSharedCacheResultSchema>;
export type InspectedCacheImage = z.infer<typeof inspectedImageSchema>;
/** Limitations that bound claims made from one cache file set. */
export const DYLD_SHARED_CACHE_LIMITATIONS = [
"The cache describes the operating system it was built for; it is evidence about this cache file set, not about another host.",
"Image headers are read through the cache's VM mappings; slide info, local symbols, and code signatures are not parsed, and images are not extracted.",
"Install paths are matched exactly against the cache image table; dyld alias canonicalization is not modeled, so an alias may report absent or undetermined though dyld would load it from the cache.",
];
+367 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
traceDylibLoading,
type DylibSharedCacheView,
type DylibTreeEntry,
type DylibTreeView,
type MachoDependency,
@@ -17,6 +18,7 @@ const slice = (overrides: Partial<MachoSlice> = {}): MachoSlice => ({
rpaths: [],
dyld_environment: [],
code_signature_present: true,
platforms: [{ id: 1, name: "macos" }],
...overrides,
});
@@ -144,7 +146,7 @@ describe("dyld path expansion", () => {
}),
[plugin]: parsed(
slice({
file_type: "bundle",
file_type: "dylib",
dependencies: [dependency("@rpath/libshared.dylib")],
}),
),
@@ -300,13 +302,26 @@ describe("dyld resolution outcomes", () => {
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.edges.map(({ resolution }) => resolution.status)).toEqual([
"conditional",
"undetermined",
"undetermined",
]);
expect(trace.findings[0]?.explanation).toContain(
"Contents/MacOS/../Overrides/libfound.dylib",
);
expect(trace.edges[0]?.candidates.map(({ outcome }) => outcome)).toEqual([
"absent",
"resolved",
]);
expect(trace.findings[0]?.explanation).toContain(
"Unmodeled environment overrides",
);
expect(trace.findings[0]?.explanation).toContain(
"does not establish which image dyld will load",
);
});
it("reports non-Mach-O, malformed and wrong-architecture candidates", async () => {
@@ -338,6 +353,7 @@ describe("dyld resolution outcomes", () => {
status: "partial",
unparsed_images: ["Contents/MacOS/b/lib.dylib"],
roots_without_architecture: [],
unverified_shared_cache_images: [],
});
expect(trace.images.map(({ path }) => path)).toEqual([
MAIN,
@@ -512,6 +528,7 @@ describe("dyld slice compatibility and coverage", () => {
status: "partial",
unparsed_images: [broken],
roots_without_architecture: [],
unverified_shared_cache_images: [],
});
expect(trace.images.find(({ path }) => path === broken)?.reason).toBe(
"load command 0 has invalid cmdsize 0",
@@ -519,6 +536,229 @@ describe("dyld slice compatibility and coverage", () => {
});
});
/** A macOS arm64e cache that lists libSystem and libswiftCore. */
const cache = (
overrides: Partial<DylibSharedCacheView> = {},
): DylibSharedCacheView => ({
architecture: "arm64e",
platforms: [
{ id: 1, name: "macos" },
{ id: 6, name: "maccatalyst" },
],
unavailableSubcaches: [],
lookup: (path: string) =>
[
"/usr/lib/libSystem.B.dylib",
"/usr/lib/swift/libswiftCore.dylib",
].includes(path)
? "mapped"
: "absent",
image: () => Promise.resolve(parsed(slice({ architecture: "arm64e" }))),
...overrides,
});
describe("shared cache resolution", () => {
it("resolves cached system paths before bundled fallbacks", 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("/usr/lib/libgone.dylib"),
],
}),
"Contents/Frameworks/libswiftCore.dylib": parsed(slice()),
}),
{ roots: [MAIN], sharedCache: cache() },
);
expect(
trace.edges.map(({ install_name: name, resolution, candidates }) => [
name,
resolution,
candidates.map(({ outcome }) => outcome),
]),
).toEqual([
[
"/usr/lib/libSystem.B.dylib",
{ status: "shared-cache", image: "/usr/lib/libSystem.B.dylib" },
["shared-cache"],
],
[
"@rpath/libswiftCore.dylib",
{ status: "shared-cache", image: "/usr/lib/swift/libswiftCore.dylib" },
["shared-cache"],
],
[
"/usr/lib/libgone.dylib",
{ status: "undetermined", image: null },
["outside-target"],
],
]);
expect(trace.images.map(({ path }) => path)).toEqual([MAIN]);
expect(trace.coverage.status).toBe("complete");
expect(trace.limitations).toContainEqual(
expect.stringContaining("transitive dependencies were not traversed"),
);
expect(trace.limitations).toContainEqual(
expect.stringContaining(
"looked up only in the supplied dyld shared cache",
),
);
expect(trace.limitations).not.toContainEqual(
expect.stringContaining("are not evaluated, including /System"),
);
});
it("does not treat mapped bytes as a load until they parse as a compatible Mach-O", async () => {
const root = memoryView({
[MAIN]: executable({
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
});
const notMachO = await traceDylibLoading(root, {
roots: [MAIN],
sharedCache: cache({
image: () => Promise.resolve({ status: "not-mach-o" }),
}),
});
expect(notMachO.edges[0]?.resolution).toEqual({
status: "undetermined",
image: null,
});
expect(notMachO.coverage).toMatchObject({
status: "partial",
unverified_shared_cache_images: ["/usr/lib/libSystem.B.dylib"],
});
expect(notMachO.images.map(({ path }) => path)).toEqual([MAIN]);
const wrongArchitecture = await traceDylibLoading(root, {
roots: [MAIN],
sharedCache: cache({
image: () => Promise.resolve(parsed(slice({ architecture: "x86_64" }))),
}),
});
expect(wrongArchitecture.edges[0]?.candidates[0]?.outcome).toBe(
"undetermined",
);
});
it("keeps a cache hit conditional after an earlier undeterminable candidate", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: ["/opt/vendor/lib", "/usr/lib/swift"],
dependencies: [dependency("@rpath/libswiftCore.dylib")],
}),
}),
{ roots: [MAIN], sharedCache: cache() },
);
expect(trace.edges[0]?.resolution).toEqual({
status: "conditional",
image: "/usr/lib/swift/libswiftCore.dylib",
});
expect(trace.edges[0]?.candidates.map(({ outcome }) => outcome)).toEqual([
"outside-target",
"shared-cache",
]);
});
});
describe("shared cache applicability", () => {
it("does not consult a cache for another CPU family or platform", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
architecture: "x86_64",
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
"Contents/MacOS/Phone": executable({
platforms: [{ id: 2, name: "ios" }],
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
"Contents/MacOS/Old": executable({
platforms: [],
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
}),
{
roots: [MAIN, "Contents/MacOS/Phone", "Contents/MacOS/Old"],
sharedCache: cache(),
},
);
expect(trace.edges.map(({ candidates }) => candidates[0]?.outcome)).toEqual(
["outside-target", "outside-target", "outside-target"],
);
expect(trace.limitations).toEqual(
expect.arrayContaining([
`The supplied shared cache was not used for ${MAIN} (x86_64): the arm64e cache does not serve x86_64 processes. Its absolute paths stay outside-target.`,
"The supplied shared cache was not used for Contents/MacOS/Phone (arm64): the cache serves macos and maccatalyst processes, not ios. Its absolute paths stay outside-target.",
"The supplied shared cache was not used for Contents/MacOS/Old (arm64): the process records no LC_BUILD_VERSION or LC_VERSION_MIN platform. Its absolute paths stay outside-target.",
]),
);
const generic = await traceDylibLoading(
memoryView({
[MAIN]: executable({
architecture: "arm64e",
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
}),
{ roots: [MAIN], sharedCache: cache({ architecture: "arm64" }) },
);
// A generic arm64 cache lacks the arm64e ABI.
expect(generic.edges[0]?.candidates[0]?.outcome).toBe("outside-target");
const unknownPlatform = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
}),
{ roots: [MAIN], sharedCache: cache({ platforms: [] }) },
);
expect(unknownPlatform.edges[0]?.candidates[0]?.outcome).toBe(
"outside-target",
);
});
it("leaves images in unavailable subcaches undetermined", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
}),
{
roots: [MAIN],
sharedCache: cache({
unavailableSubcaches: [".01 (missing)"],
lookup: () => "unverified",
}),
},
);
expect(trace.edges[0]?.resolution).toEqual({
status: "undetermined",
image: null,
});
expect(trace.coverage).toMatchObject({
status: "partial",
unverified_shared_cache_images: ["/usr/lib/libSystem.B.dylib"],
});
expect(trace.limitations).toContainEqual(
expect.stringContaining("Subcaches .01 (missing)"),
);
});
it("states that absolute paths are not evaluated without a cache", async () => {
const trace = await traceDylibLoading(
memoryView({ [MAIN]: executable({}) }),
{ roots: [MAIN] },
);
expect(trace.limitations).toContainEqual(
expect.stringContaining("Pass shared_cache"),
);
});
});
describe("lazily loaded dependencies", () => {
it("resolves lazy loads without traversing them or reporting launch failure", async () => {
const trace = await traceDylibLoading(
@@ -603,3 +843,127 @@ describe("conditional loads", () => {
).toContain(`${VENDOR} loads only conditionally; if it loads,`);
});
});
describe("dependency loadability", () => {
it.each(["execute", "bundle", "other"] as const)(
"does not load cached or in-root %s images as dependencies",
async (fileType) => {
for (const useCache of [true, false]) {
const path = useCache
? "/usr/lib/libSystem.B.dylib"
: "@executable_path/libinvalid.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({ dependencies: [dependency(path)] }),
"Contents/MacOS/libinvalid.dylib": parsed(
slice({ file_type: fileType }),
),
}),
{
roots: [MAIN],
...(useCache
? {
sharedCache: cache({
image: async () =>
parsed(
slice({ architecture: "arm64e", file_type: fileType }),
),
}),
}
: {}),
},
);
expect(trace.edges[0]?.resolution).toEqual({
status: "undetermined",
image: null,
});
}
},
);
it.each([{ platforms: [] }, { platforms: [{ id: 2, name: "ios" }] }])(
"keeps cached unknown or incompatible platforms uncertain (%j)",
async ({ platforms }) => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
}),
{
roots: [MAIN],
sharedCache: cache({
image: async () =>
parsed(slice({ architecture: "arm64e", platforms })),
}),
},
);
expect(trace.edges[0]?.resolution.status).toBe("undetermined");
expect(trace.coverage.status).toBe("partial");
},
);
});
it("propagates embedded search-path uncertainty through found, missing, lazy and descendant edges", async () => {
const child = "Contents/MacOS/child.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: ["DYLD_LIBRARY_PATH=/tmp"],
dependencies: [
dependency("@executable_path/child.dylib"),
dependency("@executable_path/missing.dylib"),
dependency("@executable_path/lazy.dylib", {
command: "LC_LAZY_LOAD_DYLIB",
}),
dependency("/usr/lib/libSystem.B.dylib"),
],
}),
[child]: parsed(
slice({ dependencies: [dependency("@loader_path/gone.dylib")] }),
),
}),
{ roots: [MAIN], sharedCache: cache() },
);
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
"conditional",
"undetermined",
"undetermined",
"conditional",
"undetermined",
]);
expect(trace.edges.at(-1)?.loader_conditional).toBe(true);
expect(trace.findings.map(({ kind }) => kind)).toEqual([
"dyld-environment-present",
]);
});
it.each([
"DYLD_PRINT_LIBRARIES=1",
"DYLD_PRINT_RPATHS=1",
"DYLD_LIBRARY_PATH_LOG=/tmp",
"DYLD_LIBRARY_PATH",
])(
"retains %s as an observation without adding path uncertainty",
async (setting) => {
const child = "Contents/MacOS/child.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: [setting],
dependencies: [dependency("@executable_path/child.dylib")],
}),
[child]: parsed(slice()),
}),
{ roots: [MAIN] },
);
expect(trace.edges[0]?.resolution.status).toBe("resolved");
expect(trace.coverage.status).toBe("complete");
expect(
trace.images.find(({ path }) => path === MAIN)?.slices[0]
?.dyld_environment,
).toEqual([setting]);
expect(trace.findings.map(({ kind }) => kind)).toContain(
"dyld-environment-present",
);
},
);
+178 -177
View File
@@ -1,184 +1,71 @@
import { z } from "zod";
import {
compatibleSlice,
cacheServes,
directoryOf,
expandPrefix,
joinPath,
resolveTreePath,
type Expansion,
} from "./dyldPaths.js";
import { digestSchema } from "../digests.js";
import {
DYLIB_RESOLUTION_LIMITATIONS,
deriveFindings,
cacheMismatch,
sharedCacheLimitations,
type UnservedRoot,
} from "./dylibResolutionFindings.js";
import type {
MachoDependency,
MachoSlice,
MachoImageFacts,
DylibTreeView,
DylibSharedCacheView,
DylibCandidate,
DylibEdge,
DylibTrace,
} from "./dylibResolutionSchema.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);
export {
dylibResolutionInputSchema,
machoSliceSchema,
dylibResolutionResultSchema,
type MachoDependency,
type MachoSlice,
type MachoImageFacts,
type DylibTreeEntry,
type DylibTreeView,
type DylibSharedCacheView,
type DylibResolutionResult,
type DylibEdge,
type DylibFinding,
type DylibTrace,
} from "./dylibResolutionSchema.js";
/** 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 Candidate = DylibCandidate;
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 })[];
};
// Apple's PathOverrides consumes these variable names. Print/logging variables
// remain observations and do not change which library a search selects.
const DYLD_PATH_VARIABLES: ReadonlySet<string> = new Set([
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"DYLD_FALLBACK_FRAMEWORK_PATH",
"DYLD_VERSIONED_LIBRARY_PATH",
"DYLD_VERSIONED_FRAMEWORK_PATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_IMAGE_SUFFIX",
"DYLD_ROOT_PATH",
"DYLD_OVERLAY_PATH",
]);
const hasDyldPathOverrides = (slice: MachoSlice): boolean =>
slice.dyld_environment.some((setting) => {
const delimiter = setting.indexOf("=");
return (
delimiter > 0 && DYLD_PATH_VARIABLES.has(setting.slice(0, delimiter))
);
});
interface LoadedImage {
readonly slice: MachoSlice;
@@ -192,10 +79,16 @@ interface ProcessContext {
readonly view: DylibTreeView;
readonly root: string;
readonly architecture: string;
readonly platforms: MachoSlice["platforms"];
readonly searchPathsUnknown: boolean;
readonly executable: string | null;
readonly loaded: Map<string, LoadedImage>;
readonly byInstallName: Map<string, string>;
readonly images: Map<string, MachoImageFacts>;
/** The supplied cache, when it serves this process's architecture and platform. */
readonly sharedCache: DylibSharedCacheView | undefined;
/** Cached install paths whose bytes or loadability could not be verified. */
readonly unverifiedCacheImages: Set<string>;
}
interface CandidateTemplate {
@@ -259,8 +152,39 @@ const evaluateCandidate = async (
rpath: template.rpath,
rpath_owner: template.rpathOwner,
};
if (template.expansion.scope === "outside")
if (template.expansion.scope === "outside") {
const sharedCache = context.sharedCache;
const cached = sharedCache?.lookup(template.expansion.path) ?? "absent";
if (cached === "mapped" && sharedCache !== undefined) {
// Address coverage is not a load. Confirm a compatible Mach-O without
// retaining its commands, so cache images stay leaves.
const facts = await sharedCache.image(template.expansion.path);
if (
facts?.status === "parsed" &&
facts.slices.some(
(slice) =>
cacheServes(slice.architecture, context.architecture) &&
slice.file_type === "dylib" &&
slice.platforms.some(({ id }) =>
context.platforms.some((platform) => platform.id === id),
),
)
)
return {
...base,
outcome: "shared-cache",
resolved_path: template.expansion.path,
};
context.unverifiedCacheImages.add(template.expansion.path);
return { ...base, outcome: "undetermined", resolved_path: null };
}
// Listed in the cache, but its subcache is unavailable: dyld might load it.
if (cached === "unverified") {
context.unverifiedCacheImages.add(template.expansion.path);
return { ...base, outcome: "undetermined", resolved_path: null };
}
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);
@@ -269,13 +193,24 @@ const evaluateCandidate = async (
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 };
if (facts.status !== "parsed")
return { ...base, outcome: facts.status, resolved_path: lookup.path };
const slice = compatibleSlice(facts.slices, context.architecture);
if (slice === undefined)
return {
...base,
outcome: "architecture-missing",
resolved_path: lookup.path,
};
if (slice.file_type !== "dylib")
return { ...base, outcome: "unsupported", resolved_path: lookup.path };
if (
!slice.platforms.some(({ id }) =>
context.platforms.some((platform) => platform.id === id),
)
)
return { ...base, outcome: "undetermined", resolved_path: lookup.path };
return { ...base, outcome: "resolved", resolved_path: lookup.path };
};
const imageFacts = async (
@@ -297,6 +232,11 @@ const resolution = (candidates: readonly Candidate[]): Edge["resolution"] => {
status: external ? "conditional" : "resolved",
image: candidate.resolved_path,
};
if (candidate.outcome === "shared-cache")
return {
status: external ? "conditional" : "shared-cache",
image: candidate.resolved_path,
};
// dyld might load these; REA cannot decide them from the analyzed root.
if (
candidate.outcome === "outside-target" ||
@@ -332,9 +272,19 @@ const resolveDependency = async (
const candidate = await evaluateCandidate(template, context);
candidates.push(candidate);
// dyld stops at the first loadable candidate.
if (candidate.outcome === "resolved") break;
if (
candidate.outcome === "resolved" ||
candidate.outcome === "shared-cache"
)
break;
}
const searched = resolution(candidates);
const modeled = resolution(candidates);
const searched: Edge["resolution"] = context.searchPathsUnknown
? {
...modeled,
status: modeled.image === null ? "undetermined" : "conditional",
}
: modeled;
// Reusing an image that itself loads only conditionally is conditional too.
const resolved: Edge["resolution"] =
loaded !== undefined &&
@@ -428,6 +378,7 @@ export const traceDylibLoading = async (
/** Mach-O files whose root role could not be classified because they did not parse. */
readonly unclassified?: readonly string[];
readonly architecture?: string;
readonly sharedCache?: DylibSharedCacheView;
readonly signal?: AbortSignal;
},
): Promise<DylibTrace> => {
@@ -437,6 +388,8 @@ export const traceDylibLoading = async (
const roots: DylibTrace["roots"][number][] = [];
const edges: Edge[] = [];
const withoutArchitecture: string[] = [];
const unserved: UnservedRoot[] = [];
const unverifiedCacheImages = new Set<string>();
for (const root of request.roots) {
const facts = await view.image(root);
images.set(root, facts);
@@ -449,16 +402,31 @@ export const traceDylibLoading = async (
if (slices.length === 0) withoutArchitecture.push(root);
for (const slice of slices) {
roots.push({ image: root, architecture: slice.architecture });
const mismatch =
request.sharedCache === undefined
? undefined
: cacheMismatch(request.sharedCache, slice);
if (mismatch !== undefined)
unserved.push({
image: root,
architecture: slice.architecture,
reason: mismatch,
});
edges.push(
...(await traceProcess(
{
view,
root,
architecture: slice.architecture,
platforms: slice.platforms,
searchPathsUnknown: hasDyldPathOverrides(slice),
executable: slice.file_type === "execute" ? root : null,
loaded: new Map(),
byInstallName: new Map(),
images,
sharedCache:
mismatch === undefined ? request.sharedCache : undefined,
unverifiedCacheImages,
},
slice,
request.signal,
@@ -472,6 +440,17 @@ export const traceDylibLoading = async (
const unparsed = reported
.filter(([, facts]) => facts.status !== "parsed")
.map(([path]) => path);
// LC_DYLD_ENVIRONMENT can prepend search paths dyld honors at launch; its
// settings are reported but not modeled, so resolutions stay conditional.
const environmentRoots = roots.filter(({ image, architecture }) => {
const facts = images.get(image);
return (
facts?.status === "parsed" &&
facts.slices.some(
(s) => s.architecture === architecture && hasDyldPathOverrides(s),
)
);
});
return {
roots,
images: reported.map(([path, facts]) => ({
@@ -489,13 +468,34 @@ export const traceDylibLoading = async (
findings: deriveFindings(edges, roots, images),
coverage: {
status:
unparsed.length === 0 && withoutArchitecture.length === 0
unparsed.length === 0 &&
withoutArchitecture.length === 0 &&
unverifiedCacheImages.size === 0 &&
environmentRoots.length === 0
? "complete"
: "partial",
unparsed_images: unparsed,
roots_without_architecture: withoutArchitecture,
unverified_shared_cache_images: [...unverifiedCacheImages].sort(compare),
},
limitations: DYLIB_RESOLUTION_LIMITATIONS,
limitations: [
...DYLIB_RESOLUTION_LIMITATIONS,
...sharedCacheLimitations(request.sharedCache, unserved),
...(edges.some(
(edge) =>
edge.command !== "LC_LAZY_LOAD_DYLIB" &&
edge.candidates.some(({ outcome }) => outcome === "shared-cache"),
)
? [
"Cache-backed images are leaf references: their load commands and transitive dependencies were not traversed. Complete coverage describes the selected roots and in-root images, not a complete process-wide dependency graph. Use inspect_dyld_shared_cache with selected images to inspect cached load commands.",
]
: []),
...(environmentRoots.length === 0
? []
: [
`Roots ${environmentRoots.map(({ image }) => image).join(", ")} set dyld environment variables through LC_DYLD_ENVIRONMENT; their resolutions are conditional because those search paths are not modeled.`,
]),
],
};
};
@@ -508,6 +508,7 @@ const withoutDependencies = (
rpaths: slice.rpaths,
dyld_environment: slice.dyld_environment,
code_signature_present: slice.code_signature_present,
platforms: slice.platforms,
});
const compare = (left: string, right: string): number =>
+80 -6
View File
@@ -1,8 +1,12 @@
import { applePlatformLabel } from "./applePlatforms.js";
import { cacheServes } from "./dyldPaths.js";
import type {
DylibEdge as Edge,
DylibFinding as Finding,
DylibSharedCacheView,
DylibTrace,
MachoImageFacts,
MachoSlice,
} from "./dylibResolution.js";
export const deriveFindings = (
@@ -15,21 +19,40 @@ export const deriveFindings = (
if (edge.resolution.status === "unresolved")
findings.push(unresolvedFinding(edge, index));
const resolvedAt = edge.candidates.findIndex(
({ outcome }) => outcome === "resolved",
({ outcome }) => outcome === "resolved" || outcome === "shared-cache",
);
const earlier = edge.candidates
const earlierAbsent = edge.candidates
.slice(0, Math.max(resolvedAt, 0))
.filter(
({ source, outcome }) => source === "rpath" && outcome === "absent",
)
.map(({ path }) => path);
if (resolvedAt > 0 && earlier.length > 0)
const earlierUnknown = edge.candidates
.slice(0, Math.max(resolvedAt, 0))
.some(({ outcome }) =>
[
"outside-target",
"escapes-target",
"unsupported",
"undetermined",
].includes(outcome),
);
const loaderQualifies =
edge.loader_conditional === true
? " This loader itself loads only conditionally; if it never loads, this search never occurs."
: "";
const unknownQualifies = earlierUnknown
? " An earlier candidate outside the analyzed root may still win at runtime, so this fallback loads only conditionally."
: edge.resolution.status === "conditional"
? " Unmodeled environment overrides or other process search inputs may take precedence; placing an image at this rpath does not establish which image dyld will load."
: "";
if (resolvedAt > 0 && earlierAbsent.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).`,
explanation: `The modeled dyld search checks ${earlierAbsent.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A compatible Mach-O placed at an earlier modeled path could take precedence over that fallback, subject to unmodeled search inputs and code-signing library validation; library validation is not evaluated here (see inspect_signature).${loaderQualifies}${unknownQualifies}`,
});
});
for (const { image, architecture } of roots) {
@@ -45,7 +68,7 @@ export const deriveFindings = (
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.`,
explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); any image-selection overrides they request are not modeled. Diagnostic settings alone do not alter resolution.`,
});
}
return findings;
@@ -84,10 +107,61 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
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.",
];
/** A process root the supplied shared cache cannot answer for. */
export interface UnservedRoot {
readonly image: string;
readonly architecture: string;
readonly reason: string;
}
/**
* Why the cache cannot serve a process slice, or undefined when it can:
* dyld maps only a cache built for the process's CPU family and platform.
*/
export const cacheMismatch = (
cache: DylibSharedCacheView,
slice: Pick<MachoSlice, "architecture" | "platforms">,
): string | undefined => {
if (!cacheServes(cache.architecture, slice.architecture))
return `the ${cache.architecture} cache does not serve ${slice.architecture} processes`;
if (cache.platforms.length === 0)
return "the cache header does not record its platform";
if (slice.platforms.length === 0)
return "the process records no LC_BUILD_VERSION or LC_VERSION_MIN platform";
if (
!slice.platforms.some(({ id }) =>
cache.platforms.some((platform) => platform.id === id),
)
)
return `the cache serves ${cache.platforms.map(applePlatformLabel).join(" and ")} processes, not ${slice.platforms.map(applePlatformLabel).join(" or ")}`;
return undefined;
};
export const sharedCacheLimitations = (
cache: DylibSharedCacheView | undefined,
unserved: readonly UnservedRoot[],
): string[] => {
if (cache === undefined)
return [
"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. Pass shared_cache to look them up in a cache.",
];
return [
"Absolute install names and rpaths were looked up only in the supplied dyld shared cache; paths it does not list stay outside-target because the host filesystem outside the analyzed root is not read. Shared-cache hits assume no on-disk override (root or unzippered twin) though dyld checks the filesystem first; treat them as conditional when the external disk state is unknown. On another OS build the cache contents, and therefore these outcomes, can differ.",
...unserved.map(
({ image, architecture, reason }) =>
`The supplied shared cache was not used for ${image} (${architecture}): ${reason}. Its absolute paths stay outside-target.`,
),
...(cache.unavailableSubcaches.length === 0
? []
: [
`Subcaches ${cache.unavailableSubcaches.join(", ")} of the supplied cache are unavailable; install paths listed there are undetermined and reported in coverage.unverified_shared_cache_images.`,
]),
];
};
+214
View File
@@ -0,0 +1,214 @@
import { z } from "zod";
import { applePlatformSchema, type ApplePlatform } from "./applePlatforms.js";
import { digestSchema } from "../digests.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(),
/** Main dyld shared cache file whose image list answers system install paths. */
shared_cache: z.string().min(1).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(),
/** LC_BUILD_VERSION or LC_VERSION_MIN_* platforms; zippered images list two. */
platforms: z.array(applePlatformSchema),
});
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>;
}
/** Image list of one caller-supplied dyld shared cache. */
export interface DylibSharedCacheView {
readonly architecture: string;
/** The cache's platform and alternate platform; empty when the header predates them. */
readonly platforms: readonly ApplePlatform[];
/** Subcaches whose bytes are missing or do not match, such as `.03 (missing)`. */
readonly unavailableSubcaches: readonly string[];
/**
* `mapped`: listed and inside an admitted mapping. That is address coverage,
* not a load: a shared-cache hit also requires `image` to parse a slice this
* process can load as a dylib on its platform. `unverified`: listed, but the subcache holding it is
* unavailable. `absent`: not listed.
*/
lookup(path: string): "mapped" | "unverified" | "absent";
/**
* Load commands at a mapped install path. Undefined when `lookup` is not
* `mapped`. Tracing uses this only to confirm the hit; cached load commands
* are not traversed.
*/
image(path: string): Promise<MachoImageFacts | undefined>;
}
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",
"shared-cache",
"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",
"shared-cache",
"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,
shared_cache: z
.strictObject({
path: z.string(),
uuid: z.string(),
architecture: z.string(),
os_version: z.string().nullable(),
main_file_sha256: digestSchema,
subcache_sha256: z.record(z.string(), digestSchema).default({}),
})
.nullable(),
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()),
/** Listed cache paths whose bytes or process-compatible dylib representation could not be verified. */
unverified_shared_cache_images: z.array(z.string()),
}),
limitations: z.array(z.string().min(1)),
});
export type DylibResolutionResult = z.infer<typeof dylibResolutionResultSchema>;
export type DylibCandidate = z.infer<typeof candidateSchema>;
export type DylibEdge = z.infer<typeof edgeSchema>;
export type DylibFinding = z.infer<typeof findingSchema>;
/** Result before the adapter adds file digests and the analyzed root. */
export type DylibTrace = Omit<
DylibResolutionResult,
"root_path" | "target_sha256" | "shared_cache" | "images"
> & {
readonly images: readonly (Omit<
DylibResolutionResult["images"][number],
"sha256"
> & { readonly sha256?: never })[];
};
+1
View File
@@ -13,6 +13,7 @@ export class ArtifactOperationError extends AnalysisError {
| "inspect_asset_catalog"
| "inspect_keyed_archive"
| "trace_dylib_resolution"
| "inspect_dyld_shared_cache"
| "export_web_scripts"
| "trace_web_module_imports"
| "trace_web_source_location"
-101
View File
@@ -1,101 +0,0 @@
import { z } from "zod";
/** Observed signing-directory metadata; policy interpretation belongs to the adapter. */
export const codeDirectorySchema = z.object({
version: z.string().nullable(),
flags: z
.object({
value: z.number().int().nonnegative(),
names: z.array(z.string()),
})
.nullable(),
code_slots: z.number().int().nonnegative().nullable(),
special_slots: z.number().int().nonnegative().nullable(),
location: z.string().nullable(),
hash_type: z.string().nullable(),
platform_identifier: z.number().int().nonnegative().nullable(),
runtime_version: z.string().nullable(),
executable_segment_flags: z.number().int().nonnegative().nullable(),
});
/** Observed sealed-resource summary. */
export const sealedResourcesSchema = z.object({
status: z.enum(["sealed", "none", "unknown"]),
version: z.number().int().nullable(),
rules: z.number().int().nullable(),
files: z.number().int().nullable(),
});
/** Local verification observation; unknown is distinct from a proven invalid signature. */
export const signatureVerificationSchema = z.object({
path: z.string(),
status: z.enum(["valid", "invalid", "unsigned", "unknown"]),
exit_code: z.number().int().nullable(),
diagnostics: z.array(z.string()),
validated_nested_code: z.array(z.string()),
prepared_nested_code: z.array(z.string()).default([]),
raw_stdout: z.string().default(""),
raw_stderr: z.string().default(""),
});
/** Local ticket presence and content identity, without a remote notarization claim. */
export const stapledTicketSchema = z.object({
status: z.enum(["present", "absent", "unreadable", "not-applicable"]),
path: z.string().nullable(),
sha256: z
.string()
.regex(/^[a-f0-9]{64}$/u)
.nullable(),
size: z.number().int().nonnegative().nullable(),
reason: z.string().nullable(),
});
const facetSchema = z.discriminatedUnion("facet", [
z.object({
facet: z.literal("library-validation"),
state: z.enum(["enforced", "disabled", "not-enforced", "unknown"]),
basis: z.literal("derived"),
evidence: z.array(z.string()),
explanation: z.string(),
}),
z.object({
facet: z.literal("dyld-environment-variables"),
state: z.enum(["ignored", "honored", "unknown"]),
basis: z.literal("derived"),
evidence: z.array(z.string()),
explanation: z.string(),
}),
z.object({
facet: z.literal("debugger-attach"),
state: z.enum(["allowed", "blocked", "unknown"]),
basis: z.literal("derived"),
evidence: z.array(z.string()),
explanation: z.string(),
}),
z.object({
facet: z.literal("executable-memory"),
state: z.enum([
"restricted",
"jit-allowed",
"unsigned-allowed",
"protection-disabled",
"unrestricted",
"unknown",
]),
basis: z.literal("derived"),
evidence: z.array(z.string()),
explanation: z.string(),
}),
z.object({
facet: z.literal("app-sandbox"),
state: z.enum(["sandboxed", "not-sandboxed", "unknown"]),
basis: z.literal("derived"),
evidence: z.array(z.string()),
explanation: z.string(),
}),
]);
/** Shared result semantics; adapters determine each facet's policy and evidence. */
export const securityFacetsSchema = z.array(facetSchema);
export type CodeDirectory = z.infer<typeof codeDirectorySchema>;
export type SecurityFacet = z.infer<typeof facetSchema>;
-12
View File
@@ -1,11 +1,4 @@
import { z } from "zod";
import {
codeDirectorySchema,
sealedResourcesSchema,
securityFacetsSchema,
signatureVerificationSchema,
stapledTicketSchema,
} from "./codeSigningPosture.js";
import { jsonValueSchema } from "../jsonValue.js";
const hexAddress = z.string().regex(/^0x[a-fA-F0-9]+$/u);
@@ -120,11 +113,6 @@ export const inspectSignatureSchema = z.object({
entitlements: jsonValueSchema.nullable(),
timestamp: z.string().nullable(),
hardened_runtime: z.boolean().nullable(),
code_directory: codeDirectorySchema.nullable(),
sealed_resources: sealedResourcesSchema,
verification: signatureVerificationSchema.nullable(),
stapled_ticket: stapledTicketSchema,
security_facets: securityFacetsSchema,
provenance: z.array(nativeCommandInvocationSchema),
limitations: z.array(z.string()),
});
-245
View File
@@ -1,245 +0,0 @@
import { describe, expect, it } from "vitest";
import {
codeDirectoryFlagNames,
deriveSecurityFacets,
} from "./CodeSigningPolicy.js";
import type { CodeDirectory } from "../domain/native/codeSigningPosture.js";
const directory = (
flags: number | null,
platform: number | null = null,
): CodeDirectory => ({
version: "20500",
flags:
flags === null
? null
: { value: flags, names: codeDirectoryFlagNames(flags) },
code_slots: 1,
special_slots: 7,
location: "embedded",
hash_type: "sha256",
platform_identifier: platform,
runtime_version: null,
executable_segment_flags: 1,
});
type PostureInput = Parameters<typeof deriveSecurityFacets>[0];
const derive = (
input: Omit<PostureInput, "mixedSlices" | "appleOrigin"> &
Partial<PostureInput>,
) => deriveSecurityFacets({ mixedSlices: false, appleOrigin: false, ...input });
const states = (facets: ReturnType<typeof deriveSecurityFacets>) =>
Object.fromEntries(facets.map(({ facet, state }) => [facet, state]));
const RUNTIME = 0x10000;
describe("CodeDirectory flags", () => {
it("names known bits and keeps unknown bits numeric", () => {
expect(codeDirectoryFlagNames(0x12a02)).toEqual([
"adhoc",
"kill",
"restrict",
"library-validation",
"runtime",
]);
expect(codeDirectoryFlagNames(0x40000 | 0x2)).toEqual(["adhoc", "0x40000"]);
});
});
describe("derived security facets", () => {
it("does not apply platform policy for a reported zero platform byte", () => {
expect(
states(
derive({
signed: true,
codeDirectory: directory(0x2, 0),
entitlements: null,
entitlementsKnown: true,
appleOrigin: true,
}),
),
).toMatchObject({
"library-validation": "not-enforced",
"dyld-environment-variables": "honored",
"debugger-attach": "allowed",
});
});
it("treats unsigned and plain ad hoc code as unrestricted", () => {
for (const signed of [false, true])
expect(
states(
derive({
signed,
codeDirectory: signed ? directory(0x2) : null,
entitlements: null,
entitlementsKnown: true,
}),
),
).toEqual({
"library-validation": "not-enforced",
"dyld-environment-variables": "honored",
"debugger-attach": "allowed",
"executable-memory": "unrestricted",
"app-sandbox": "not-sandboxed",
});
});
it("restricts hardened-runtime code unless entitlements relax it", () => {
expect(
states(
derive({
signed: true,
codeDirectory: directory(RUNTIME),
entitlements: { "com.apple.security.app-sandbox": true },
entitlementsKnown: true,
}),
),
).toEqual({
"library-validation": "enforced",
"dyld-environment-variables": "ignored",
"debugger-attach": "blocked",
"executable-memory": "restricted",
"app-sandbox": "sandboxed",
});
const relaxed = derive({
signed: true,
codeDirectory: directory(RUNTIME),
entitlements: {
"com.apple.security.cs.disable-library-validation": true,
"com.apple.security.cs.allow-dyld-environment-variables": true,
"com.apple.security.get-task-allow": true,
"com.apple.security.cs.allow-jit": true,
},
entitlementsKnown: true,
});
expect(states(relaxed)).toMatchObject({
"library-validation": "disabled",
"dyld-environment-variables": "honored",
"debugger-attach": "allowed",
"executable-memory": "jit-allowed",
});
expect(relaxed[0]?.evidence).toEqual([
"hardened runtime",
"com.apple.security.cs.disable-library-validation",
]);
});
it("lets explicit flags and platform identity override entitlements", () => {
const flagged = derive({
signed: true,
codeDirectory: directory(0x2000 | 0x800 | RUNTIME),
entitlements: {
"com.apple.security.cs.disable-library-validation": true,
},
entitlementsKnown: true,
});
expect(states(flagged)).toMatchObject({
"library-validation": "enforced",
"dyld-environment-variables": "ignored",
});
expect(
states(
derive({
signed: true,
codeDirectory: directory(RUNTIME, 26),
entitlements: null,
entitlementsKnown: true,
appleOrigin: true,
}),
),
).toMatchObject({
"library-validation": "enforced",
"dyld-environment-variables": "ignored",
"debugger-attach": "blocked",
});
});
});
describe("security facets that need more than one CodeDirectory", () => {
it("applies platform policy only to Apple-signed platform identifiers", () => {
const crafted = derive({
signed: true,
codeDirectory: directory(0, 26),
entitlements: null,
entitlementsKnown: true,
});
expect(states(crafted)).toMatchObject({
"library-validation": "unknown",
"dyld-environment-variables": "unknown",
"debugger-attach": "unknown",
});
expect(crafted[0]?.evidence).toEqual([
"platform identifier",
"Apple origin not verified",
]);
});
it("leaves every facet unknown when slices differ in signing state", () => {
const mixed = derive({
signed: true,
codeDirectory: directory(0x2000 | RUNTIME, 26),
entitlements: null,
entitlementsKnown: false,
appleOrigin: true,
mixedSlices: true,
});
expect(new Set(mixed.map(({ state }) => state))).toEqual(
new Set(["unknown"]),
);
expect(mixed[0]?.evidence).toEqual([
"architecture slices differ in signing state",
]);
});
it("leaves every facet unknown when verification failed", () => {
const invalid = derive({
signed: true,
codeDirectory: directory(0x2000 | RUNTIME, 26),
entitlements: null,
entitlementsKnown: true,
appleOrigin: true,
signatureInvalid: true,
});
expect(new Set(invalid.map(({ state }) => state))).toEqual(
new Set(["unknown"]),
);
expect(invalid[0]?.evidence).toEqual(["signature verification failed"]);
});
it("keeps facets unknown when flags or entitlements are unavailable", () => {
expect(
states(
derive({
signed: true,
codeDirectory: directory(RUNTIME),
entitlements: null,
entitlementsKnown: false,
}),
),
).toEqual({
"library-validation": "unknown",
"dyld-environment-variables": "unknown",
"debugger-attach": "unknown",
"executable-memory": "unknown",
"app-sandbox": "unknown",
});
expect(
states(
derive({
signed: true,
codeDirectory: directory(null),
entitlements: null,
entitlementsKnown: true,
}),
),
).toMatchObject({
"library-validation": "unknown",
"dyld-environment-variables": "unknown",
"debugger-attach": "unknown",
"executable-memory": "unknown",
});
});
});
-395
View File
@@ -1,395 +0,0 @@
import type { JsonValue } from "../domain/jsonValue.js";
import type {
CodeDirectory,
SecurityFacet,
} from "../domain/native/codeSigningPosture.js";
/** `SecCodeSignatureFlags` bits that codesign reports in a CodeDirectory. */
const CODE_DIRECTORY_FLAGS: readonly (readonly [number, string])[] = [
[0x1, "host"],
[0x2, "adhoc"],
[0x100, "hard"],
[0x200, "kill"],
[0x400, "expires"],
[0x800, "restrict"],
[0x1000, "enforcement"],
[0x2000, "library-validation"],
[0x10000, "runtime"],
[0x20000, "linker-signed"],
];
/** Decode CodeDirectory flag bits; unknown bits keep their hexadecimal value. */
export const codeDirectoryFlagNames = (value: number): string[] => {
const flags = value >>> 0;
const names = CODE_DIRECTORY_FLAGS.flatMap(([bit, name]) =>
(flags & bit) === 0 ? [] : [name],
);
const known = CODE_DIRECTORY_FLAGS.reduce((total, [bit]) => total | bit, 0);
for (let index = 0; index < 32; index++)
if (((flags >>> index) & 1) === 1 && ((known >>> index) & 1) === 0)
names.push(`0x${(2 ** index).toString(16)}`);
return names;
};
const entitlement = (
entitlements: JsonValue | null,
key: string,
): boolean | undefined => {
if (
entitlements === null ||
typeof entitlements !== "object" ||
Array.isArray(entitlements)
)
return undefined;
const value = entitlements[key];
return typeof value === "boolean" ? value : undefined;
};
interface PostureInput {
readonly signed: boolean;
readonly codeDirectory: CodeDirectory | null;
readonly entitlements: JsonValue | null;
/** False when mixed or unreadable signing states hide the aggregate entitlements. */
readonly entitlementsKnown: boolean;
/** Architecture slices differ in signing state, so no one CodeDirectory describes the process. */
readonly mixedSlices: boolean;
/**
* The signature satisfies `anchor apple`. Only then does a CodeDirectory
* platform identifier mean an Apple platform binary.
*/
readonly appleOrigin: boolean;
/**
* The local `--deep --strict` verification failed for the opened code.
* Flag bits from an unauthenticated CodeDirectory must not decide facets.
*/
readonly signatureInvalid?: boolean;
}
/** Whether the CodeDirectory claims a platform binary, and whether Apple signed it. */
const platformBinary = (
input: PostureInput,
): "apple" | "unverified" | "none" =>
(input.codeDirectory?.platform_identifier ?? 0) === 0
? "none"
: input.appleOrigin
? "apple"
: "unverified";
const UNVERIFIED_PLATFORM = [
"platform identifier",
"Apple origin not verified",
] as const;
const has = (input: PostureInput, flag: string): boolean =>
input.codeDirectory?.flags?.names.includes(flag) === true;
const libraryValidation = (input: PostureInput): SecurityFacet => {
const base = { facet: "library-validation", basis: "derived" } as const;
const explanation =
"With library validation, the process loads only libraries signed by Apple or by the same team; without it, any library dyld finds can load.";
if (!input.signed)
return {
...base,
state: "not-enforced",
evidence: ["unsigned"],
explanation,
};
if (input.codeDirectory === null || input.codeDirectory.flags === null)
return {
...base,
state: "unknown",
evidence: ["CodeDirectory flags unavailable"],
explanation,
};
const platform = platformBinary(input);
if (platform !== "none")
return platform === "apple"
? {
...base,
state: "enforced",
evidence: ["platform binary"],
explanation,
}
: {
...base,
state: "unknown",
evidence: [...UNVERIFIED_PLATFORM],
explanation,
};
if (has(input, "library-validation"))
return {
...base,
state: "enforced",
evidence: ["CodeDirectory flag library-validation"],
explanation,
};
if (!has(input, "runtime"))
return {
...base,
state: "not-enforced",
evidence: ["no hardened runtime or library-validation flag"],
explanation,
};
if (!input.entitlementsKnown)
return {
...base,
state: "unknown",
evidence: ["hardened runtime", "entitlements unavailable"],
explanation,
};
return entitlement(
input.entitlements,
"com.apple.security.cs.disable-library-validation",
) === true
? {
...base,
state: "disabled",
evidence: [
"hardened runtime",
"com.apple.security.cs.disable-library-validation",
],
explanation,
}
: {
...base,
state: "enforced",
evidence: ["hardened runtime"],
explanation,
};
};
const dyldEnvironment = (input: PostureInput): SecurityFacet => {
const base = {
facet: "dyld-environment-variables",
basis: "derived",
} as const;
const explanation =
"Whether dyld honors DYLD_* variables such as DYLD_INSERT_LIBRARIES at launch. setuid/setgid bits, a __RESTRICT segment, and system integrity policy can also restrict them and are not evaluated here.";
if (has(input, "restrict"))
return {
...base,
state: "ignored",
evidence: ["CodeDirectory flag restrict"],
explanation,
};
const platform = platformBinary(input);
if (platform !== "none")
return platform === "apple"
? {
...base,
state: "ignored",
evidence: ["platform binary"],
explanation,
}
: {
...base,
state: "unknown",
evidence: [...UNVERIFIED_PLATFORM],
explanation,
};
if (!has(input, "runtime"))
return input.signed && (input.codeDirectory?.flags ?? null) === null
? {
...base,
state: "unknown",
evidence: ["CodeDirectory flags unavailable"],
explanation,
}
: {
...base,
state: "honored",
evidence: ["no hardened runtime"],
explanation,
};
if (!input.entitlementsKnown)
return {
...base,
state: "unknown",
evidence: ["hardened runtime", "entitlements unavailable"],
explanation,
};
return entitlement(
input.entitlements,
"com.apple.security.cs.allow-dyld-environment-variables",
) === true
? {
...base,
state: "honored",
evidence: [
"hardened runtime",
"com.apple.security.cs.allow-dyld-environment-variables",
],
explanation,
}
: {
...base,
state: "ignored",
evidence: ["hardened runtime"],
explanation,
};
};
const debuggerAttach = (input: PostureInput): SecurityFacet => {
const base = { facet: "debugger-attach", basis: "derived" } as const;
const explanation =
"Whether a debugger running as the same user can obtain the task port. Developer Tools authorization and system integrity policy still apply.";
if (
input.entitlementsKnown &&
entitlement(input.entitlements, "com.apple.security.get-task-allow") ===
true
)
return {
...base,
state: "allowed",
evidence: ["com.apple.security.get-task-allow"],
explanation,
};
const platform = platformBinary(input);
if (platform !== "none")
return platform === "apple"
? {
...base,
state: "blocked",
evidence: ["platform binary"],
explanation,
}
: {
...base,
state: "unknown",
evidence: [...UNVERIFIED_PLATFORM],
explanation,
};
if (has(input, "runtime"))
return input.entitlementsKnown
? {
...base,
state: "blocked",
evidence: ["hardened runtime without get-task-allow"],
explanation,
}
: {
...base,
state: "unknown",
evidence: ["hardened runtime", "entitlements unavailable"],
explanation,
};
if (input.signed && (input.codeDirectory?.flags ?? null) === null)
return {
...base,
state: "unknown",
evidence: ["CodeDirectory flags unavailable"],
explanation,
};
return {
...base,
state: "allowed",
evidence: ["no hardened runtime"],
explanation,
};
};
const executableMemory = (input: PostureInput): SecurityFacet => {
const base = { facet: "executable-memory", basis: "derived" } as const;
const explanation =
"Hardened-runtime limits on writable or unsigned executable memory, relaxed by code-signing entitlements.";
if (!has(input, "runtime"))
return input.signed && (input.codeDirectory?.flags ?? null) === null
? {
...base,
state: "unknown",
evidence: ["CodeDirectory flags unavailable"],
explanation,
}
: {
...base,
state: "unrestricted",
evidence: ["no hardened runtime"],
explanation,
};
if (!input.entitlementsKnown)
return {
...base,
state: "unknown",
evidence: ["hardened runtime", "entitlements unavailable"],
explanation,
};
for (const [key, state] of [
[
"com.apple.security.cs.disable-executable-page-protection",
"protection-disabled",
],
[
"com.apple.security.cs.allow-unsigned-executable-memory",
"unsigned-allowed",
],
["com.apple.security.cs.allow-jit", "jit-allowed"],
] as const)
if (entitlement(input.entitlements, key) === true)
return {
...base,
state,
evidence: ["hardened runtime", key],
explanation,
};
return {
...base,
state: "restricted",
evidence: ["hardened runtime"],
explanation,
};
};
const appSandbox = (input: PostureInput): SecurityFacet => {
const base = { facet: "app-sandbox", basis: "derived" } as const;
const explanation =
"Whether the App Sandbox entitlement confines the process.";
if (!input.entitlementsKnown)
return {
...base,
state: "unknown",
evidence: ["entitlements unavailable"],
explanation,
};
return entitlement(input.entitlements, "com.apple.security.app-sandbox") ===
true
? {
...base,
state: "sandboxed",
evidence: ["com.apple.security.app-sandbox"],
explanation,
}
: {
...base,
state: "not-sandboxed",
evidence: ["no com.apple.security.app-sandbox entitlement"],
explanation,
};
};
/** Derive launch-time security facets from observed flags and entitlements. */
export const deriveSecurityFacets = (input: PostureInput): SecurityFacet[] => {
const facets = [
libraryValidation(input),
dyldEnvironment(input),
debuggerAttach(input),
executableMemory(input),
appSandbox(input),
];
// An invalid main signature leaves flag bits unauthenticated; keep the raw
// CodeDirectory fields but report every facet as unknown.
if (input.signatureInvalid === true)
return facets.map((facet) => ({
...facet,
state: "unknown" as const,
evidence: ["signature verification failed"],
}));
// Each slice runs under its own signature; the inspected one does not
// describe an unsigned slice of the same file.
return input.mixedSlices
? facets.map((facet) => ({
...facet,
state: "unknown" as const,
evidence: ["architecture slices differ in signing state"],
}))
: facets;
};
-141
View File
@@ -1,141 +0,0 @@
import type { InspectSignature } from "../domain/native/nativeInspection.js";
import type { NativeCommandCapture } from "./CommandRunner.js";
/** Strip the diagnostic's echoed pathname for classification, keeping the raw line elsewhere. */
export const codesignReason = (line: string, path: string): string => {
if (line.startsWith(`${path}: `)) return line.slice(path.length + 2);
if (line.startsWith("/") || line.startsWith(`${path}/`)) {
const delimiter = line.lastIndexOf(": ");
return delimiter >= 0 ? line.slice(delimiter + 2) : "";
}
return line;
};
/** Remove the complete selected path before splitting lines, including embedded LF bytes. */
export const codesignReasons = (
capture: NativeCommandCapture,
path: string,
): string[] => {
const observed = `${capture.stderr}\n${capture.stdout}`;
const text =
path.length === 0 ? observed : observed.split(path).join("$SELECTED_PATH");
const lines = text.split("\n");
const progress = lines.some((line) =>
/^--(?:prepared|validated):/u.test(line),
);
return (
lines
.filter((line) => !/^--(?:prepared|validated):/u.test(line))
// Unprefixed continuation text can belong to an unescaped nested filename.
// Keep it as raw evidence, but do not turn it into a verification reason.
.filter(
(line) =>
!progress ||
line.startsWith("$SELECTED_PATH: ") ||
line.startsWith("$SELECTED_PATH/"),
)
.map((line) => codesignReason(line, "$SELECTED_PATH"))
);
};
/** Classify only explicit diagnostic reasons; nonzero exit alone proves no invalidity. */
const operationalReason = (reason: string): boolean =>
/^(?:permission denied|operation not permitted|EACCES\b|EPERM\b|I\/O error|input\/output error)/iu.test(
reason,
);
/**
* Definitive codesign rejections. A nonzero exit whose text is not in this
* list, and is not an operational failure, stays `unknown` so an unrecognized
* diagnostic is not reported as a broken signature.
*/
const INVALID_SIGNATURE_REASONS = [
"a resource envelope is obsolete",
"a sealed resource is missing or invalid",
"bundle format unrecognized, invalid, or unsuitable",
"code or signature modified",
"file added:",
"file modified:",
"invalid or unsupported format",
"invalid signature",
"main executable failed strict validation",
"resource envelope is obsolete",
"resource fork, Finder information, or similar detritus not allowed",
"signature is invalid",
"unsealed contents present",
] as const;
const invalidSignatureReason = (reason: string): boolean => {
const text = reason.toLowerCase();
return INVALID_SIGNATURE_REASONS.some((phrase) =>
text.startsWith(phrase.toLowerCase()),
);
};
/** Operational diagnostics can coexist with an independently proven invalid component. */
export const codesignOperationalFailure = (
capture: NativeCommandCapture,
path: string,
): boolean => codesignReasons(capture, path).some(operationalReason);
const verificationStatus = (
capture: NativeCommandCapture,
reasons: readonly string[],
unsigned: boolean,
): NonNullable<InspectSignature["verification"]>["status"] => {
if (capture.exitCode === 0) return "valid";
const unsignedReason = reasons.some((reason) =>
/^(?:code object is not signed at all|code object is not signed|not signed at all)/iu.test(
reason,
),
);
if (reasons.some(invalidSignatureReason)) return "invalid";
if (unsignedReason && !unsigned) return "invalid";
if (reasons.some(operationalReason)) return "unknown";
if (unsignedReason) return "unsigned";
return "unknown";
};
/** Project codesign verification without changing pathname bytes or discarding diagnostics. */
export const signatureVerification = (
capture: NativeCommandCapture,
path: string,
unsigned: boolean,
progressCompleteness: "complete" | "ambiguous" = "complete",
): NonNullable<InspectSignature["verification"]> => {
const lines = `${capture.stderr}\n${capture.stdout}`
.split("\n")
.filter((line) => line.length > 0);
const diagnostics = lines.filter(
(line) => !/^--(?:prepared|validated):/u.test(line),
);
return {
path,
status:
progressCompleteness === "ambiguous" && capture.exitCode !== 0
? "unknown"
: verificationStatus(capture, codesignReasons(capture, path), unsigned),
exit_code: capture.exitCode,
diagnostics,
prepared_nested_code: [
...new Set(
lines.flatMap((line) =>
line.startsWith("--prepared:")
? [line.slice("--prepared:".length)]
: [],
),
),
].sort(),
raw_stdout: capture.stdout,
raw_stderr: capture.stderr,
validated_nested_code: [
...new Set(
lines.flatMap((line) =>
line.startsWith("--validated:")
? [line.slice("--validated:".length)]
: [],
),
),
].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
};
};
+208 -16
View File
@@ -8,7 +8,7 @@ import { observeNativeUi } from "./NativeUiObservation.js";
import { LldbCallTracer, type NativeCallTracer } from "./LldbCallTracer.js";
import { observeNativeCalls } from "./NativeCallObservation.js";
import { dirname, isAbsolute, resolve } from "node:path";
import { open, stat } from "node:fs/promises";
import { open, realpath, stat } from "node:fs/promises";
import { z } from "zod";
@@ -25,6 +25,7 @@ import {
type NativeToolName,
} from "../contracts/native/nativeToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
@@ -35,9 +36,14 @@ import { BinaryTargetError } from "../domain/configurationErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import {
omittedPrototypeKeysLimitation,
parseXmlPropertyList,
} from "../domain/propertyListKeys.js";
import {
demangleSwiftSchema,
inspectPlistSchema,
inspectSignatureSchema,
listArchitecturesSchema,
type NativeCommandInvocation,
} from "../domain/native/nativeInspection.js";
@@ -48,8 +54,7 @@ import {
type NativeCommandCapture,
type NativeCommandRunner,
} from "./CommandRunner.js";
import { inspectNativeSignature } from "./NativeSignatureInspection.js";
import type { NativeObservation } from "./NativeObservation.js";
import { parseCodeSignature } from "./parsers/codesign.js";
import { parseDemangledSymbols } from "./parsers/demangle.js";
import { parseLipoArchitectures } from "./parsers/lipo.js";
import {
@@ -203,10 +208,6 @@ class NativeMacOSClient implements AnalysisClient {
)
: observation;
} catch (cause: unknown) {
if (cause instanceof AnalysisError) return err(cause);
// A helper that observed the abort escapes as a DOMException.
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(operation));
return err(
new AnalysisOutputError(operation, "Native output parsing failed", {
cause,
@@ -317,16 +318,127 @@ class NativeMacOSClient implements AnalysisClient {
async #inspectSignature(
signal?: AbortSignal,
): Promise<Result<NativeObservation, AnalysisError>> {
return inspectNativeSignature({
target: this.target,
capture: (argv, commandSignal) =>
this.#run("inspect_signature", "codesign", argv, {
signal: commandSignal,
acceptNonZero: true,
}),
invocation: (capture) => invocation(capture, this.target.path),
...(signal === undefined ? {} : { signal }),
const display = await this.#run(
"inspect_signature",
"codesign",
["-d", "--verbose=4", this.target.path],
{ signal, acceptNonZero: true },
);
if (!display.ok) return display;
const unsigned = isNonzeroUnsignedObservation(display.value);
const displayFailure = codeSignCaptureFailure(display.value);
if (displayFailure !== null) return err(displayFailure);
const requirements = await this.#run(
"inspect_signature",
"codesign",
["-d", "-r-", this.target.path],
{ signal, acceptNonZero: true },
);
if (!requirements.ok) return requirements;
const requirementsFailure = codeSignCaptureFailure(requirements.value);
if (requirementsFailure !== null) return err(requirementsFailure);
const entitlements = await this.#run(
"inspect_signature",
"codesign",
["-d", "--entitlements", ":-", this.target.path],
{ signal, acceptNonZero: true },
);
if (!entitlements.ok) return entitlements;
const entitlementsFailure = codeSignCaptureFailure(entitlements.value);
if (entitlementsFailure !== null) return err(entitlementsFailure);
// codesign echoes the resolved executable path in its diagnostics.
const parsed = parseCodeSignature(display.value.stderr, unsigned, [
this.target.path,
...(await canonicalPath(this.target.path)),
]);
// The requirement is printed to stdout; stderr echoes the path.
const requirementText =
/designated\s*=>\s*(.+)$/mu.exec(requirements.value.stdout)?.[1] ?? null;
// Entitlements XML is printed to stdout; stderr echoes the path.
const entitlementValue = parseEntitlements(entitlements.value.stdout);
const captures = [display.value, requirements.value, entitlements.value];
const limitations = [
...parsed.limitations,
...(entitlementValue.omittedPrototypeKeys === 0
? []
: [
`Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`,
]),
];
const mixedSigning =
!unsigned &&
(isNonzeroUnsignedObservation(requirements.value) ||
isNonzeroUnsignedObservation(entitlements.value));
if (mixedSigning) {
const slices = await this.#inspectMixedSignatureSlices(
parsed.format,
requirements.value.exitCode !== 0,
entitlements.value.exitCode !== 0,
signal,
);
if (!slices.ok) return slices;
captures.push(...slices.value.captures);
limitations.push(...slices.value.limitations);
}
const provenance = captures.map((capture) =>
invocation(capture, this.target.path),
);
const result = inspectSignatureSchema.parse({
...parsed,
designated_requirement: requirementText,
entitlements: entitlementValue.value,
provenance,
limitations,
});
return ok({
result: jsonValueSchema.parse(result),
provenance,
limitations: result.limitations,
locations: [],
});
}
async #inspectMixedSignatureSlices(
format: string | null,
requirementsUnavailable: boolean,
entitlementsUnavailable: boolean,
signal?: AbortSignal,
): Promise<Result<MixedSignatureSlices, AnalysisError>> {
const captures: NativeCommandCapture[] = [];
const unsigned: string[] = [];
const unclassified: string[] = [];
for (const architecture of codeSignArchitectures(format, this.target)) {
const slice = await this.#run(
"inspect_signature",
"codesign",
["-d", "-a", architecture, "--verbose=4", this.target.path],
{ signal, acceptNonZero: true },
);
if (!slice.ok) return slice;
captures.push(slice.value);
if (slice.value.exitCode === 0) continue;
if (isNonzeroUnsignedObservation(slice.value))
unsigned.push(architecture);
else unclassified.push(architecture);
}
const limitations = [
unsigned.length > 0
? `Unsigned Mach-O slices: ${unsigned.join(", ")}.`
: "One or more Mach-O slices are unsigned, but their architectures could not be classified.",
];
if (unclassified.length > 0)
limitations.push(
`Signature state could not be classified for Mach-O slices: ${unclassified.join(", ")}.`,
);
if (requirementsUnavailable)
limitations.push(
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
);
if (entitlementsUnavailable)
limitations.push(
"The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.",
);
return ok({ captures, limitations });
}
async #inspectPlist(
@@ -451,6 +563,18 @@ class NativeMacOSClient implements AnalysisClient {
}
}
interface NativeObservation {
readonly result: JsonValue;
readonly provenance: readonly NativeCommandInvocation[];
readonly limitations: readonly string[];
readonly locations: readonly EvidenceLocation[];
}
interface MixedSignatureSlices {
readonly captures: readonly NativeCommandCapture[];
readonly limitations: readonly string[];
}
const isNativeOperation = (
operation: AnalysisOperation,
): operation is NativeToolName =>
@@ -471,6 +595,62 @@ const translateCommandFailure = (
return new ProviderAdapterError(IDENTITY.id, operation, { cause: failure });
};
const translateCodeSignExitFailure = (
capture: NativeCommandCapture,
): AnalysisError =>
translateCommandFailure(
"inspect_signature",
new NativeCommandFailure("codesign", "nonzero-exit", capture.exitCode),
);
const commandOutput = (capture: NativeCommandCapture): string =>
`${capture.stdout}\n${capture.stderr}`;
/**
* A signed artifact exits zero; its diagnostics can still contain this text
* through the echoed path or identifier.
*/
const isNonzeroUnsignedObservation = (capture: NativeCommandCapture): boolean =>
capture.exitCode !== null &&
capture.exitCode !== 0 &&
/not signed at all|code object is not signed/iu.test(commandOutput(capture));
const canonicalPath = async (path: string): Promise<string[]> => {
try {
return [await realpath(path)];
} catch {
return [];
}
};
const codeSignCaptureFailure = (
capture: NativeCommandCapture,
): AnalysisError | null =>
capture.exitCode === 0 || isNonzeroUnsignedObservation(capture)
? null
: translateCodeSignExitFailure(capture);
const codeSignArchitectures = (
format: string | null,
target: BinaryTarget,
): string[] => {
const universal = /^Mach-O universal \(([^)\r\n]+)\)$/u.exec(
format ?? "",
)?.[1];
if (universal !== undefined) {
const architectures = universal
.split(/\s+/u)
.filter((architecture) =>
/^(?:i386|x86_64|armv7|arm64|arm64e)$/u.test(architecture),
);
if (architectures.length > 0) return [...new Set(architectures)];
}
if (target.kind !== "executable" || target.format !== "mach-o") return [];
return target.availableArchitectures.map((architecture) =>
architecture === "x86" ? "i386" : architecture,
);
};
const invocation = (
capture: NativeCommandCapture,
artifactPath: string,
@@ -491,6 +671,18 @@ const invocation = (
stderr_bytes: capture.stderrBytes,
});
const parseEntitlements = (
output: string,
): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => {
const start = output.indexOf("<?xml");
const end = output.lastIndexOf("</plist>");
if (start < 0 || end < start) return { value: null, omittedPrototypeKeys: 0 };
const { value, omittedPrototypeKeys } = parseXmlPropertyList(
output.slice(start, end + "</plist>".length),
);
return { value: jsonValueSchema.parse(value), omittedPrototypeKeys };
};
/**
* Report a plist that cannot be decoded as the caller's selection, or as the
* target's missing default, rather than as a failed tool run.
-78
View File
@@ -1,78 +0,0 @@
import { expect, it } from "vitest";
import { inspectNestedCodePaths } from "./NativeNestedCode.js";
it("observes cancellation between nested filesystem lookups", async () => {
const controller = new AbortController();
const paths: string[] = [];
await expect(
inspectNestedCodePaths(
{
validated_nested_code: ["/app/first", "/app/second"],
},
controller.signal,
{
lookup: async (path) => {
paths.push(path);
controller.abort();
},
names: async () => [],
},
),
).rejects.toMatchObject({ name: "AbortError" });
expect(paths).toEqual(["/app/first"]);
});
it("checks prepared-only fragments with the same completeness rules", async () => {
const warnings = await inspectNestedCodePaths(
{ prepared_nested_code: ["/app/odd"], validated_nested_code: [] },
undefined,
{
lookup: async () => {
throw Object.assign(new Error("not found"), { code: "ENOENT" });
},
names: async () => [],
},
);
expect(warnings.limitations).toContainEqual(
expect.stringContaining(
'prepared nested code at "/app/odd", which does not exist',
),
);
});
it.each(["EACCES", "EPERM", "EIO", "ESTALE"])(
"keeps observed records when path lookup fails with %s",
async (code) => {
const result = await inspectNestedCodePaths(
{
prepared_nested_code: ["/app/first"],
validated_nested_code: ["/app/second"],
},
undefined,
{
lookup: async () => {
throw Object.assign(new Error("lookup failed"), { code });
},
names: async () => [],
},
);
expect(result.limitations).toHaveLength(2);
expect(result.limitations.every((message) => message.includes(code))).toBe(
true,
);
expect(result.progressAmbiguous).toBe(true);
},
);
it("detects newline ambiguity even when the reported first fragment does not exist", async () => {
const result = await inspectNestedCodePaths(
{ prepared_nested_code: ["/app/odd"], validated_nested_code: [] },
undefined,
{
lookup: async () => {
throw Object.assign(new Error("missing"), { code: "ENOENT" });
},
names: async () => ["odd\n/app: invalid signature"],
},
);
expect(result.progressAmbiguous).toBe(true);
});
-105
View File
@@ -1,105 +0,0 @@
import { lstat, readdir } from "node:fs/promises";
import { dirname, basename } from "node:path";
/** Filesystem seam for path-completeness checks and cancellation regressions. */
export interface NativeNestedPathView {
lookup(path: string): Promise<void>;
names(directory: string): Promise<string[]>;
}
const FILE_SYSTEM: NativeNestedPathView = {
lookup: async (path) => {
await lstat(path);
},
names: (path) => readdir(path),
};
const errorCode = (cause: unknown) =>
cause instanceof Error && "code" in cause ? cause.code : undefined;
/** Completeness of newline-delimited progress records, distinct from verification. */
export interface NestedCodePathInspection {
readonly limitations: readonly string[];
readonly progressAmbiguous: boolean;
}
/** Preserve reported paths and operational errors, and identify ambiguous progress. */
export const inspectNestedCodePaths = async (
verification: {
readonly validated_nested_code: readonly string[];
readonly prepared_nested_code?: readonly string[];
},
signal?: AbortSignal,
view: NativeNestedPathView = FILE_SYSTEM,
): Promise<NestedCodePathInspection> => {
signal?.throwIfAborted();
const limitations: string[] = [];
const directories = new Map<string, Promise<ReadonlySet<string>>>();
let progressAmbiguous = false;
const records = new Map<string, "prepared" | "validated">();
for (const path of verification.prepared_nested_code ?? [])
records.set(path, "prepared");
for (const path of verification.validated_nested_code)
records.set(path, "validated");
for (const [path, record] of records) {
signal?.throwIfAborted();
try {
await view.lookup(path);
} catch (cause: unknown) {
signal?.throwIfAborted();
const code = errorCode(cause);
if (code === "EACCES" || code === "EPERM") {
progressAmbiguous = true;
limitations.push(
`Could not confirm completeness of codesign nested path ${JSON.stringify(path)}: permission denied (${code}); the reported path may be a fragment.`,
);
continue;
}
if (code === "ENOENT" || code === "ENOTDIR")
limitations.push(
`codesign reported ${record} nested code at ${JSON.stringify(path)}, which does not exist; structured nested-code records may hold fragments of a path containing a newline.`,
);
else {
progressAmbiguous = true;
limitations.push(
`Could not confirm completeness of codesign nested path ${JSON.stringify(path)}: ${cause instanceof Error ? cause.message : String(cause)} (${String(code ?? "unknown")}); the reported path may be a fragment.`,
);
}
}
signal?.throwIfAborted();
const parent = dirname(path);
let names = directories.get(parent);
if (names === undefined) {
names = view
.names(parent)
.then(
(entries) =>
new Set(
entries
.filter((name) => name.includes("\n"))
.map((name) => name.slice(0, name.indexOf("\n"))),
),
);
directories.set(parent, names);
}
try {
const prefixes = await names;
const ambiguous = prefixes.has(basename(path));
signal?.throwIfAborted();
if (ambiguous) {
progressAmbiguous = true;
limitations.push(
`codesign reported ${record} nested code at ${JSON.stringify(path)}, which could be the first line of a nested path containing a newline; structured nested-code records are ambiguous and should not be trusted as complete.`,
);
}
} catch (cause: unknown) {
signal?.throwIfAborted();
progressAmbiguous = true;
limitations.push(
`Could not confirm completeness of codesign nested path ${JSON.stringify(path)} from parent directory ${JSON.stringify(parent)}: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
}
signal?.throwIfAborted();
return { limitations, progressAmbiguous };
};
-11
View File
@@ -1,11 +0,0 @@
import type { JsonValue } from "../domain/jsonValue.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import type { NativeCommandInvocation } from "../domain/native/nativeInspection.js";
/** Provider observation before shared execution/Evidence projection. */
export interface NativeObservation {
readonly result: JsonValue;
readonly provenance: readonly NativeCommandInvocation[];
readonly limitations: readonly string[];
readonly locations: readonly EvidenceLocation[];
}
-15
View File
@@ -1,15 +0,0 @@
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
/** Parse the XML entitlement document within codesign output, retaining omission evidence. */
export const parseSignatureEntitlements = (
output: string,
): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => {
const start = output.indexOf("<?xml");
const end = output.lastIndexOf("</plist>");
if (start < 0 || end < start) return { value: null, omittedPrototypeKeys: 0 };
const { value, omittedPrototypeKeys } = parseXmlPropertyList(
output.slice(start, end + "</plist>".length),
);
return { value: jsonValueSchema.parse(value), omittedPrototypeKeys };
};
-234
View File
@@ -1,234 +0,0 @@
import { realpath } from "node:fs/promises";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
inspectSignatureSchema,
type NativeCommandInvocation,
} from "../domain/native/nativeInspection.js";
import { deriveSecurityFacets } from "./CodeSigningPolicy.js";
import {
bindSignatureTarget,
verifySignatureTarget,
} from "./SignatureTargetBinding.js";
import { omittedPrototypeKeysLimitation } from "../domain/propertyListKeys.js";
import { err, ok, type Result } from "../domain/result.js";
import {
NativeCommandFailure,
type NativeCommandCapture,
} from "./CommandRunner.js";
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacOSProviderMetadata.js";
import type { NativeObservation } from "./NativeObservation.js";
import {
signatureVerification,
codesignOperationalFailure,
codesignReasons,
} from "./CodesignVerification.js";
import { parseCodeSignature } from "./parsers/codesign.js";
import { parseSignatureEntitlements } from "./NativeSignatureEntitlements.js";
import {
inspectSignatureSlices,
unsignedSignatureCapture,
type SignatureCapture,
} from "./NativeSignatureProbes.js";
import {
SIGNATURE_POSTURE_LIMITATIONS,
signedCodePath,
stapledTicket,
} from "./NativeSignaturePosture.js";
import { inspectNestedCodePaths } from "./NativeNestedCode.js";
interface SignatureDisplays {
readonly display: NativeCommandCapture;
readonly requirements: NativeCommandCapture;
readonly entitlements: NativeCommandCapture;
}
/** Display failures and unsigned observations use the same reason parser as verification. */
const signatureDisplays = async (
path: string,
capture: SignatureCapture,
signal?: AbortSignal,
): Promise<Result<SignatureDisplays, AnalysisError>> => {
const display = await capture(["-d", "--verbose=4", path], signal);
if (!display.ok) return display;
const requirements = await capture(["-d", "-r-", path], signal);
if (!requirements.ok) return requirements;
const entitlements = await capture(
["-d", "--entitlements", ":-", path],
signal,
);
if (!entitlements.ok) return entitlements;
for (const result of [
display.value,
requirements.value,
entitlements.value,
]) {
if (result.exitCode !== 0 && !unsignedSignatureCapture(result, path))
return err(
new ProviderAdapterError(
NATIVE_MACOS_PROVIDER_IDENTITY.id,
"inspect_signature",
{
diagnostics: {
path,
tool: result.tool,
arguments: [...result.arguments],
exit_code: result.exitCode,
signal: result.signal,
raw_stdout: result.stdout,
raw_stderr: result.stderr,
},
cause: new NativeCommandFailure(
"codesign",
"nonzero-exit",
result.exitCode,
),
},
),
);
}
return ok({
display: display.value,
requirements: requirements.value,
entitlements: entitlements.value,
});
};
/** Compose displays, independent slice knowledge, verification, and local ticket evidence. */
export const inspectNativeSignature = async (options: {
readonly target: BinaryTarget;
readonly capture: SignatureCapture;
readonly invocation: (
capture: NativeCommandCapture,
) => NativeCommandInvocation;
readonly signal?: AbortSignal;
}): Promise<Result<NativeObservation, AnalysisError>> => {
const { target, capture, signal } = options;
const binding = await bindSignatureTarget(target, signal);
if (!binding.ok) return binding;
const displays = await signatureDisplays(target.path, capture, signal);
if (!displays.ok) return displays;
const { display, requirements, entitlements } = displays.value;
const unsigned = unsignedSignatureCapture(display, target.path);
let canonical: string[] = [];
try {
canonical = [await realpath(target.path)];
} catch {
/* The display retains the observed path. */
}
const parsed = parseCodeSignature(display.stderr, unsigned, [
target.path,
...canonical,
]);
const entitlementValue = parseSignatureEntitlements(entitlements.stdout);
const code = signedCodePath(target);
const verify = (path: string) =>
capture(["--verify", "--deep", "--strict", "--verbose=2", path], signal);
const verified = await verify(code);
if (!verified.ok) return verified;
const captures = [display, requirements, entitlements, verified.value];
let appleOrigin = false;
let appleOriginFailure: string | undefined;
if (!unsigned && (parsed.code_directory?.platform_identifier ?? 0) !== 0) {
const anchor = await capture(["--verify", "-R=anchor apple", code], signal);
if (!anchor.ok) return anchor;
captures.push(anchor.value);
appleOrigin = anchor.value.exitCode === 0;
if (!appleOrigin) {
const reason = codesignReasons(anchor.value, code)
.filter((line) => line.trim().length > 0)
.join("\n");
appleOriginFailure = reason || `codesign exited ${anchor.value.exitCode}`;
}
}
const slices = await inspectSignatureSlices(
target,
{ format: parsed.format, requirements, entitlements, unsigned },
capture,
signal,
);
if (!slices.ok) return slices;
captures.push(...slices.value.captures);
const aggregateUnsigned =
slices.value.signed === undefined ? unsigned : !slices.value.signed;
// Verification is independent evidence: uncertain display probes must not
// erase an explicit signature failure from the verifier.
const reportedVerification = signatureVerification(
verified.value,
code,
aggregateUnsigned,
);
const nestedPaths = await inspectNestedCodePaths(
reportedVerification,
signal,
);
const verification = nestedPaths.progressAmbiguous
? signatureVerification(
verified.value,
code,
aggregateUnsigned,
"ambiguous",
)
: reportedVerification;
const limitations = [
...parsed.limitations,
...SIGNATURE_POSTURE_LIMITATIONS,
...slices.value.limitations,
...nestedPaths.limitations,
];
if (entitlementValue.omittedPrototypeKeys !== 0)
limitations.push(
`Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`,
);
if (appleOriginFailure !== undefined)
limitations.push(`Apple-origin check failed: ${appleOriginFailure}`);
if (verification.status === "unknown")
limitations.push(
"Signature verification is inconclusive (permission, I/O or unrecognized diagnostic); this is not a proven broken signature.",
);
else if (codesignOperationalFailure(verified.value, code))
limitations.push(
"Some code could not be verified due to operational diagnostics; definitive signature failures elsewhere still prove invalidity.",
);
let mainInvalid =
verification.status === "invalid" || verification.status === "unknown";
if (mainInvalid && !unsigned && code !== target.path) {
const main = await verify(target.path);
if (!main.ok) return main;
captures.push(main.value);
mainInvalid = main.value.exitCode !== 0;
}
const provenance = captures.map(options.invocation);
const ticket = await stapledTicket(target, signal);
const version = await verifySignatureTarget(target, binding.value, signal);
if (!version.ok) return version;
const facets = deriveSecurityFacets({
signed: !aggregateUnsigned,
codeDirectory: parsed.code_directory,
entitlements: entitlementValue.value,
entitlementsKnown: unsigned || entitlements.exitCode === 0,
mixedSlices: slices.value.mixed,
appleOrigin,
signatureInvalid: mainInvalid,
});
const result = inspectSignatureSchema.parse({
...parsed,
signed: !aggregateUnsigned,
designated_requirement:
/designated\s*=>\s*(.+)$/mu.exec(requirements.stdout)?.[1] ?? null,
entitlements: entitlementValue.value,
verification,
stapled_ticket: ticket,
security_facets: facets,
provenance,
limitations,
});
return ok({
result: jsonValueSchema.parse(result),
provenance,
limitations: result.limitations,
locations: [],
});
};
-255
View File
@@ -1,255 +0,0 @@
import { createHash } from "node:crypto";
import { constants, type Stats } from "node:fs";
import { lstat, open } from "node:fs/promises";
import { join } from "node:path";
import type { InspectSignature } from "../domain/native/nativeInspection.js";
import { ticketStructureIssue } from "./NotarizationTicket.js";
/** Notarization tickets are a few kilobytes; larger files are hashed in chunks. */
const HASH_CHUNK_BYTES = 64 * 1024;
/** The code `codesign --verify` should check: the bundle when one was opened. */
export const signedCodePath = (target: {
readonly path: string;
readonly sourcePath?: string;
readonly bundleInfoPlist?: string;
}): string => appBundle(target) ?? target.path;
/**
* The app bundle directory a target was opened from. Only a bundle opened as a
* directory carries its Info.plist; a regular file named `X.app` is not one.
* The bundle is derived from the Info.plist location, not from the outer
* opening path, so an iOS-on-Mac wrapper resolves to the inner `Wrapper/*.app`
* bundle whose executable and ticket were actually inspected.
*/
const appBundle = (target: {
readonly sourcePath?: string;
readonly bundleInfoPlist?: string;
}): string | undefined => {
if (target.bundleInfoPlist === undefined) return undefined;
const derived = bundleDirFromPlist(target.bundleInfoPlist);
if (derived !== undefined) return derived;
return target.sourcePath;
};
/**
* Bundle directory containing an Info.plist: `.../Foo.app/Contents/Info.plist`
* lives two levels below its bundle, while a flat `.../Foo.app/Info.plist`
* lives one level below it.
*/
const bundleDirFromPlist = (plist: string): string | undefined => {
const contentsSuffix = "/Contents/Info.plist";
if (plist.endsWith(contentsSuffix))
return plist.slice(0, -"/Contents/Info.plist".length) || undefined;
const flatSuffix = "/Info.plist";
if (plist.endsWith(flatSuffix))
return plist.slice(0, -"/Info.plist".length) || undefined;
return undefined;
};
/**
* Report a ticket stapled to an app bundle, which `stapler` stores at
* `Contents/CodeResources`. Presence is observed; the ticket's validity and
* Apple's notarization record are not checked, because that needs the network.
*/
export const stapledTicket = async (
target: { readonly sourcePath?: string; readonly bundleInfoPlist?: string },
signal?: AbortSignal,
pathProbe: (path: string, phase: "before" | "after") => Promise<Stats> = (
path,
) => lstat(path),
): Promise<InspectSignature["stapled_ticket"]> => {
signal?.throwIfAborted();
const bundle = appBundle(target);
if (bundle === undefined) {
signal?.throwIfAborted();
return {
status: "not-applicable",
path: null,
sha256: null,
size: null,
reason: null,
};
}
const relative = "Contents/CodeResources";
const path = join(bundle, relative);
try {
signal?.throwIfAborted();
const metadata = await pathProbe(path, "before");
if (!metadata.isFile()) {
signal?.throwIfAborted();
return absent;
}
// Open nonblocking so a concurrent replacement with a FIFO cannot hang
// inspect_signature; the opened handle is revalidated below.
const handle = await open(
path,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
try {
const opened = await handle.stat();
// The path was a regular file at lstat. A directory or FIFO now means
// the ticket was replaced; absence would hide that change.
if (!opened.isFile())
return {
status: "unreadable",
path: relative,
sha256: null,
size: null,
reason: "changed",
};
// If the path was replaced between lstat and open, or mutated while
// hashing, the digest would describe the wrong bytes: dev and ino catch
// replacement, while size and mtime catch same-inode writes. Report any
// drift as changed instead of a stale digest.
if (!sameTicketFile(opened, metadata))
return {
status: "unreadable",
path: relative,
sha256: null,
size: null,
reason: "changed",
};
const structureIssue = await ticketStructureIssue(
async (offset, length) => {
signal?.throwIfAborted();
const buffer = new Uint8Array(length);
let done = 0;
while (done < length) {
const { bytesRead } = await handle.read(
buffer,
done,
length - done,
offset + done,
);
if (bytesRead === 0) break;
done += bytesRead;
}
return buffer.subarray(0, done);
},
opened.size,
);
const hash = createHash("sha256");
const buffer = new Uint8Array(HASH_CHUNK_BYTES);
let size = 0;
while (size < opened.size) {
signal?.throwIfAborted();
const { bytesRead } = await handle.read(
buffer,
0,
Math.min(buffer.length, opened.size - size),
size,
);
if (bytesRead === 0) break;
size += bytesRead;
hash.update(buffer.subarray(0, bytesRead));
}
signal?.throwIfAborted();
const closing = await handle.stat();
if (!sameTicketFile(closing, opened) || size !== opened.size)
return {
status: "unreadable",
path: relative,
sha256: null,
size: null,
reason: "changed",
};
// An atomic replacement swaps the pathname to a new inode while the
// handle still describes the unlinked original: re-resolve the path and
// refuse a digest that no longer belongs to the bundle.
try {
const current = await pathProbe(path, "after");
if (!sameTicketFile(current, opened))
return {
status: "unreadable",
path: relative,
sha256: null,
size: null,
reason: "changed",
};
} catch (cause: unknown) {
signal?.throwIfAborted();
const code = errorCode(cause);
if (code !== "ENOENT" && code !== "ENOTDIR") throw cause;
return {
status: "unreadable",
path: relative,
sha256: null,
size: null,
reason: "changed",
};
}
return {
status: structureIssue === null ? "present" : "unreadable",
path: relative,
sha256: hash.digest("hex"),
size,
reason: structureIssue,
};
} finally {
await handle.close();
}
} catch (cause: unknown) {
const code = errorCode(cause);
if (code === "ENOENT" || code === "ENOTDIR") return absent;
// A privacy or ACL denial leaves the ticket's presence unknown, not the
// whole signature inspection.
if (code === "EACCES" || code === "EPERM" || code === "EIO")
return {
status: "unreadable",
path: relative,
sha256: null,
size: null,
reason: code,
};
throw cause;
}
};
/**
* Whether two observations describe the same file version. Device and inode
* catch replacement; size and mtime catch same-inode writes and truncations
* whose digest would otherwise mix multiple file states.
*/
const sameTicketFile = (
left: {
readonly dev: number;
readonly ino: number;
readonly size: number;
readonly mtimeMs: number;
readonly ctimeMs: number;
},
right: {
readonly dev: number;
readonly ino: number;
readonly size: number;
readonly mtimeMs: number;
readonly ctimeMs: number;
},
): boolean =>
left.dev === right.dev &&
left.ino === right.ino &&
left.size === right.size &&
left.mtimeMs === right.mtimeMs &&
left.ctimeMs === right.ctimeMs;
const absent = {
status: "absent",
path: null,
sha256: null,
size: null,
reason: null,
} as const;
const errorCode = (cause: unknown): string | undefined =>
cause instanceof Error && "code" in cause && typeof cause.code === "string"
? cause.code
: undefined;
/** Limitations that keep verification and notarization claims bounded. */
export const SIGNATURE_POSTURE_LIMITATIONS = [
"Signature verification is local `codesign --verify --deep --strict`; certificate revocation, Gatekeeper policy, and Apple's notarization records are not checked.",
"Security facets are derived from CodeDirectory flags and entitlements; runtime policy such as System Integrity Protection, AMFI, and setuid bits can further restrict a process.",
"Ticket presence requires recognized local s8ch/g8tk container and DER framing; certificate authenticity, ticket signature validity, and ticket-to-code binding are not verified.",
];
-148
View File
@@ -1,148 +0,0 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { canonicalJson } from "../domain/comparisonSemantics.js";
import { ok, type Result } from "../domain/result.js";
import type { NativeCommandCapture } from "./CommandRunner.js";
import { codesignReasons } from "./CodesignVerification.js";
import { parseCodeSignature } from "./parsers/codesign.js";
import { parseSignatureEntitlements } from "./NativeSignatureEntitlements.js";
import {
signatureArchitectures,
summarizeSignatureSlices,
} from "./NativeSignatureSlices.js";
/** The provider owns process execution and typed failure translation. */
export type SignatureCapture = (
argv: readonly string[],
signal?: AbortSignal,
) => Promise<Result<NativeCommandCapture, AnalysisError>>;
/** Detect explicit unsigned display reasons, not words embedded in echoed pathnames. */
export const unsignedSignatureCapture = (
capture: NativeCommandCapture,
path: string,
): boolean =>
capture.exitCode !== null &&
capture.exitCode !== 0 &&
codesignReasons(capture, path).some((reason) =>
/^(?:code object is not signed at all|code object is not signed|not signed at all)/iu.test(
reason,
),
);
/** Slice observations and aggregate knowledge, with every contributing capture retained. */
export interface SignatureSliceObservation {
readonly mixed: boolean;
readonly captures: readonly NativeCommandCapture[];
readonly limitations: readonly string[];
readonly signed: boolean | undefined;
readonly classificationUnknown: boolean;
}
/** Proactively inspect independent signatures of universal Mach-O architectures. */
export const inspectSignatureSlices = async (
target: BinaryTarget,
input: {
readonly format: string | null;
readonly requirements: NativeCommandCapture;
readonly entitlements: NativeCommandCapture;
readonly unsigned: boolean;
},
capture: SignatureCapture,
signal?: AbortSignal,
): Promise<Result<SignatureSliceObservation, AnalysisError>> => {
const architectures = signatureArchitectures(input.format, target);
const gate =
!input.unsigned &&
(unsignedSignatureCapture(input.requirements, target.path) ||
unsignedSignatureCapture(input.entitlements, target.path));
if (!gate && architectures.length <= 1)
return ok({
mixed: false,
captures: [],
limitations: [],
signed: undefined,
classificationUnknown: false,
});
const captures: NativeCommandCapture[] = [];
const signed: string[] = [];
const unsigned: string[] = [];
const unclassified: string[] = [];
const postures = new Map<string, string>();
for (const architecture of architectures) {
const display = await capture(
["-d", "-a", architecture, "--verbose=4", target.path],
signal,
);
if (!display.ok) return display;
captures.push(display.value);
if (display.value.exitCode !== 0) {
(unsignedSignatureCapture(display.value, target.path)
? unsigned
: unclassified
).push(architecture);
continue;
}
signed.push(architecture);
const entitlements = await capture(
["-d", "--entitlements", ":-", "-a", architecture, target.path],
signal,
);
if (!entitlements.ok) return entitlements;
captures.push(entitlements.value);
const directory = parseCodeSignature(display.value.stderr, false, [
target.path,
]).code_directory;
if (
directory?.flags === null ||
directory === null ||
entitlements.value.exitCode !== 0
) {
unclassified.push(architecture);
continue;
}
postures.set(
architecture,
canonicalJson(
{
flags: directory.flags.value,
platform: directory.platform_identifier,
entitlements: parseSignatureEntitlements(entitlements.value.stdout)
.value,
},
"Signature slice posture",
),
);
}
const summary = summarizeSignatureSlices({
signed,
unsigned,
unclassified,
postures,
});
const limitations: string[] = [];
if (unsigned.length > 0)
limitations.push(`Unsigned Mach-O slices: ${unsigned.join(", ")}.`);
if (summary.postureDiffers)
limitations.push(
"Signed Mach-O slices differ in CodeDirectory flags or entitlements; aggregate facets are unknown.",
);
if (unclassified.length > 0)
limitations.push(
`Signature state could not be classified for Mach-O slices: ${unclassified.join(", ")}.`,
);
if (gate && input.requirements.exitCode !== 0)
limitations.push(
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
);
if (gate && input.entitlements.exitCode !== 0)
limitations.push(
"The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.",
);
return ok({
...summary,
mixed: gate || summary.mixed,
captures,
limitations,
});
};
-51
View File
@@ -1,51 +0,0 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
/** Architectures to probe, retaining subtypes from codesign and target inventory. */
export const signatureArchitectures = (
format: string | null,
target: BinaryTarget,
): string[] => {
const universal = /^Mach-O universal \(([^)\r\n]+)\)$/u.exec(
format ?? "",
)?.[1];
const declared =
universal?.split(/\s+/u).filter((name) => name.length > 0) ?? [];
const available =
target.kind === "executable" && target.format === "mach-o"
? target.availableArchitectures.map((name) =>
name === "x86" ? "i386" : name,
)
: [];
return [...new Set([...declared, ...available])];
};
/** Aggregate signing knowledge, keeping inconclusive probes distinct from unsigned. */
export const summarizeSignatureSlices = (input: {
readonly signed: readonly string[];
readonly unsigned: readonly string[];
readonly unclassified: readonly string[];
readonly postures: ReadonlyMap<string, string>;
}): {
readonly signed: boolean | undefined;
readonly mixed: boolean;
readonly postureDiffers: boolean;
readonly classificationUnknown: boolean;
} => {
const classificationUnknown = input.unclassified.length > 0;
const postureDiffers = new Set(input.postures.values()).size > 1;
const signed =
input.signed.length > 0
? true
: classificationUnknown || input.unsigned.length === 0
? undefined
: false;
return {
signed,
classificationUnknown,
postureDiffers,
mixed:
classificationUnknown ||
(input.signed.length > 0 && input.unsigned.length > 0) ||
postureDiffers,
};
};
-39
View File
@@ -1,39 +0,0 @@
import { expect, it } from "vitest";
import { notarizationTicketFixture } from "../../tests/fixtures/notarizationTicket.js";
import { ticketStructureIssue } from "./NotarizationTicket.js";
const inspect = (bytes: Uint8Array) =>
ticketStructureIssue(
async (offset, length) => bytes.subarray(offset, offset + length),
bytes.length,
);
it("recognizes local ticket framing without claiming cryptographic validity", async () => {
expect(await inspect(notarizationTicketFixture())).toBeNull();
});
it("rejects empty, arbitrary, truncated and contradictory ticket containers", async () => {
expect(await inspect(new Uint8Array())).toContain("Not a recognized");
expect(await inspect(Buffer.from("stapled ticket bytes"))).toContain(
"Not a recognized",
);
const ticket = Buffer.from(notarizationTicketFixture());
expect(await inspect(ticket.subarray(0, ticket.length - 1))).toContain(
"signature",
);
ticket.writeUInt32LE(2, 16 + 4 + 8);
expect(await inspect(ticket)).toContain("hash records disagree");
});
it("requires the complete signature field and validates all padding bytes", async () => {
const bytes = Buffer.from(notarizationTicketFixture());
expect(await inspect(bytes.subarray(0, bytes.length - 64))).toContain(
"72 bytes",
);
expect(await inspect(bytes)).toBeNull();
bytes[bytes.length - 1] = 1;
expect(await inspect(bytes)).toContain("signature padding");
expect(await inspect(Buffer.concat([bytes, Buffer.alloc(1)]))).toContain(
"72 bytes",
);
});
-78
View File
@@ -1,78 +0,0 @@
/** Bounded positional reader used to inspect local ticket framing. */
export type TicketReadAt = (
offset: number,
length: number,
) => Promise<Uint8Array>;
/**
* Inspect the local s8ch/g8tk ticket framing. Observed in stapled app tickets;
* see https://www.mothersruin.com/software/Archaeology/reverse/tickets.html.
* This does not authenticate certificates, signatures, or notarized cdhashes.
*/
export const ticketStructureIssue = async (
read: TicketReadAt,
size: number,
): Promise<string | null> => {
const header = Buffer.from(await read(0, 16));
if (header.length !== 16 || header.toString("ascii", 0, 4) !== "s8ch")
return "Not a recognized s8ch notarization ticket container";
if (header.readUInt32LE(4) !== 1)
return `Unsupported notarization ticket container version ${header.readUInt32LE(4)}`;
const certificateSize = header.readUInt32LE(8);
const contentSize = header.readUInt32LE(12);
const contentOffset = 16 + certificateSize;
const signatureOffset = contentOffset + contentSize;
if (certificateSize < 4 || contentSize < 24 || signatureOffset >= size)
return "Ticket certificate, content, or signature extent is missing or truncated";
const content = Buffer.from(await read(contentOffset, 24));
if (content.length !== 24 || content.toString("ascii", 0, 4) !== "g8tk")
return "Ticket content has no complete g8tk header";
const hashSize = content.readUInt16LE(6);
const hashCount = content.readUInt32LE(8);
if (
hashSize === 0 ||
hashCount === 0 ||
24 + (hashSize + 1) * hashCount !== contentSize
)
return "Ticket hash records disagree with the declared content length";
const certificates = await derSequenceExtent(read, 16, certificateSize);
if (certificates !== certificateSize)
return "Ticket certificate block has malformed DER framing";
const signatureFieldSize = 72;
if (size - signatureOffset !== signatureFieldSize)
return "Ticket signature field is truncated or does not contain exactly 72 bytes";
const signatureSize = await derSequenceExtent(
read,
signatureOffset,
signatureFieldSize,
);
if (signatureSize === null || signatureSize < 8)
return "Ticket signature has malformed DER framing";
// The fixed signature field contains DER followed by zero padding.
const paddingSize = size - signatureOffset - signatureSize;
const padding = await read(signatureOffset + signatureSize, paddingSize);
if (padding.byteLength !== paddingSize || padding.some((byte) => byte !== 0))
return "Ticket has malformed signature padding";
return null;
};
/** Read only a DER SEQUENCE tag/length and check its extent against its section. */
const derSequenceExtent = async (
read: TicketReadAt,
offset: number,
available: number,
): Promise<number | null> => {
const prefix = Buffer.from(await read(offset, Math.min(6, available)));
if (prefix.length < 2 || prefix[0] !== 0x30) return null;
const first = prefix[1] ?? 0;
if (first < 128) return first + 2 <= available ? first + 2 : null;
const width = first & 0x7f;
if (width === 0 || width > 4 || prefix.length < width + 2 || prefix[2] === 0)
return null;
let length = 0;
for (let index = 0; index < width; index++)
length = length * 256 + (prefix[index + 2] ?? 0);
return length >= 128 && length + width + 2 <= available
? length + width + 2
: null;
};
-161
View File
@@ -1,161 +0,0 @@
import { createHash } from "node:crypto";
import { constants, type BigIntStats } from "node:fs";
import { lstat, open } from "node:fs/promises";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacOSProviderMetadata.js";
/** Binding of commands to the selected executable's registered content/version. */
export interface SignatureTargetBinding {
readonly identity: string;
}
const identity = (stat: BigIntStats): string =>
[stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
const changedTarget = (target: BinaryTarget, reason: string) =>
err(
new AnalysisArtifactChangedError("inspect_signature", target.path, reason),
);
const signatureReadFailure = (
target: BinaryTarget,
cause: unknown,
): AnalysisError => {
const code =
cause instanceof Error && "code" in cause ? cause.code : undefined;
if (code === "EACCES" || code === "EPERM")
return new AnalysisAccessDeniedError(
"inspect_signature",
target.path,
code,
{ cause },
);
if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP")
return new AnalysisArtifactChangedError(
"inspect_signature",
target.path,
`Registered signature target is no longer readable (${code}): ${target.path}`,
{ cause },
);
return new ProviderAdapterError(
NATIVE_MACOS_PROVIDER_IDENTITY.id,
"inspect_signature",
{
cause,
diagnostics: {
path: target.path,
phase: "target-version-binding",
system_code: typeof code === "string" ? code : null,
reason: cause instanceof Error ? cause.message : String(cause),
},
},
);
};
/** Establish a bounded-memory digest/version baseline before external commands run. */
export const bindSignatureTarget = async (
target: BinaryTarget,
signal?: AbortSignal,
): Promise<Result<SignatureTargetBinding, AnalysisError>> => {
signal?.throwIfAborted();
try {
const before = await lstat(target.path, { bigint: true });
if (!before.isFile() || before.isSymbolicLink())
return changedTarget(
target,
`Signature target is no longer a regular file: ${target.path}`,
);
const file = await open(
target.path,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
try {
const opened = await file.stat({ bigint: true });
if (identity(opened) !== identity(before))
return changedTarget(
target,
`Signature target changed before open: ${target.path}`,
);
const size = Number(opened.size);
if (!Number.isSafeInteger(size))
return err(
new AnalysisResourceConstraintError(
"inspect_signature",
"file-size",
`Signature target size is not exactly representable: ${target.path}`,
null,
),
);
const hash = createHash("sha256");
const chunk = Buffer.alloc(64 * 1024);
let position = 0;
while (position < size) {
signal?.throwIfAborted();
const { bytesRead } = await file.read(
chunk,
0,
Math.min(chunk.length, size - position),
position,
);
if (bytesRead === 0)
return changedTarget(
target,
`Signature target was truncated while hashing: ${target.path}`,
);
hash.update(chunk.subarray(0, bytesRead));
position += bytesRead;
}
const observed = hash.digest("hex");
if (observed !== target.sha256)
return changedTarget(
target,
`Signature target digest changed: ${target.path}; expected ${target.sha256}, observed ${observed}`,
);
const after = await file.stat({ bigint: true });
const current = await lstat(target.path, { bigint: true });
if (
identity(after) !== identity(opened) ||
identity(current) !== identity(opened)
)
return changedTarget(
target,
`Signature target changed while establishing its version: ${target.path}`,
);
return ok({ identity: identity(opened) });
} finally {
await file.close();
}
} catch (cause: unknown) {
signal?.throwIfAborted();
return err(signatureReadFailure(target, cause));
}
};
/** Reject drift between display, entitlement and verification captures. */
export const verifySignatureTarget = async (
target: BinaryTarget,
binding: SignatureTargetBinding,
signal?: AbortSignal,
): Promise<Result<void, AnalysisError>> => {
signal?.throwIfAborted();
try {
const current = await lstat(target.path, { bigint: true });
return current.isFile() && identity(current) === binding.identity
? ok(undefined)
: changedTarget(
target,
`Signature target changed during inspection: ${target.path}`,
);
} catch (cause: unknown) {
signal?.throwIfAborted();
return err(signatureReadFailure(target, cause));
}
};
-77
View File
@@ -1,77 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseCodeSignature } from "./codesign.js";
const display = (lines: readonly string[]): string =>
[
"Identifier=com.example.fixture",
"Format=app bundle with Mach-O thin (arm64)",
...lines,
"TeamIdentifier=TEAM12345",
"",
].join("\n");
describe("codesign CodeDirectory parsing", () => {
it("decodes CodeDirectory fields and flags from the numeric value", () => {
const parsed = parseCodeSignature(
display([
"CodeDirectory v=20500 size=443 flags=0x12a00(kill,restrict,library-validation,runtime) hashes=3209+7 location=embedded",
"Platform identifier=26",
"Hash type=sha256 size=32",
"Executable Segment flags=0x1",
"Runtime Version=26.4.0",
"Sealed Resources version=2 rules=13 files=290",
]),
false,
);
expect(parsed.code_directory).toEqual({
version: "20500",
flags: {
value: 0x12a00,
names: ["kill", "restrict", "library-validation", "runtime"],
},
code_slots: 3209,
special_slots: 7,
location: "embedded",
hash_type: "sha256",
platform_identifier: 26,
runtime_version: "26.4.0",
executable_segment_flags: 1,
});
expect(parsed.hardened_runtime).toBe(true);
expect(parsed.sealed_resources).toEqual({
status: "sealed",
version: 2,
rules: 13,
files: 290,
});
});
it("reports unsealed, unsigned and incomplete output without guessing", () => {
const bare = parseCodeSignature(
display([
"CodeDirectory v=20400 size=336 location=embedded",
"Sealed Resources=none",
]),
false,
);
expect(bare.code_directory).toMatchObject({
flags: null,
code_slots: null,
platform_identifier: null,
});
expect(bare.sealed_resources.status).toBe("none");
expect(parseCodeSignature(display([]), false)).toMatchObject({
code_directory: null,
sealed_resources: { status: "unknown" },
});
expect(
parseCodeSignature("fixture: code object is not signed at all\n", true),
).toMatchObject({
signed: false,
code_directory: null,
sealed_resources: { status: "none" },
stapled_ticket: { status: "not-applicable" },
});
});
});
-84
View File
@@ -1,5 +1,3 @@
import { type CodeDirectory } from "../../domain/native/codeSigningPosture.js";
import { codeDirectoryFlagNames } from "../CodeSigningPolicy.js";
import {
inspectSignatureSchema,
type InspectSignature,
@@ -25,21 +23,11 @@ export const parseCodeSignature = (
const values = new Map<string, string>();
const authorities: string[] = [];
const cdhashes: string[] = [];
let sealed: InspectSignature["sealed_resources"] = {
status: "unknown",
version: null,
rules: null,
files: null,
};
for (const line of fields.split("\n")) {
if (line.startsWith("CodeDirectory ")) {
values.set("CodeDirectory", line.slice("CodeDirectory ".length));
continue;
}
if (line.startsWith("Sealed Resources")) {
sealed = parseSealedResources(line);
continue;
}
const separator = line.indexOf("=");
if (separator < 1) continue;
const key = line.slice(0, separator).trim();
@@ -60,19 +48,6 @@ export const parseCodeSignature = (
entitlements: null,
timestamp: values.get("Timestamp") ?? null,
hardened_runtime: parseRuntime(values.get("CodeDirectory")),
code_directory: unsigned ? null : parseCodeDirectory(values),
sealed_resources: unsigned
? { status: "none", version: null, rules: null, files: null }
: sealed,
verification: null,
stapled_ticket: {
status: "not-applicable",
path: null,
sha256: null,
size: null,
reason: null,
},
security_facets: [],
limitations: unsigned
? ["Artifact is not signed."]
: [
@@ -129,62 +104,3 @@ const parseRuntime = (value: string | undefined): boolean | null => {
if (flags === undefined) return null;
return flags.split(",").some((flag) => flag.trim() === "runtime");
};
const integerField = (
text: string | undefined,
pattern: RegExp,
radix = 10,
): number | null => {
const match = text === undefined ? undefined : pattern.exec(text)?.[1];
if (match === undefined) return null;
const value = Number.parseInt(match, radix);
return Number.isSafeInteger(value) ? value : null;
};
/**
* `CodeDirectory v=20500 size=… flags=0x10000(runtime) hashes=3209+7
* location=embedded`, plus the separate hash, platform, runtime-version and
* executable-segment lines. Flag names are decoded from the numeric value.
*/
const parseCodeDirectory = (
values: ReadonlyMap<string, string>,
): CodeDirectory | null => {
const line = values.get("CodeDirectory");
if (line === undefined) return null;
const flags = integerField(line, /(?:^|\s)flags=0x([0-9a-f]+)/iu, 16);
return {
version: /(?:^|\s)v=(\S+)/u.exec(line)?.[1] ?? null,
flags:
flags === null
? null
: { value: flags, names: codeDirectoryFlagNames(flags) },
code_slots: integerField(line, /(?:^|\s)hashes=(\d+)\+/u),
special_slots: integerField(line, /(?:^|\s)hashes=\d+\+(\d+)/u),
location: /(?:^|\s)location=(\S+)/u.exec(line)?.[1] ?? null,
hash_type: values.get("Hash type")?.split(/\s+/u)[0] ?? null,
platform_identifier: integerField(
values.get("Platform identifier"),
/^(\d+)$/u,
),
runtime_version: values.get("Runtime Version") ?? null,
executable_segment_flags: integerField(
values.get("Executable Segment flags"),
/^0x([0-9a-f]+)$/iu,
16,
),
};
};
/** `Sealed Resources version=2 rules=13 files=290` or `Sealed Resources=none`. */
const parseSealedResources = (
line: string,
): InspectSignature["sealed_resources"] => {
if (/^Sealed Resources\s*=\s*none$/u.test(line.trim()))
return { status: "none", version: null, rules: null, files: null };
return {
status: "sealed",
version: integerField(line, /\sversion=(\d+)/u),
rules: integerField(line, /\srules=(\d+)/u),
files: integerField(line, /\sfiles=(\d+)/u),
};
};
+2
View File
@@ -7,6 +7,7 @@ import { registerCompareSourceToBundleTool } from "./registerApplicationTools/co
import { registerCompareJavaScriptExportShapesTool } from "./registerApplicationTools/compareExportShapes.js";
import { registerCoverageTools } from "./registerApplicationTools/coverage.js";
import { registerReconstructionObligationLedgerTool } from "./registerApplicationTools/obligationLedger.js";
import { registerInspectDyldSharedCacheTool } from "./registerApplicationTools/inspectDyldSharedCache.js";
import { registerProjectMobileApplicationGraphTools } from "./registerApplicationTools/projectMobileApplicationGraph.js";
import type { ApplicationToolRegistration } from "./registerApplicationTools/types.js";
@@ -25,4 +26,5 @@ export const registerApplicationTools = (
registerReconstructionObligationLedgerTool(server, options);
registerCoverageTools(server, options);
registerProjectMobileApplicationGraphTools(server, options);
registerInspectDyldSharedCacheTool(server, options);
};
@@ -0,0 +1,28 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { inspectDyldSharedCacheEvidence } from "../../application/apple/DyldSharedCacheService.js";
import { applicationToolContract } from "../../contracts/applicationToolContracts.js";
import { logToolExecution } from "../toolLogging.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { toCallToolResult } from "../toolResult.js";
import { recordResult } from "./helpers.js";
import type { ApplicationToolRegistration } from "./types.js";
/** Register the target-free dyld shared cache inspection tool. */
export const registerInspectDyldSharedCacheTool = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
const contract = applicationToolContract("inspect_dyld_shared_cache");
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async (input: unknown, context) => {
const result = await logToolExecution(options.logger, contract.name, () =>
inspectDyldSharedCacheEvidence(input, context.mcpReq.signal),
);
if (!result.ok) return toCallToolResult(result, contract);
return recordResult(options, contract, result.value);
},
);
};
+86 -4
View File
@@ -6,10 +6,12 @@ import { describe, expect } from "vitest";
import {
FILE_TYPE,
LC,
buildVersionCommand,
dylibCommand,
machoImage,
rpathCommand,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
import { dyldCacheFixture } from "../../../src/artifacts/apple/DyldSharedCache.fixture.js";
import { dylibResolutionResultSchema } from "../../../src/domain/apple/dylibResolution.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -36,6 +38,7 @@ const fixtureApp = async (): Promise<string> => {
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
"Contents/MacOS/App": machoImage({
commands: [
buildVersionCommand(1),
rpathCommand("@executable_path/../Frameworks"),
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"),
@@ -43,10 +46,16 @@ const fixtureApp = async (): Promise<string> => {
}),
"Contents/Frameworks/libcore.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib")],
commands: [
buildVersionCommand(1),
dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib"),
],
}),
"Contents/Helpers/tool": machoImage({
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib")],
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib"),
],
}),
});
return app;
@@ -145,7 +154,9 @@ describe("trace-dylib-resolution CLI", () => {
}
},
);
});
describe("trace-dylib-resolution CLI inputs", () => {
cliTest(
"resolves a standalone Mach-O against its own directory",
async ({ cli }) => {
@@ -153,11 +164,15 @@ describe("trace-dylib-resolution CLI", () => {
await writeFiles(directory, {
tool: machoImage({
commands: [
buildVersionCommand(1),
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 }),
"lib/libhelper.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
"unrelated.txt": "not traversed",
});
const result = await cli.run({
@@ -199,6 +214,69 @@ describe("trace-dylib-resolution CLI", () => {
});
},
);
cliTest(
"answers system install paths from a supplied shared cache",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-dylib-cache-");
await writeFiles(directory, {
tool: machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libnotcached.dylib"),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/swift/libswiftCore.dylib"),
],
}),
// The .01 subcache holding libswiftCore is not written.
dyld_shared_cache_arm64e: dyldCacheFixture([
{
path: "/usr/lib/libSystem.B.dylib",
bytes: machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
},
{
path: "/usr/lib/swift/libswiftCore.dylib",
bytes: machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
inSubcache: true,
},
]).main,
});
const result = await cli.run({
arguments: [
"trace-dylib-resolution",
join(directory, "tool"),
"--shared-cache",
join(directory, "dyld_shared_cache_arm64e"),
"--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.shared_cache).toMatchObject({
path: join(directory, "dyld_shared_cache_arm64e"),
architecture: "arm64e",
os_version: "26.6.0",
});
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
"shared-cache",
"undetermined",
"undetermined",
]);
expect(trace.coverage).toMatchObject({
status: "partial",
unverified_shared_cache_images: ["/usr/lib/swift/libswiftCore.dylib"],
});
},
);
});
describe.skipIf(process.getuid?.() === 0)(
@@ -211,10 +289,14 @@ describe.skipIf(process.getuid?.() === 0)(
await writeFiles(directory, {
tool: machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libsecret.dylib"),
],
}),
"libsecret.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
"libsecret.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
});
await chmod(join(directory, "libsecret.dylib"), 0o000);
try {
+1 -1
View File
@@ -185,7 +185,7 @@ describe("canonical product catalog", () => {
expect(
catalog.providers.find(({ id }) => id === APPLE_APPLICATION_PROVIDER.id)
?.capabilities,
).toEqual(["project_apple_application_graph"]);
).toEqual(["inspect_dyld_shared_cache", "project_apple_application_graph"]);
expect(
catalog.providers.find(({ id }) => id === MANAGED_WORKFLOW_PROVIDER.id)
?.capabilities,
@@ -0,0 +1,436 @@
import { createHash } from "node:crypto";
import { rm, symlink, writeFile } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import {
dyldCacheFixture,
type CacheFixture,
} from "../../../src/artifacts/apple/DyldSharedCache.fixture.js";
import {
FILE_TYPE,
CPU,
buildVersionCommand,
LC,
dylibCommand,
dylibUseCommand,
machoImage,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
import {
inspectDyldSharedCache,
inspectDyldSharedCacheEvidence,
} from "../../../src/application/apple/DyldSharedCacheService.js";
import { dyldSharedCacheResultSchema } from "../../../src/domain/apple/dyldSharedCache.js";
import { traceDylibResolution } from "../../../src/artifacts/apple/DylibResolutionReader.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("rejects a directory and overlapping combined VM mappings with specific reasons", async () => {
const directory = await createTestTempDirectory("rea-cache-invalid-regions-");
expect(await inspectDyldSharedCache({ cache_path: directory })).toMatchObject(
{ ok: false, error: { reason: "path" } },
);
const fixture = dyldCacheFixture(IMAGES);
const original = fixture.subcaches[0];
if (original === undefined) throw new Error("missing subcache fixture");
const subcache = Buffer.from(original.bytes);
const main = Buffer.from(fixture.main);
const mainTable = main.readUInt32LE(0x10);
const subTable = subcache.readUInt32LE(0x10);
subcache.writeBigUInt64LE(main.readBigUInt64LE(mainTable), subTable);
// Keep the declared VM offset consistent so the failure is the overlap.
main.writeBigUInt64LE(0n, main.readUInt32LE(0x188) + 16);
const path = await writeCache({
...fixture,
main,
subcaches: [{ ...original, bytes: subcache }],
});
const result = await inspectDyldSharedCache({ cache_path: path });
expect(result).toMatchObject({
ok: false,
error: { reason: "format", detail: expect.stringContaining("overlap") },
});
});
const dylib = (
installName: string,
dependencies: readonly Uint8Array[] = [],
platform = 1,
) =>
machoImage({
fileType: FILE_TYPE.dylib,
cpu: CPU.arm64e,
commands: [
buildVersionCommand(platform),
dylibCommand(LC.ID_DYLIB, installName),
...dependencies,
],
});
it("rejects a same-UUID subcache whose architecture or VM offset disagrees", async () => {
const fixture = dyldCacheFixture(IMAGES);
const original = fixture.subcaches[0];
if (original === undefined) throw new Error("missing subcache fixture");
const wrongArchitecture = Buffer.from(original.bytes);
wrongArchitecture.write("dyld_v1 x86_64".padEnd(15, " "), 0, "latin1");
const architecturePath = await writeCache({
...fixture,
subcaches: [{ ...original, bytes: wrongArchitecture }],
});
expect(
await inspectDyldSharedCache({ cache_path: architecturePath }),
).toMatchObject({
ok: false,
error: {
reason: "format",
detail: expect.stringContaining("does not match"),
},
});
const main = Buffer.from(fixture.main);
main.writeBigUInt64LE(0n, main.readUInt32LE(0x188) + 16);
const offsetPath = await writeCache({ ...fixture, main });
expect(
await inspectDyldSharedCache({ cache_path: offsetPath }),
).toMatchObject({
ok: false,
error: { reason: "format", detail: expect.stringContaining("disagrees") },
});
});
it("does not resolve a cached path whose bytes are not a Mach-O image", async () => {
const path = await writeCache(
dyldCacheFixture([
{ path: "/usr/lib/libSystem.B.dylib", bytes: Buffer.alloc(64, 0) },
]),
);
const targetPath = join(dirname(path), "tool");
const bytes = machoImage({
fileType: FILE_TYPE.execute,
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
],
});
await writeFile(targetPath, bytes);
const trace = await traceDylibResolution({
rootPath: dirname(path),
targetPath,
targetSha256: createHash("sha256").update(bytes).digest("hex"),
enumerateRoots: false,
parameters: { shared_cache: path },
});
expect(trace.edges[0]?.candidates[0]?.outcome).toBe("undetermined");
expect(trace.edges[0]?.resolution).toEqual({
status: "undetermined",
image: null,
});
expect(trace.coverage.unverified_shared_cache_images).toEqual([
"/usr/lib/libSystem.B.dylib",
]);
expect(trace.images.map(({ path: image }) => image)).not.toContain(
"/usr/lib/libSystem.B.dylib",
);
});
it("rejects unmapped images when no unavailable subcache can explain the address", async () => {
const fixture = dyldCacheFixture(IMAGES);
const main = Buffer.from(fixture.main);
main.writeBigUInt64LE(0x700000000n, main.readUInt32LE(0x1c0));
const path = await writeCache({ ...fixture, main });
expect(await inspectDyldSharedCache({ cache_path: path })).toMatchObject({
ok: false,
error: {
reason: "format",
detail: expect.stringContaining("unmapped image address"),
},
});
});
const writeCache = async (
fixture: CacheFixture,
name = "dyld_shared_cache_arm64e",
): Promise<string> => {
const directory = await createTestTempDirectory("rea-dyld-cache-");
const base = join(directory, "dyld_shared_cache_arm64e");
await writeFile(join(directory, name), fixture.main);
for (const { suffix, bytes } of fixture.subcaches)
await writeFile(`${base}${suffix}`, bytes);
return join(directory, name);
};
const IMAGES = [
{
path: "/usr/lib/libSystem.B.dylib",
bytes: dylib("/usr/lib/libSystem.B.dylib", [
dylibCommand(LC.REEXPORT_DYLIB, "/usr/lib/system/libcache.dylib"),
]),
},
{
path: "/usr/lib/swift/libswiftCore.dylib",
bytes: dylib("/usr/lib/swift/libswiftCore.dylib", [
dylibUseCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib", 0x4 | 0x8),
]),
inSubcache: true,
},
];
it.each([
{ type: 2, subtype: 0, expected: { id: 0, name: "development" } },
{ type: 2, subtype: 1, expected: { id: 1, name: "production" } },
{ type: 2, subtype: 9, expected: { id: 9, name: null } },
{ type: 0, subtype: 1, expected: null },
{ type: 1, subtype: 0, expected: null },
])(
"preserves applicable multi-cache subtype $subtype for type $type",
async ({ type, subtype, expected }) => {
const fixture = dyldCacheFixture(IMAGES);
const main = Buffer.from(fixture.main);
main.writeBigUInt64LE(BigInt(type), 0x68);
main.writeUInt32LE(subtype, 0x1c8);
const path = await writeCache({ ...fixture, main });
const result = await inspectDyldSharedCache({ cache_path: path });
if (!result.ok) throw result.error;
const cache = dyldSharedCacheResultSchema.parse(result.value);
expect(cache.cache_type).toBe(
type === 2 ? "multi-cache" : type === 0 ? "development" : "production",
);
expect(cache.cache_subtype).toEqual(expected);
},
);
it("keeps a legacy multi-cache subtype unknown when its header lacks the field", async () => {
const fixture = dyldCacheFixture([], { legacy: true });
const main = Buffer.from(fixture.main);
main.writeBigUInt64LE(2n, 0x68);
const path = await writeCache({ ...fixture, main });
const result = await inspectDyldSharedCache({ cache_path: path });
if (!result.ok) throw result.error;
expect(result.value.cache_subtype).toBeNull();
});
describe("dyld shared cache inspection", () => {
it("reads the header, subcaches, image list and cached load commands", async () => {
const path = await writeCache(dyldCacheFixture(IMAGES));
const result = await inspectDyldSharedCache({
cache_path: path,
images: ["/usr/lib/swift/libswiftCore.dylib", "/usr/lib/missing.dylib"],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const cache = dyldSharedCacheResultSchema.parse(result.value);
expect(cache).toMatchObject({
architecture: "arm64e",
platform: { id: 1, name: "macos" },
os_version: "26.6.0",
shared_region: { start: "0x180000000", size: "0x20000000" },
images_total: 2,
coverage: { status: "complete", unreadable_subcaches: [] },
});
expect(cache.subcaches).toEqual([
expect.objectContaining({
suffix: ".01",
status: "present",
vm_offset: "0x10000000",
}),
]);
expect(cache.images.map(({ path: image }) => image)).toEqual(
IMAGES.map(({ path: image }) => image),
);
const [swift, missing] = cache.inspected_images;
expect(swift).toMatchObject({ status: "parsed", file: ".01" });
expect(swift?.slices[0]?.dependencies).toEqual([
expect.objectContaining({
install_name: "/usr/lib/libSystem.B.dylib",
encoding: "dylib_use_command",
upward: true,
delayed_init: true,
}),
]);
expect(missing).toEqual({
path: "/usr/lib/missing.dylib",
status: "absent",
address: null,
file: null,
reason: null,
slices: [],
});
});
it("reports missing and mismatched subcaches as partial coverage", async () => {
const mismatched = await writeCache(
dyldCacheFixture(IMAGES, { subcacheUuidSeed: 9 }),
);
const result = await inspectDyldSharedCache({
cache_path: mismatched,
images: ["/usr/lib/swift/libswiftCore.dylib"],
});
expect(result.ok && result.value).toMatchObject({
subcaches: [{ suffix: ".01", status: "uuid-mismatch" }],
inspected_images: [{ status: "unmapped" }],
coverage: { status: "partial", unreadable_subcaches: [".01"] },
});
const missing = await writeCache(dyldCacheFixture(IMAGES));
await rm(`${missing}.01`);
const without = await inspectDyldSharedCache({ cache_path: missing });
expect(without.ok && without.value.subcaches[0]).toMatchObject({
status: "missing",
observed_uuid: null,
});
});
it("finds a development cache's subcaches beside its base name", async () => {
const path = await writeCache(
dyldCacheFixture(IMAGES),
"dyld_shared_cache_arm64e.development",
);
const result = await inspectDyldSharedCache({
cache_path: path,
images: ["/usr/lib/swift/libswiftCore.dylib"],
});
expect(result.ok && result.value).toMatchObject({
subcaches: [{ suffix: ".01", status: "present" }],
inspected_images: [{ status: "parsed", file: ".01" }],
});
});
it("reads legacy single-file headers without later fields", async () => {
const path = await writeCache(
dyldCacheFixture(IMAGES.slice(0, 1), { legacy: true }),
);
const result = await inspectDyldSharedCache({
cache_path: path,
images: ["/usr/lib/libSystem.B.dylib"],
});
expect(result.ok && result.value).toMatchObject({
platform: null,
os_version: null,
subcaches: [],
images_total: 1,
inspected_images: [{ status: "parsed" }],
});
});
it("separates malformed input, missing files and non-cache files", async () => {
expect(await inspectDyldSharedCache({ images: [] })).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
const directory = await createTestTempDirectory("rea-dyld-cache-bad-");
expect(
await inspectDyldSharedCache({ cache_path: join(directory, "absent") }),
).toMatchObject({ ok: false, error: { reason: "path" } });
const notCache = join(directory, "not-a-cache");
await writeFile(notCache, Buffer.alloc(64, 0x41));
expect(
await inspectDyldSharedCache({ cache_path: notCache }),
).toMatchObject({
ok: false,
error: { reason: "format" },
});
});
it("wraps the observation as Evidence about the main cache file", async () => {
const path = await writeCache(dyldCacheFixture(IMAGES));
const evidence = await inspectDyldSharedCacheEvidence({ cache_path: path });
expect(evidence.ok && evidence.value).toMatchObject({
operation: "inspect_dyld_shared_cache",
subject: { local_path: path, format: "file" },
confidence: "observed",
});
});
});
it("retains a relative caller-selected cache path in trace and inspection metadata", async () => {
const path = await writeCache(dyldCacheFixture(IMAGES));
const selected = relative(process.cwd(), path);
const targetPath = join(dirname(path), "tool");
const bytes = machoImage({
fileType: FILE_TYPE.execute,
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
],
});
await writeFile(targetPath, bytes);
const trace = await traceDylibResolution({
rootPath: dirname(path),
targetPath,
targetSha256: createHash("sha256").update(bytes).digest("hex"),
enumerateRoots: false,
parameters: { shared_cache: selected },
});
expect(trace.shared_cache?.path).toBe(selected);
expect(trace.shared_cache?.main_file_sha256).toMatch(/^[a-f0-9]{64}$/u);
expect(trace.edges[0]?.resolution.status).toBe("shared-cache");
const inspected = await inspectDyldSharedCacheEvidence({
cache_path: selected,
});
if (!inspected.ok) throw inspected.error;
expect(inspected.value.parameters).toMatchObject({ cache_path: selected });
});
it("discovers companions beside a symlink target while preserving selected metadata", async () => {
const path = await writeCache(
dyldCacheFixture(IMAGES),
"dyld_shared_cache_arm64e.development",
);
const aliases = await createTestTempDirectory("rea-cache-alias-");
const alias = join(aliases, "selected-cache");
await symlink(path, alias);
const result = await inspectDyldSharedCache({
cache_path: alias,
images: ["/usr/lib/swift/libswiftCore.dylib"],
});
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
cache_path: alias,
subcaches: [{ status: "present", suffix: ".01" }],
inspected_images: [{ status: "parsed", file: ".01" }],
coverage: { status: "complete", unreadable_subcaches: [] },
});
});
it.each([
[2, 7],
[3, 8],
[4, 9],
[11, 12],
])(
"decodes simulator cache family %i into platform %i and retains raw metadata",
async (raw, normalized) => {
const fixture = dyldCacheFixture([
{
path: "/usr/lib/libSystem.B.dylib",
bytes: dylib("/usr/lib/libSystem.B.dylib", [], normalized),
},
]);
const main = Buffer.from(fixture.main);
main.writeUInt32LE(raw, 0xd8);
main.writeUInt32LE(1 << 9, 0xdc);
const path = await writeCache({ ...fixture, main });
const result = await inspectDyldSharedCache({ cache_path: path });
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
platform: { id: normalized },
header_platform: { id: raw },
simulator: true,
});
const targetPath = join(dirname(path), "simulator-tool");
const bytes = machoImage({
fileType: FILE_TYPE.execute,
commands: [
buildVersionCommand(normalized),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
],
});
await writeFile(targetPath, bytes);
const trace = await traceDylibResolution({
rootPath: dirname(path),
targetPath,
targetSha256: createHash("sha256").update(bytes).digest("hex"),
enumerateRoots: false,
parameters: { shared_cache: path },
});
expect(trace.edges[0]?.resolution.status).toBe("shared-cache");
},
);
@@ -0,0 +1,80 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, expect, it } from "vitest";
import { z } from "zod";
import { dyldCacheFixture } from "../../../src/artifacts/apple/DyldSharedCache.fixture.js";
import {
FILE_TYPE,
LC,
dylibCommand,
machoImage,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
import { dyldSharedCacheResultSchema } from "../../../src/domain/apple/dyldSharedCache.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const resources: Array<{ close(): Promise<unknown> }> = [];
afterEach(async () => {
for (const resource of resources.splice(0)) await resource.close();
});
it("inspects a dyld shared cache through MCP without a target", async () => {
const session = createTestBinarySession(() => {
throw new Error("shared cache inspection must not launch a provider");
});
const server = createServer(session, session);
const client = new Client({ name: "dyld-shared-cache-test", version: "1" });
resources.push(client, server, session);
const [a, b] = InMemoryTransport.createLinkedPair();
await server.connect(b);
await client.connect(a);
const directory = await createTestTempDirectory("rea-dyld-cache-mcp-");
const path = join(directory, "dyld_shared_cache_arm64e");
const fixture = dyldCacheFixture([
{
path: "/usr/lib/libSystem.B.dylib",
bytes: machoImage({
fileType: FILE_TYPE.dylib,
commands: [dylibCommand(LC.ID_DYLIB, "/usr/lib/libSystem.B.dylib")],
}),
},
]);
const main = Buffer.from(fixture.main);
main.writeBigUInt64LE(2n, 0x68);
main.writeUInt32LE(1, 0x1c8);
await writeFile(path, main);
const tools = await client.listTools();
expect(tools.tools.map(({ name }) => name)).toContain(
"inspect_dyld_shared_cache",
);
const called = await client.callTool({
name: "inspect_dyld_shared_cache",
arguments: { cache_path: path, images: ["/usr/lib/libSystem.B.dylib"] },
});
expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe(
true,
);
const result = dyldSharedCacheResultSchema.parse(
z.object({ result: z.unknown() }).parse(called.structuredContent).result,
);
expect(result).toMatchObject({
architecture: "arm64e",
cache_type: "multi-cache",
cache_subtype: { id: 1, name: "production" },
images_total: 1,
inspected_images: [
{ path: "/usr/lib/libSystem.B.dylib", status: "parsed" },
],
});
const missing = await client.callTool({
name: "inspect_dyld_shared_cache",
arguments: { cache_path: join(directory, "absent") },
});
expect(missing.isError).toBe(true);
});
+14 -3
View File
@@ -8,6 +8,7 @@ import { z } from "zod";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import {
FILE_TYPE,
buildVersionCommand,
LC,
dylibCommand,
machoImage,
@@ -55,6 +56,7 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe
executable,
machoImage({
commands: [
buildVersionCommand(1),
rpathCommand("@executable_path/../Frameworks"),
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
],
@@ -62,7 +64,10 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe
);
await writeFile(
join(app, "Contents/Frameworks/libcore.dylib"),
machoImage({ fileType: FILE_TYPE.dylib }),
machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
);
await withClient(async (client) => {
const opened = await client.callTool({
@@ -129,12 +134,18 @@ it("traces a standalone Mach-O file whose name ends in .app", async () => {
await writeFile(
join(directory, "Tool.app"),
machoImage({
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libcore.dylib")],
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libcore.dylib"),
],
}),
);
await writeFile(
join(directory, "libcore.dylib"),
machoImage({ fileType: FILE_TYPE.dylib }),
machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
);
await withClient(async (client) => {
const opened = await client.callTool({
@@ -1,140 +0,0 @@
import { expect, it } from "vitest";
import { signatureVerification } from "../../../../src/native/CodesignVerification.js";
import { NativeFixtureRunner } from "../../../fixtures/nativeCommands.js";
it.each(["permission denied", "I/O error", "EACCES"])(
"does not classify echoed pathname %s as an I/O failure",
async (text) => {
const path = `/Applications/${text}.app`;
const capture = await new NativeFixtureRunner().run("codesign", [
"--verify",
path,
]);
if (!capture.ok) throw capture.error;
const observed = signatureVerification(
{
...capture.value,
exitCode: 1,
stderr: `${path}: a sealed resource is missing or invalid\n`,
},
path,
false,
);
expect(observed.status).toBe("invalid");
expect(observed.diagnostics).toEqual([
`${path}: a sealed resource is missing or invalid`,
]);
},
);
it("keeps operational, unsigned, definite invalid and unrecognized failures distinct", async () => {
const path = "/Applications/Fixture.app";
const capture = await new NativeFixtureRunner().run("codesign", [
"--verify",
path,
]);
if (!capture.ok) throw capture.error;
const status = (reason: string, unsigned = false) =>
signatureVerification(
{ ...capture.value, exitCode: 1, stderr: `${path}: ${reason}\n` },
path,
unsigned,
).status;
expect(status("permission denied")).toBe("unknown");
expect(status("I/O error")).toBe("unknown");
expect(status("invalid signature")).toBe("invalid");
expect(status("main executable failed strict validation")).toBe("invalid");
expect(status("bundle format unrecognized, invalid, or unsuitable")).toBe(
"invalid",
);
expect(
status(
"resource fork, Finder information, or similar detritus not allowed",
),
).toBe("invalid");
expect(status("code object is not signed at all", true)).toBe("unsigned");
expect(status("code object is not signed at all")).toBe("invalid");
expect(status("unrecognized failure")).toBe("unknown");
});
it("excludes complete selected paths with embedded newlines before classifying reasons", async () => {
const path =
"/Applications/permission denied\ncode object is not signed at all\nFixture.app";
const capture = await new NativeFixtureRunner().run("codesign", [
"--verify",
path,
]);
if (!capture.ok) throw capture.error;
const result = signatureVerification(
{ ...capture.value, exitCode: 1, stderr: `${path}: invalid signature\n` },
path,
false,
);
expect(result.status).toBe("invalid");
expect(result.diagnostics.join("\n")).toContain(path);
});
it("preserves definitive invalidity when other components have operational failures", async () => {
const path = "/Applications/Fixture.app";
const capture = await new NativeFixtureRunner().run("codesign", [
"--verify",
path,
]);
if (!capture.ok) throw capture.error;
const observed = signatureVerification(
{
...capture.value,
exitCode: 1,
stderr: `${path}/Contents/Helpers/one: permission denied\n${path}/Contents/Helpers/two: invalid signature\n`,
},
path,
false,
);
expect(observed.status).toBe("invalid");
expect(observed.diagnostics).toHaveLength(2);
});
it("preserves unmatched prepared records and ignores unprefixed nested-name fragments as reasons", async () => {
const path = "/Applications/Fixture.app";
const capture = await new NativeFixtureRunner().run("codesign", [
"--verify",
path,
]);
if (!capture.ok) throw capture.error;
const stderr = `--prepared:${path}/Contents/Helpers/odd\ninvalid signature\n${path}: permission denied\n`;
const result = signatureVerification(
{ ...capture.value, exitCode: 1, stderr },
path,
false,
);
expect(result.status).toBe("unknown");
expect(result.prepared_nested_code).toEqual([`${path}/Contents/Helpers/odd`]);
expect(result.raw_stderr).toBe(stderr);
expect(result.diagnostics).toContain("invalid signature");
});
it("keeps failed verification unknown when nested progress can contain a selected-path diagnostic", async () => {
const path = "/Applications/Fixture.app";
const capture = await new NativeFixtureRunner().run("codesign", [
"--verify",
path,
]);
if (!capture.ok) throw capture.error;
const stderr = `--prepared:${path}/Contents/Helpers/odd\n${path}: invalid signature\n${path}: permission denied\n`;
const result = signatureVerification(
{ ...capture.value, exitCode: 1, stderr },
path,
false,
"ambiguous",
);
expect(result.status).toBe("unknown");
expect(result.raw_stderr).toBe(stderr);
expect(
signatureVerification(
{ ...capture.value, exitCode: 0, stderr },
path,
false,
"ambiguous",
).status,
).toBe("valid");
});
@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -19,14 +18,9 @@ import { parseCodeSignature } from "../../../../src/native/parsers/codesign.js";
import {
NativeFixtureRunner as FixtureRunner,
nativeFixture as fixture,
nativeMachoTarget as unboundMachoTarget,
nativeMachoTarget as machoTarget,
} from "../../../fixtures/nativeCommands.js";
const machoTarget = (path: string, app?: string) => ({
...unboundMachoTarget(path, app),
sha256: createHash("sha256").update("fixture").digest("hex"),
});
let directory: string | undefined;
afterEach(async () => {
if (directory !== undefined)
@@ -324,13 +318,10 @@ class PrototypeEntitlementsRunner extends FixtureRunner {
describe("native signature entitlements", () => {
it("reports entitlement entries keyed __proto__ that the result omits", async () => {
directory = await createTestTempDirectory("rea-signature-entitlements-");
const executable = join(directory, "fixture");
await writeFile(executable, "fixture");
const client = new NativeMacOSProvider(
new PrototypeEntitlementsRunner(),
"darwin",
).createClient(machoTarget(executable));
).createClient(machoTarget("/private/fixture"));
const signature = await client.execute("inspect_signature", {});
@@ -1,985 +0,0 @@
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { createHash } from "node:crypto";
import { chmod, lstat, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { stapledTicket } from "../../../../src/native/NativeSignaturePosture.js";
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
import { ok } from "../../../../src/domain/result.js";
import { inspectSignatureSchema } from "../../../../src/domain/native/nativeInspection.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { notarizationTicketFixture } from "../../../fixtures/notarizationTicket.js";
import {
NativeFixtureRunner as FixtureRunner,
nativeMachoTarget as unboundMachoTarget,
} from "../../../fixtures/nativeCommands.js";
const machoTarget = (path: string, app?: string) => ({
...unboundMachoTarget(path, app),
sha256: createHash("sha256").update("fixture").digest("hex"),
});
const TAMPERED_STDERR =
"--prepared:/Applications/Fixture.app/Contents/XPCServices/B.xpc\n--validated:/Applications/Fixture.app/Contents/XPCServices/B.xpc\n--validated:/Applications/Fixture.app/Contents/PlugIns/A.appex\n/Applications/Fixture.app: a sealed resource is missing or invalid\nfile modified: /Applications/Fixture.app/Contents/Resources/en.lproj/Main.nib\n";
/** Fail only strict verification, as a tampered bundle does. */
class TamperedRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || arguments_[0] !== "--verify") return result;
const stderr = TAMPERED_STDERR.replaceAll(
"/Applications/Fixture.app",
arguments_.at(-1) ?? "",
);
return ok({
...result.value,
stdout: "",
stderr,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(stderr),
exitCode: 1,
});
}
}
const fixtureApp = async (ticket: string | Uint8Array | undefined) => {
const directory = await createTestTempDirectory("rea-signature-posture-");
const app = join(directory, "Fixture.app");
const executable = join(app, "Contents/MacOS/Fixture");
await mkdir(join(app, "Contents/MacOS"), { recursive: true });
await writeFile(executable, "fixture");
if (ticket !== undefined)
await writeFile(join(app, "Contents/CodeResources"), ticket);
return { app, executable };
};
describe("native signature posture", () => {
it("verifies the opened bundle and reports a stapled ticket", async () => {
const ticket = notarizationTicketFixture();
const { app, executable } = await fixtureApp(ticket);
const signature = await new NativeMacOSProvider(
new FixtureRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
expect(signature.ok).toBe(true);
if (!signature.ok) return;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.code_directory).toMatchObject({
version: "20500",
flags: { value: 0x10000, names: ["runtime"] },
code_slots: 10,
special_slots: 7,
});
expect(result.verification).toMatchObject({
path: app,
status: "valid",
exit_code: 0,
});
expect(result.stapled_ticket).toEqual({
status: "present",
path: "Contents/CodeResources",
sha256: createHash("sha256").update(ticket).digest("hex"),
size: ticket.length,
reason: null,
});
expect(
Object.fromEntries(
result.security_facets.map(({ facet, state }) => [facet, state]),
),
).toEqual({
"library-validation": "enforced",
"dyld-environment-variables": "ignored",
"debugger-attach": "blocked",
"executable-memory": "restricted",
"app-sandbox": "sandboxed",
});
expect(result.provenance.map(({ command }) => command[1])).toContain(
"--verify",
);
});
it("keeps a failed strict verification as an observation", async () => {
const { app, executable } = await fixtureApp(undefined);
await mkdir(join(app, "Contents/PlugIns/A.appex"), { recursive: true });
await mkdir(join(app, "Contents/XPCServices/B.xpc"), { recursive: true });
const signature = await new NativeMacOSProvider(
new TamperedRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
expect(signature.ok).toBe(true);
if (!signature.ok) return;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.verification).toMatchObject({
path: app,
status: "invalid",
exit_code: 1,
diagnostics: [
`${app}: a sealed resource is missing or invalid`,
`file modified: ${app}/Contents/Resources/en.lproj/Main.nib`,
],
validated_nested_code: [
`${app}/Contents/PlugIns/A.appex`,
`${app}/Contents/XPCServices/B.xpc`,
],
});
expect(result.stapled_ticket.status).toBe("absent");
});
it("does not look for a stapled ticket beside a bare Mach-O", async () => {
const directory = await createTestTempDirectory("rea-signature-bare-");
const executable = join(directory, "tool");
await writeFile(executable, "fixture");
const signature = await new NativeMacOSProvider(
new FixtureRunner(),
"darwin",
)
.createClient(machoTarget(executable))
.execute("inspect_signature", {});
expect(signature.ok && signature.value.result).toMatchObject({
verification: { path: executable },
stapled_ticket: { status: "not-applicable" },
});
});
it.skipIf(process.getuid?.() === 0)(
"keeps inspecting when the stapled ticket cannot be read",
async () => {
const { app, executable } = await fixtureApp("ticket");
const ticket = join(app, "Contents/CodeResources");
await chmod(ticket, 0o000);
try {
const signature = await new NativeMacOSProvider(
new FixtureRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
expect(signature.ok && signature.value.result).toMatchObject({
stapled_ticket: {
status: "unreadable",
path: "Contents/CodeResources",
sha256: null,
reason: "EACCES",
},
verification: { status: "valid" },
});
} finally {
await chmod(ticket, 0o644);
}
},
);
it("reports unsigned nested code inside a signed bundle as invalid", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new UnsignedNestedRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
expect(signature.ok && signature.value.result).toMatchObject({
signed: true,
verification: { status: "invalid", exit_code: 1 },
});
});
});
describe("native signature posture boundaries", () => {
it("verifies the inner bundle for iOS-on-Mac wrappers", async () => {
const directory = await createTestTempDirectory("rea-signature-wrapper-");
const outer = join(directory, "WrapperApp.app");
const inner = join(outer, "Wrapper", "Inner.app");
const executable = join(inner, "Contents/MacOS/Tool");
await mkdir(join(inner, "Contents/MacOS"), { recursive: true });
await writeFile(executable, "fixture");
const target = {
...machoTarget(executable),
sourcePath: outer,
bundleInfoPlist: join(inner, "Contents/Info.plist"),
};
const signature = await new NativeMacOSProvider(
new FixtureRunner(),
"darwin",
)
.createClient(target)
.execute("inspect_signature", {});
expect(signature.ok && signature.value.result).toMatchObject({
verification: { path: inner },
});
});
it("treats a regular file named .app as a bare Mach-O", async () => {
const directory = await createTestTempDirectory("rea-signature-file-");
const executable = join(directory, "Tool.app");
await writeFile(executable, "fixture");
const signature = await new NativeMacOSProvider(
new FixtureRunner(),
"darwin",
)
.createClient({ ...machoTarget(executable), sourcePath: executable })
.execute("inspect_signature", {});
expect(signature.ok && signature.value.result).toMatchObject({
verification: { path: executable },
stapled_ticket: { status: "not-applicable" },
});
});
it("applies platform policy only after the signature satisfies anchor apple", async () => {
const { app, executable } = await fixtureApp(undefined);
const facets = async (anchorExit: number) => {
const signature = await new NativeMacOSProvider(
new PlatformRunner(anchorExit),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.provenance.map(({ command }) => command.at(-2))).toContain(
"-R=anchor apple",
);
if (anchorExit === 0)
expect(
result.limitations.some((line) =>
line.startsWith("Apple-origin check failed"),
),
).toBe(false);
else
expect(result.limitations).toContain(
"Apple-origin check failed: code failed to satisfy specified code requirement(s)",
);
return Object.fromEntries(
result.security_facets.map(({ facet, state }) => [facet, state]),
);
};
expect(await facets(0)).toMatchObject({
"library-validation": "enforced",
"dyld-environment-variables": "ignored",
"debugger-attach": "blocked",
});
expect(await facets(3)).toMatchObject({
"library-validation": "unknown",
"dyld-environment-variables": "unknown",
"debugger-attach": "unknown",
});
});
it("flags reported nested code that does not exist as a split path", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new NewlinePathRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
// The line projection splits the name; the fragment is flagged.
expect(result.verification?.validated_nested_code).toEqual([
`${app}/Contents/Helpers/odd`,
]);
expect(result.limitations).toContainEqual(
expect.stringContaining(
`validated nested code at ${JSON.stringify(`${app}/Contents/Helpers/odd`)}, which does not exist`,
),
);
});
it("reports cancellation while hashing the stapled ticket", async () => {
const { app, executable } = await fixtureApp("ticket");
const controller = new AbortController();
const signature = await new NativeMacOSProvider(
new AbortAfterVerifyRunner(controller),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {}, { signal: controller.signal });
expect(signature.ok).toBe(false);
if (signature.ok) return;
expect(signature.error).toBeInstanceOf(AnalysisCancelledError);
});
});
describe("native signature posture slice-aware cases", () => {
it("keeps main-executable facets when only nested code fails", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new NestedOnlyFailureRunner(app),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.verification?.status).toBe("invalid");
expect(
result.security_facets.find(({ facet }) => facet === "library-validation")
?.state,
).toBe("enforced");
});
it("probes every slice of a universal binary before declaring it signed", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new UniversalMixedRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
// Aggregate display calls report the signed native slice; the unsigned
// x86_64 slice is found only per-architecture.
expect(result.verification?.status).toBe("valid");
expect(
result.security_facets.find(
({ facet }) => facet === "library-validation",
),
).toMatchObject({
state: "unknown",
evidence: ["architecture slices differ in signing state"],
});
expect(result.limitations).toContainEqual(
expect.stringContaining("Unsigned Mach-O slices: x86_64."),
);
});
it("preserves a trailing carriage return in reported nested paths", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new CarriageReturnPathRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
// The CR is a valid pathname byte, not a line delimiter: it is kept and
// the nonexistent path is flagged rather than silently normalized.
expect(result.verification?.validated_nested_code).toEqual([
`${app}/Contents/Helpers/cr\r`,
]);
expect(result.limitations).toContainEqual(
expect.stringContaining("which does not exist"),
);
});
it("marks facets unknown when signed slices differ in posture", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new UniversalPostureDiffRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
// Every slice verifies, but their CodeDirectory flags disagree.
expect(result.verification?.status).toBe("valid");
expect(
result.security_facets.find(
({ facet }) => facet === "library-validation",
),
).toMatchObject({
state: "unknown",
evidence: ["architecture slices differ in signing state"],
});
expect(result.limitations).toContainEqual(
expect.stringContaining("differ in CodeDirectory flags or entitlements"),
);
});
it("reports verification I/O failures as unknown, not invalid", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new PermissionDeniedRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.verification?.status).toBe("unknown");
expect(result.limitations).toContainEqual(
expect.stringContaining("not a proven broken signature"),
);
});
});
describe("signature architecture coverage", () => {
it("does not let uncertain display probes hide definitive verification failure", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new DefiniteInvalidWithUncertainSlicesRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.signed).toBe(true);
expect(result.verification?.status).toBe("invalid");
expect(result.limitations).toContainEqual(
expect.stringContaining("could not be classified"),
);
});
it("does not turn inconclusive slice probes into an unsigned claim", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new InconclusiveSlicesRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.signed).toBe(true);
expect(result.verification?.status).toBe("unknown");
expect(
result.security_facets.every(({ state }) => state === "unknown"),
).toBe(true);
expect(result.limitations).toContainEqual(
expect.stringContaining("could not be classified"),
);
});
it("does not report arbitrary ticket-location bytes as a stapled ticket", async () => {
const bytes = "stapled ticket bytes";
const { app, executable } = await fixtureApp(bytes);
const signature = await new NativeMacOSProvider(
new FixtureRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.stapled_ticket).toMatchObject({
status: "unreadable",
sha256: createHash("sha256").update(bytes).digest("hex"),
size: bytes.length,
reason: "Not a recognized s8ch notarization ticket container",
});
});
it("probes a valid x86_64h subtype rather than dropping it", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new UniversalMixedRunner("x86_64h"),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.limitations).toContain("Unsigned Mach-O slices: x86_64h.");
expect(
result.security_facets.every(({ state }) => state === "unknown"),
).toBe(true);
});
it("retains per-slice entitlement captures used to compare posture", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new UniversalPostureDiffRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
const commands = result.provenance.filter(
({ command }) =>
command.includes("-a") && command.includes("--entitlements"),
);
expect(commands).toHaveLength(2);
expect(result.entitlements).toEqual({
"com.apple.security.app-sandbox": true,
});
});
});
describe("signature target version binding", () => {
it("rejects a replacement before invoking signature tools", async () => {
const { app, executable } = await fixtureApp(undefined);
const runner = new (class extends FixtureRunner {
calls = 0;
override async run(tool: string, args: readonly string[]) {
this.calls += 1;
return super.run(tool, args);
}
})();
const client = new NativeMacOSProvider(runner, "darwin").createClient(
machoTarget(executable, app),
);
await writeFile(executable, "replacement bytes");
const signature = await client.execute("inspect_signature", {});
expect(signature.ok).toBe(false);
if (signature.ok) throw new Error("Expected target change");
expect(projectAnalysisError(signature.error)).toMatchObject({
code: "artifact_changed",
details: { path: executable },
});
expect(runner.calls).toBe(0);
});
it.skipIf(process.getuid?.() === 0)(
"rejects an unreadable selected executable",
async () => {
const { app, executable } = await fixtureApp(undefined);
const client = new NativeMacOSProvider(
new FixtureRunner(),
"darwin",
).createClient(machoTarget(executable, app));
await chmod(executable, 0);
try {
const signature = await client.execute("inspect_signature", {});
expect(signature.ok).toBe(false);
if (signature.ok) throw new Error("Expected access denial");
expect(projectAnalysisError(signature.error)).toMatchObject({
code: "access_denied",
details: { path: executable },
});
} finally {
await chmod(executable, 0o600);
}
},
);
it("reports a regular ticket replaced by a directory as changed", async () => {
const { app } = await fixtureApp(notarizationTicketFixture());
const ticket = await stapledTicket(
{ sourcePath: app, bundleInfoPlist: join(app, "Contents/Info.plist") },
undefined,
async (path, phase) => {
const metadata = await lstat(path);
if (phase === "before") {
await rm(path);
await mkdir(path);
}
return metadata;
},
);
expect(ticket).toMatchObject({
status: "unreadable",
path: "Contents/CodeResources",
reason: "changed",
sha256: null,
});
});
it.each(["EACCES", "EPERM", "EIO"])(
"preserves final ticket-path failure %s instead of inventing mutation",
async (code) => {
const { app } = await fixtureApp(notarizationTicketFixture());
const ticket = await stapledTicket(
{ sourcePath: app, bundleInfoPlist: join(app, "Contents/Info.plist") },
undefined,
async (path, phase) => {
if (phase === "after")
throw Object.assign(new Error("host lookup failed"), { code });
return lstat(path);
},
);
expect(ticket).toMatchObject({
status: "unreadable",
reason: code,
sha256: null,
});
},
);
it("rejects observations when the executable changes between captures", async () => {
const { app, executable } = await fixtureApp(undefined);
const signature = await new NativeMacOSProvider(
new ReplacingExecutableRunner(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
expect(signature.ok).toBe(false);
if (signature.ok) throw new Error("Expected target change");
expect(projectAnalysisError(signature.error)).toMatchObject({
code: "artifact_changed",
details: { path: executable },
});
});
it.skipIf(process.getuid?.() === 0)(
"reports denied nested-path completeness checks",
async () => {
const { app, executable } = await fixtureApp(undefined);
const helpers = join(app, "Contents/Helpers");
await mkdir(helpers);
await chmod(helpers, 0);
try {
const signature = await new NativeMacOSProvider(
new (class extends FixtureRunner {
override async run(tool: string, args: readonly string[]) {
const result = await super.run(tool, args);
if (!result.ok || args[0] !== "--verify") return result;
const stderr = `--prepared:${helpers}/odd\n${app}: invalid signature\n${app}: permission denied\n`;
return ok({
...result.value,
exitCode: 1,
stderr,
stderrBytes: Buffer.byteLength(stderr),
});
}
})(),
"darwin",
)
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!signature.ok) throw signature.error;
const result = inspectSignatureSchema.parse(signature.value.result);
expect(result.verification?.status).toBe("unknown");
expect(result.verification?.raw_stderr).toContain(
`${app}: invalid signature`,
);
expect(result.limitations).toContainEqual(
expect.stringContaining("Could not confirm completeness"),
);
expect(result.limitations).toContainEqual(
expect.stringContaining("permission denied (EACCES)"),
);
} finally {
await chmod(helpers, 0o755);
}
},
);
});
it("preserves failed display diagnostics through the caller-facing error contract", async () => {
const { app, executable } = await fixtureApp(undefined);
const diagnostic = `${executable}: permission denied while reading signature\n`;
const runner = new (class extends FixtureRunner {
override async run(tool: string, args: readonly string[]) {
const result = await super.run(tool, args);
return result.ok && args.includes("--verbose=4")
? ok({
...result.value,
exitCode: 1,
stderr: diagnostic,
stderrBytes: Buffer.byteLength(diagnostic),
})
: result;
}
})();
const result = await new NativeMacOSProvider(runner, "darwin")
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected display failure");
expect(projectAnalysisError(result.error)).toMatchObject({
details: {
diagnostics: {
path: executable,
exit_code: 1,
raw_stderr: diagnostic,
arguments: ["-d", "--verbose=4", executable],
},
},
});
});
it("uses filesystem-confirmed newline ambiguity to qualify verification without losing raw evidence", async () => {
const { app, executable } = await fixtureApp(undefined);
const helpers = join(app, "Contents/Helpers");
await mkdir(helpers);
// A slash cannot be part of a POSIX basename. An absolute-looking continuation
// is represented by nested directories following the newline in the prefix.
const injected = join(helpers, `odd\n${app}: invalid signature`);
await mkdir(injected, { recursive: true });
const runner = new (class extends FixtureRunner {
override async run(tool: string, args: readonly string[]) {
const result = await super.run(tool, args);
if (!result.ok || args[0] !== "--verify") return result;
const stderr = `--prepared:${helpers}/odd\n${app}: invalid signature\n${app}: permission denied\n`;
return ok({
...result.value,
exitCode: 1,
stderr,
stderrBytes: Buffer.byteLength(stderr),
});
}
})();
const observed = await new NativeMacOSProvider(runner, "darwin")
.createClient(machoTarget(executable, app))
.execute("inspect_signature", {});
if (!observed.ok) throw observed.error;
const result = inspectSignatureSchema.parse(observed.value.result);
expect(result.verification?.status).toBe("unknown");
expect(result.verification?.raw_stderr).toContain(
`${app}: invalid signature`,
);
expect(result.limitations).toContainEqual(
expect.stringContaining("structured nested-code records are ambiguous"),
);
});
/** Mutate only the fixture after its initial display has been observed. */
class ReplacingExecutableRunner extends FixtureRunner {
#changed = false;
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (
!this.#changed &&
tool === "codesign" &&
arguments_.includes("--verbose=4")
) {
const path = arguments_.at(-1);
if (path !== undefined)
await writeFile(path, "replacement executable bytes");
this.#changed = true;
}
return result;
}
}
/** A signed bundle whose nested helper is unsigned. */
class UnsignedNestedRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || arguments_[0] !== "--verify") return result;
const stderr =
"/Applications/Fixture.app/Contents/Helpers/tool: code object is not signed at all\nIn subcomponent: /Applications/Fixture.app/Contents/Helpers/tool\n";
return ok({
...result.value,
stdout: "",
stderr,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(stderr),
exitCode: 1,
});
}
}
/** A platform binary whose `anchor apple` requirement check exits as given. */
class PlatformRunner extends FixtureRunner {
constructor(private readonly anchorExit: number) {
super();
}
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || tool !== "codesign") return result;
if (arguments_.includes("-R=anchor apple")) {
const target = arguments_.at(-1) ?? "";
const stderr =
this.anchorExit === 0
? ""
: `${target}: code failed to satisfy specified code requirement(s)\n`;
return ok({
...result.value,
exitCode: this.anchorExit,
stderr,
stderrBytes: Buffer.byteLength(stderr),
});
}
if (!arguments_.includes("--verbose=4")) return result;
const stderr = `${result.value.stderr}Platform identifier=26\n`;
return ok({
...result.value,
stderr,
stderrBytes: Buffer.byteLength(stderr),
});
}
}
/** A nested helper whose file name contains a newline. */
class NewlinePathRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || arguments_[0] !== "--verify") return result;
const bundle = arguments_.at(-1) ?? "";
const stderr = `--validated:${bundle}/Contents/Helpers/odd\nname.app\n${bundle}: valid on disk\n`;
return ok({
...result.value,
stdout: "",
stderr,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(stderr),
});
}
}
/** Bundle verification fails on nested code while the main executable passes. */
class NestedOnlyFailureRunner extends FixtureRunner {
constructor(private readonly bundle: string) {
super();
}
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || arguments_[0] !== "--verify") return result;
if (arguments_.at(-1) !== this.bundle) return result;
const stderr = `${this.bundle}: a sealed resource is missing or invalid\nfile modified: ${this.bundle}/Contents/XPCServices/Svc.xpc\n`;
return ok({
...result.value,
stdout: "",
stderr,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(stderr),
exitCode: 1,
});
}
}
/**
* A universal binary whose aggregate display calls report the signed native
* slice while the x86_64 slice is unsigned.
*/
class UniversalMixedRunner extends FixtureRunner {
constructor(private readonly foreignArchitecture = "x86_64") {
super();
}
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || tool !== "codesign") return result;
const architectureIndex = arguments_.indexOf("-a");
if (architectureIndex >= 0) {
if (arguments_[architectureIndex + 1] !== this.foreignArchitecture)
return result;
const stderr = `${arguments_.at(-1)}: code object is not signed at all\n`;
return ok({
...result.value,
stdout: "",
stderr,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(stderr),
exitCode: 1,
});
}
if (!arguments_.includes("--verbose=4")) return result;
const stderr = result.value.stderr.replace(
/^Format=.*$/mu,
`Format=Mach-O universal (${this.foreignArchitecture} arm64)`,
);
return ok({
...result.value,
stderr,
stderrBytes: Buffer.byteLength(stderr),
});
}
}
/** Aggregate display succeeds, but per-architecture probes and verification fail operationally. */
class InconclusiveSlicesRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || tool !== "codesign") return result;
if (!arguments_.includes("-a") && arguments_[0] !== "--verify")
return result;
const stderr = "I/O error reading signing data\n";
return ok({
...result.value,
stdout: "",
stderr,
exitCode: 1,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(stderr),
});
}
}
/** Slice display uncertainty and an independently established invalid signature. */
class DefiniteInvalidWithUncertainSlicesRunner extends InconclusiveSlicesRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || arguments_[0] !== "--verify") return result;
const stderr = `${arguments_.at(-1)}: invalid signature\n`;
return ok({
...result.value,
stderr,
stderrBytes: Buffer.byteLength(stderr),
});
}
}
/** A nested helper whose file name ends in a carriage return. */
class CarriageReturnPathRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || arguments_[0] !== "--verify") return result;
const bundle = arguments_.at(-1) ?? "";
const stderr = `--validated:${bundle}/Contents/Helpers/cr\r\n${bundle}: valid on disk\n`;
return ok({
...result.value,
stdout: "",
stderr,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(stderr),
});
}
}
/**
* A universal binary whose slices all verify but carry different
* CodeDirectory flags (hardened arm64, plain x86_64).
*/
class UniversalPostureDiffRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || tool !== "codesign") return result;
if (arguments_.includes("--entitlements")) return result;
const architectureIndex = arguments_.indexOf("-a");
const architecture =
architectureIndex < 0 ? null : arguments_[architectureIndex + 1];
let stderr = result.value.stderr;
if (architecture === null)
stderr = stderr.replace(
/^Format=.*$/mu,
"Format=Mach-O universal (x86_64 arm64)",
);
else if (architecture === "x86_64")
stderr = stderr.replace(
/flags=0x[0-9a-f]+\([^)]*\)/u,
"flags=0x2(adhoc)",
);
return ok({
...result.value,
stdout: "",
stderr,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(stderr),
});
}
}
/** Verification fails because nested code cannot be read. */
class PermissionDeniedRunner extends FixtureRunner {
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (!result.ok || arguments_[0] !== "--verify") return result;
const stderr = `${arguments_.at(-1)}: permission denied while reading nested code\n`;
return ok({
...result.value,
stdout: "",
stderr,
stdoutBytes: 0,
stderrBytes: Buffer.byteLength(stderr),
exitCode: 1,
});
}
}
/** Cancel the request once local verification has finished. */
class AbortAfterVerifyRunner extends FixtureRunner {
constructor(private readonly controller: AbortController) {
super();
}
override async run(tool: string, arguments_: readonly string[]) {
const result = await super.run(tool, arguments_);
if (arguments_[0] === "--verify") this.controller.abort();
return result;
}
}
+2 -9
View File
@@ -1,5 +1,4 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { BinaryTarget } from "../../src/domain/binaryTarget.js";
import { ok } from "../../src/domain/result.js";
import type {
@@ -96,16 +95,10 @@ const capture = (
/** Build the Mach-O target identity used by native boundary fixtures. */
export const nativeMachoTarget = (
path: string,
/** The app bundle directory the target was opened from. */
bundle?: string,
sourcePath?: string,
): BinaryTarget => ({
path,
...(bundle === undefined
? {}
: {
sourcePath: bundle,
bundleInfoPlist: join(bundle, "Contents/Info.plist"),
}),
...(sourcePath === undefined ? {} : { sourcePath }),
sha256: "0".repeat(64),
kind: "executable",
format: "mach-o",
-19
View File
@@ -1,19 +0,0 @@
/** Synthetic local ticket framing; it does not contain an authenticated certificate/signature. */
export const notarizationTicketFixture = (): Uint8Array => {
const certificates = Buffer.from([0x30, 2, 0x30, 0]);
const content = Buffer.alloc(45);
content.write("g8tk", 0, "ascii");
content.writeUInt16LE(2, 4);
content.writeUInt16LE(20, 6);
content.writeUInt32LE(1, 8);
content[24] = 2;
content.fill(0xab, 25);
const signature = Buffer.alloc(72);
signature.set([0x30, 6, 2, 1, 1, 2, 1, 1]);
const header = Buffer.alloc(16);
header.write("s8ch", 0, "ascii");
header.writeUInt32LE(1, 4);
header.writeUInt32LE(certificates.length, 8);
header.writeUInt32LE(content.length, 12);
return Buffer.concat([header, certificates, content, signature]);
};