chore: sync main after artifact ownership audit

This commit is contained in:
morluto committed 2026-10-13 12:56:07 +08:00
commit d867136cec
69 files changed
+4082 -28

No files matched your search

@@ -83,6 +83,12 @@ explicit path or endpoint and do not need it.
frames; use `match_offset` / `match_limit` for later pages, or explicit null
limit for complete matching output. Preserve the source digest across pages.
This does not perform live capture.
For address/DNS/SNI/protocol summaries use `inspect_pcap_capture` with MCP
detail summary, then retained kind pcap pages. Requires Wireshark 4.x tshark
and capinfos, with Linux prlimit or Windows Job Objects; see
[packet summaries](https://github.com/morluto/rea/blob/main/docs/pcap-analysis.md).
Select an exact DNS question for address pages; page exhaustion does not prove
network completeness. This reads recordings and does not acquire traffic.
- Recorded logic/signal traces: `decode_signal_recording` with explicit decoder,
channel and protocol settings. On Linux with util-linux prlimit, supply sigrok-cli 0.7.2 through
`REA_SIGROK_COMMAND`; see [recording guide](https://github.com/morluto/rea/blob/main/docs/signal-recordings.md).
@@ -7,6 +7,13 @@ on:
- "tests/boundary/cli/captureCleanup.test.ts"
- "tests/boundary/mcp/packetCaptureMcp.test.ts"
- "src/captures/**"
- "src/pcap/**"
- "src/domain/pcap/**"
- "src/domain/analysisView/pcapCaptureView.ts"
- "src/application/pcap/**"
- "src/contracts/pcap/**"
- "src/server/registerPcapTools.ts"
- "tests/boundary/pcap/**"
- "src/domain/captures/**"
- "src/application/captures/**"
- "src/contracts/captures/**"
@@ -48,3 +55,4 @@ jobs:
- run: node scripts/verify-packet-capture.mjs
env:
REA_TSHARK_COMMAND: /usr/bin/tshark
REA_CAPINFOS_COMMAND: /usr/bin/capinfos
+1
View File
@@ -170,6 +170,7 @@ Additional tools and host support depend on the target:
| Flutter apps | Dart snapshot hash, per-ABI library digests and engine build provenance | None (static APK parsing); [Flutter guide](docs/flutter-analysis.md) |
| Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) |
| Recorded packets | Selected fields, repeated values, filters and decode-as rules | Linux: TShark 4.x + prlimit; [capture guide](docs/packet-captures.md) |
| Packet summaries | Endpoints, DNS, TLS SNI, protocol distribution and HTTP requests | Caller-supplied tshark and capinfos (Linux-verified); [pcap guide](docs/pcap-analysis.md) |
| Recorded logic signals | Decoder annotations, sample ranges and diagnostics | Linux: sigrok-cli 0.7.2 + prlimit; [signal recordings](docs/signal-recordings.md) |
| Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) |
| Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) |
+1
View File
@@ -139,6 +139,7 @@ npx rea-agents@latest setup
| JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](https://rea.tools/ar/guides/javascript/) |
| المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](https://rea.tools/ar/guides/browser/) |
| تسجيلات الشبكة المحفوظة | الطلبات، والاستجابات، ومحتويات الحمولة المتاحة، ومواضعها في المصدر | HAR؛ وmitmdump على Linux لتسجيلات mitmproxy بصيغتها الأصلية؛ [دليل تسجيلات الشبكة](docs/web-network-captures.md) |
| التقاط الحزم | نقاط النهاية وDNS وTLS SNI وتوزيع البروتوكولات وطلبات HTTP | tshark وcapinfos يوفّرهما المُستدعي؛ [دليل pcap](docs/pcap-analysis.md) |
| تجميعات .NET | البيانات الوصفية، وتعليمات CIL، واعتماديات الشيفرة الأصلية المعلنة، ومقارنات البناء | فحص ثابت؛ [دليل الشيفرة المُدارة](docs/managed-code-analysis.md) |
| حزم Android APK | تصريحات ملف manifest، والأصناف، والدوال الناتجة عن فك الترجمة، والمراجع | JADX دون واجهة رسومية وJDK كامل على Linux/macOS/Windows x64؛ [دليل Android](docs/android-analysis.md) |
| أجهزة Android | الأجهزة والحزم والعمليات والسجلات والدامبات ولقطات الشاشة ونقل APK/الملفات | adb يوفّره المُستدعي (محاكي أو جهاز)؛ [دليل ADB](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz
| JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](https://rea.tools/de/guides/javascript/) |
| Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](https://rea.tools/de/guides/browser/) |
| Gespeicherte Netzwerkaufzeichnungen | Anfragen, Antworten, zugängliche Nutzdaten und Quellpositionen | HAR; mitmdump unter Linux für Aufzeichnungen im nativen mitmproxy-Format; [Leitfaden zu Netzwerkaufzeichnungen](docs/web-network-captures.md) |
| Paketerfassungen | Endpunkte, DNS, TLS SNI, Protokollverteilung und HTTP-Anfragen | Vom Aufrufer bereitgestellte tshark- und capinfos-Binaries; [pcap-Leitfaden](docs/pcap-analysis.md) |
| .NET-Assemblies | Metadaten, CIL-Anweisungen, deklarierte native Abhängigkeiten und Build-Vergleiche | Statische Untersuchung; [Leitfaden für verwalteten Code](docs/managed-code-analysis.md) |
| Android-APKs | Manifest-Deklarationen, Klassen, dekompilierte Methoden und Verweise | JADX ohne grafische Oberfläche und ein vollständiges JDK unter Linux/macOS/Windows x64; [Android-Leitfaden](docs/android-analysis.md) |
| Android-Geräte | Geräte, Pakete, Prozesse, Logs, Dumps, Bildschirmaufnahmen sowie APK- und Dateiübertragungen | Vom Aufrufer bereitgestelltes adb (Emulator oder Gerät); [ADB-Leitfaden](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h
| JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](https://rea.tools/es/guides/javascript/) |
| Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](https://rea.tools/es/guides/browser/) |
| Capturas de red guardadas | Solicitudes, respuestas, contenido accesible de las cargas útiles y ubicaciones de origen | HAR; mitmdump en Linux para capturas en formato nativo de mitmproxy; [guía de capturas](docs/web-network-captures.md) |
| Capturas de paquetes | Endpoints, DNS, TLS SNI, distribución de protocolos y solicitudes HTTP | tshark y capinfos suministrados por el autor de la llamada; [guía pcap](docs/pcap-analysis.md) |
| Ensamblados .NET | Metadatos, instrucciones CIL, dependencias nativas declaradas y comparaciones de compilaciones | Inspección estática; [guía de código administrado](docs/managed-code-analysis.md) |
| APK de Android | Declaraciones del manifiesto, clases, métodos descompilados y referencias | JADX sin interfaz gráfica y un JDK completo en Linux/macOS/Windows x64; [guía de Android](docs/android-analysis.md) |
| Dispositivos Android | Dispositivos, paquetes, procesos, registros, volcados, capturas de pantalla y transferencias de APK/archivos | adb suministrado por el autor de la llamada (emulador o dispositivo); [guía de ADB](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np
| برنامه‌های فلاتر | هش اسنپ‌شات دارت، هش کتابخانه‌ها به تفکیک ABI و اصالت بیلد موتور | هیچ (تحلیل ایستا APK)؛ [راهنمای فلاتر](docs/flutter-analysis.md) |
| Firmware | ناحیه‌ها، نتایج استخراج و تحویل به ابزارهای تحلیل بومی | Binwalk / Unblob در لینوکس؛ [راهنمای Firmware](docs/firmware-analysis.md) |
| بسته‌های ضبط‌شده | فیلدهای منتخب، مقادیر تکراری، فیلترها و قواعد رمزگشایی | Linux: TShark 4.x + prlimit; [راهنمای ضبط بسته](docs/packet-captures.md) |
| ضبط بسته‌ها | نقاط پایانی، DNS، TLS SNI، توزیع پروتکل و درخواست‌های HTTP | tshark و capinfos تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای pcap](docs/pcap-analysis.md) |
| سیگنال‌های منطقی ضبط‌شده | توضیحات رمزگشا، بازه‌های نمونه و اطلاعات عیب‌یابی | Linux: sigrok-cli 0.7.2 + prlimit; [راهنمای سیگنال‌ها](docs/signal-recordings.md) |
| بسته‌ها و منابع | فهرست فایل‌ها، digestها، فایل‌های plist، ساختار bundle اپل و منابع استخراج‌شده | [راهنمای آرتیفکت و JavaScript](docs/javascript-artifact-reconstruction.md)، [برنامه‌های اپل](docs/apple-application-analysis.md) |
| رفتار فرایند | خروجی ترمینال، تعاملات، وضعیت خروج، مشاهدات سیستم فایل و مقایسهٔ اجراها | Linux/macOS با PTY بومی؛ [ضبط فرایند](docs/process-capture.md) |
+1
View File
@@ -143,6 +143,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les
| JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](https://rea.tools/fr/guides/javascript/) |
| Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](https://rea.tools/fr/guides/browser/) |
| Captures réseau enregistrées | Requêtes, réponses, payloads exposés et emplacements dans le code source | HAR ; mitmdump sous Linux pour les captures mitmproxy natives ; [guide des captures](docs/web-network-captures.md) |
| Captures de paquets | Points de terminaison, DNS, TLS SNI, distribution des protocoles et requêtes HTTP | tshark et capinfos fournis par l'appelant ; [guide pcap](docs/pcap-analysis.md) |
| Assemblys .NET | Métadonnées, instructions CIL, dépendances natives déclarées et comparaisons de builds | Inspection statique ; [guide du code managé](docs/managed-code-analysis.md) |
| APK Android | Déclarations du manifeste, classes, méthodes décompilées et références | JADX headless et un JDK complet sous Linux/macOS/Windows x64 ; [guide Android](docs/android-analysis.md) |
| Appareils Android | Appareils, paquets, processus, journaux, dumps, captures d'écran et transferts d'APK/fichiers | adb fourni par l'appelant (émulateur ou appareil) ; [guide ADB](docs/adb-device-analysis.md) |
+1
View File
@@ -146,6 +146,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/id/guides/javascript/) |
| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/id/guides/browser/) |
| Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](docs/web-network-captures.md) |
| Capturan paket | Endpoint, DNS, TLS SNI, distribusi protokol, dan permintaan HTTP | tshark dan capinfos buatan pemanggil; [panduan pcap](docs/pcap-analysis.md) |
| Assembly .NET | Metadata, instruksi CIL, dependensi native yang dideklarasikan, dan perbandingan build | Tidak ada (hanya analisis statis); [panduan kode terkelola](docs/managed-code-analysis.md) |
| APK Android | Deklarasi manifest, kelas, metode hasil dekompilasi, dan referensi | JADX mode headless dan JDK lengkap di Linux/macOS/Windows x64; [panduan Android](docs/android-analysis.md) |
| Perangkat Android | Perangkat, paket, proses, log, dump, tangkapan layar, serta transfer APK/berkas | adb buatan pemanggil (emulator atau perangkat); [panduan ADB](docs/adb-device-analysis.md) |
+1
View File
@@ -143,6 +143,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument
| JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](https://rea.tools/it/guides/javascript/) |
| Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](https://rea.tools/it/guides/browser/) |
| Acquisizioni di rete salvate | Richieste, risposte, payload esposti e posizioni sorgente | HAR; mitmdump su Linux per acquisizioni mitmproxy native; [guida alle acquisizioni](docs/web-network-captures.md) |
| Captured di pacchetti | Endpoint, DNS, TLS SNI, distribuzione dei protocolli e richieste HTTP | tshark e capinfos forniti dal chiamante; [guida pcap](docs/pcap-analysis.md) |
| Assembly .NET | Metadati, istruzioni CIL, dipendenze native dichiarate e confronti di build | Nessuno (solo statico); [guida managed-code](docs/managed-code-analysis.md) |
| APK Android | Dichiarazioni manifest, classi, metodi decompilati e riferimenti | JADX headless e un JDK completo su Linux/macOS/Windows x64; [guida Android](docs/android-analysis.md) |
| Dispositivi Android | Dispositivi, pacchetti, processi, log, dump, screenshot e trasferimenti APK/file | adb fornito dal chiamante (emulatore o hardware); [guida ADB](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm
| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/ja/guides/javascript/) |
| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/ja/guides/browser/) |
| 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](docs/web-network-captures.md) |
| パケットキャプチャ | エンドポイント、DNS、TLS SNI、プロトコル分布、HTTPリクエスト | 呼び出し側が用意した tshark と capinfos;[pcapガイド](docs/pcap-analysis.md) |
| .NET アセンブリ | メタデータ、CIL 命令、宣言されたネイティブ依存関係、ビルドの比較 | 静的な検査;[マネージドコードのガイド](docs/managed-code-analysis.md) |
| Android APK | マニフェストの宣言、クラス、逆コンパイルされたメソッド、参照 | Linux/macOS/Windows x64 上のヘッドレス JADX と完全な JDK;[Android ガイド](docs/android-analysis.md) |
| Android デバイス | デバイス、パッケージ、プロセス、ログ、ダンプ、スクリーンキャプチャ、APK/ファイル転送 | 呼び出し側が用意した adb(エミュレーターまたは実機);[ADB ガイド](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) |
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) |
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](docs/web-network-captures.md) |
| 패킷 캡처 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos; [pcap 가이드](docs/pcap-analysis.md) |
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](docs/managed-code-analysis.md) |
| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](docs/android-analysis.md) |
| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na
| JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](https://rea.tools/pl/guides/javascript/) |
| Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](https://rea.tools/pl/guides/browser/) |
| Zapisane przechwycenia ruchu sieciowego | Żądania, odpowiedzi, dostępne dane przesyłane i lokalizacje źródłowe | HAR; mitmdump w systemie Linux dla natywnych przechwyceń mitmproxy; [przewodnik przechwytywania](docs/web-network-captures.md) |
| Przechwytywanie pakietów | Punkty końcowe, DNS, TLS SNI, dystrybucja protokołów i żądania HTTP | Dostarczone przez wywołującego tshark i capinfos; [przewodnik pcap](docs/pcap-analysis.md) |
| Zestawy .NET | Metadane, instrukcje CIL, zadeklarowane zależności natywne i porównania kompilacji | Analiza statyczna; [przewodnik kodu zarządzanego](docs/managed-code-analysis.md) |
| Pakiety APK dla Androida | Deklaracje manifestu, klasy, zdekompilowane metody i odwołania | JADX bez interfejsu graficznego i pełny JDK w systemach Linux/macOS/Windows x64; [przewodnik Androida](docs/android-analysis.md) |
| Urządzenia z Androidem | Urządzenia, pakiety, procesy, logi, zrzuty, zrzuty ekranu oraz transfery APK/plików | adb dostarczony przez wywołującego (emulator lub urządzenie); [przewodnik ADB](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer
| JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](https://rea.tools/pt-BR/guides/javascript/) |
| Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](https://rea.tools/pt-BR/guides/browser/) |
| Capturas de rede salvas | Requisições, respostas, payloads acessíveis e localizações de origem | HAR; mitmdump no Linux para capturas no formato nativo do mitmproxy; [guia de capturas](docs/web-network-captures.md) |
| Capturas de pacotes | Endpoints, DNS, TLS SNI, distribuição de protocolos e requisições HTTP | tshark e capinfos fornecidos pelo chamador; [guia pcap](docs/pcap-analysis.md) |
| Assemblies .NET | Metadados, instruções CIL, dependências nativas declaradas e comparações de builds | Inspeção estática; [guia de código gerenciado](docs/managed-code-analysis.md) |
| APKs Android | Declarações do manifesto, classes, métodos descompilados e referências | JADX sem interface gráfica e um JDK completo no Linux/macOS/Windows x64; [guia de Android](docs/android-analysis.md) |
| Dispositivos Android | Dispositivos, pacotes, processos, logs, despejos, capturas de tela e transferências de APK/arquivos | adb fornecido pelo chamador (emulador ou aparelho); [guia de ADB](docs/adb-device-analysis.md) |
+1
View File
@@ -147,6 +147,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
| Приложения Flutter | Хеш снапшота Dart, дайджесты библиотек по ABI и происхождение сборки движка | Не требуется (статический разбор APK); [руководство по Flutter](docs/flutter-analysis.md) |
| Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](docs/firmware-analysis.md) |
| Записанные пакеты | Выбранные поля, повторяющиеся значения, фильтры и правила декодирования | Linux: TShark 4.x + prlimit; [руководство по захватам](docs/packet-captures.md) |
| Захват пакетов | Конечные точки, DNS, TLS SNI, распределение протоколов и HTTP-запросы | Предоставленные вызывающей стороной tshark и capinfos; [руководство по pcap](docs/pcap-analysis.md) |
| Записанные логические сигналы | Аннотации декодера, диапазоны отсчётов и диагностика | Linux: sigrok-cli 0.7.2 + prlimit; [руководство по сигналам](docs/signal-recordings.md) |
| Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](docs/javascript-artifact-reconstruction.md), [приложения Apple](docs/apple-application-analysis.md) |
| Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](docs/process-capture.md) |
+1
View File
@@ -139,6 +139,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
| JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](https://rea.tools/th/guides/javascript/) |
| เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](https://rea.tools/th/guides/browser/) |
| ข้อมูลเครือข่ายที่บันทึกไว้ | คำขอ คำตอบ เพย์โหลดที่ปรากฏ และตำแหน่งต้นทาง | HAR; mitmdump บน Linux สำหรับข้อมูลที่บันทึกในรูปแบบเนทีฟของ mitmproxy; [คู่มือการบันทึกข้อมูล](docs/web-network-captures.md) |
| การจับแพ็กเก็ต | จุดเชื่อมต่อ, DNS, TLS SNI, การกระจายโปรโตคอล และคำขอ HTTP | tshark และ capinfos ที่ผู้เรียกจัดเตรียม; [คู่มือ pcap](docs/pcap-analysis.md) |
| แอสเซมบลี .NET | เมทาดาทา คำสั่ง CIL การพึ่งพาเนทีฟที่ประกาศไว้ และการเปรียบเทียบผลการบิลด์ | การตรวจสอบแบบสถิต; [คู่มือโค้ดแบบจัดการ](docs/managed-code-analysis.md) |
| APK ของ Android | การประกาศใน manifest คลาส เมธอดที่ดีคอมไพล์ และการอ้างอิง | JADX แบบไม่มี GUI และ JDK แบบเต็มบน Linux/macOS/Windows x64; [คู่มือ Android](docs/android-analysis.md) |
| อุปกรณ์ Android | อุปกรณ์ แพ็กเกจ โพรเซส บันทึก ดัมพ์ ภาพหน้าจอ และการถ่ายโอน APK/ไฟล์ | adb ที่ผู้เรียกจัดเตรียม (โปรแกรมจำลองหรืออุปกรณ์จริง); [คู่มือ ADB](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
| JavaScript / Electron | Modüller, içe aktarmalar, kaynak haritaları, rotalar, IPC ve yerel eklenti ilişkileri | Node.js ve npm; [uygulama analizi](https://rea.tools/tr/guides/javascript/) |
| Web siteleri | Sayfa yapısı, betikler, ağ gözlemleri ve istenen ekran görüntüleri | Chrome ailesinden bir tarayıcı; [tarayıcı analizi](https://rea.tools/tr/guides/browser/) |
| Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR; mitmproxy'nin kendi biçimindeki yakalamalar için Linux üzerinde mitmdump; [ağ yakalama rehberi](docs/web-network-captures.md) |
| Paket yakalamaları | Uç noktalar, DNS, TLS SNI, protokol dağılımı ve HTTP istekleri | Çağıranın sağladığı tshark ve capinfos; [pcap rehberi](docs/pcap-analysis.md) |
| .NET derlemeleri | Meta veriler, CIL talimatları, bildirilmiş yerel bağımlılıklar ve derleme karşılaştırmaları | Statik inceleme; [yönetilen kod rehberi](docs/managed-code-analysis.md) |
| Android APK'leri | Manifest bildirimleri, sınıflar, tersine derlenmiş metotlar ve referanslar | Linux/macOS/Windows x64 üzerinde arayüzsüz JADX ve tam bir JDK; [Android rehberi](docs/android-analysis.md) |
| Android cihazları | Cihazlar, paketler, süreçler, günlükler, dökümler, ekran görüntüleri ve APK/dosya aktarımları | Çağıranın sağladığı adb (emülatör veya cihaz); [ADB rehberi](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
| JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](https://rea.tools/uk/guides/javascript/) |
| Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](https://rea.tools/uk/guides/browser/) |
| Збережені мережеві захоплення | Запити, відповіді, доступні корисні дані та місця в джерелі | HAR; mitmdump у Linux для захоплень у власному форматі mitmproxy; [посібник мережевих захоплень](docs/web-network-captures.md) |
| Захоплення пакетів | Кінцеві точки, DNS, TLS SNI, розподіл протоколів та HTTP-запити | Надані викликаючою стороною tshark та capinfos; [підручник pcap](docs/pcap-analysis.md) |
| Збірки .NET | Метадані, інструкції CIL, оголошені нативні залежності й порівняння збірок | Статична перевірка; [посібник керованого коду](docs/managed-code-analysis.md) |
| Android APK | Оголошення маніфесту, класи, декомпільовані методи й посилання | JADX без графічного інтерфейсу й повний JDK у Linux/macOS/Windows x64; [посібник Android](docs/android-analysis.md) |
| Пристрої Android | Пристрої, пакети, процеси, журнали, дампи, знімки екрана та передавання APK/файлів | adb, наданий викликаючою стороною (емулятор або пристрій); [підручник ADB](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np
| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](https://rea.tools/vi/guides/javascript/) |
| Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](https://rea.tools/vi/guides/browser/) |
| Bản ghi lưu lượng mạng đã lưu | Yêu cầu, phản hồi, payload có thể truy cập và vị trí nguồn | HAR; mitmdump trên Linux cho bản ghi định dạng gốc của mitmproxy; [hướng dẫn bản ghi mạng](docs/web-network-captures.md) |
| Bắt gói tin | Điểm cuối, DNS, TLS SNI, phân bố giao thức và yêu cầu HTTP | tshark và capinfos do nơi gọi cung cấp; [hướng dẫn pcap](docs/pcap-analysis.md) |
| Assembly .NET | Siêu dữ liệu, lệnh CIL, các phụ thuộc mã máy được khai báo và so sánh bản dựng | Kiểm tra tĩnh; [hướng dẫn mã được quản lý](docs/managed-code-analysis.md) |
| APK Android | Khai báo manifest, lớp, phương thức được dịch ngược và tham chiếu | JADX không giao diện đồ họa và JDK đầy đủ trên Linux/macOS/Windows x64; [hướng dẫn Android](docs/android-analysis.md) |
| Thiết bị Android | Thiết bị, gói, tiến trình, nhật ký, dump, ảnh chụp màn hình và truyền APK/tệp | adb do nơi gọi cung cấp (trình giả lập hoặc thiết bị); [hướng dẫn ADB](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](https://rea.tools/zh-TW/guides/javascript/) |
| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](https://rea.tools/zh-TW/guides/browser/) |
| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](docs/web-network-captures.md) |
| 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](docs/pcap-analysis.md) |
| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](docs/managed-code-analysis.md) |
| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) |
| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](docs/adb-device-analysis.md) |
+1
View File
@@ -139,6 +139,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/zh-CN/guides/javascript/) |
| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](https://rea.tools/zh-CN/guides/browser/) |
| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](docs/web-network-captures.md) |
| 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](docs/pcap-analysis.md) |
| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](docs/managed-code-analysis.md) |
| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) |
| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](docs/adb-device-analysis.md) |
+118
View File
@@ -0,0 +1,118 @@
# Packet capture summaries
REA summarizes already saved pcap/pcapng files through caller-selected Wireshark
4.x `tshark` and `capinfos`. Use `inspect_packet_capture` for selected fields,
filters and decode-as rules; use this family for address, DNS, SNI, protocol-stack
and URI inventories. It does not acquire traffic or fetch recorded targets.
## Supply tools
```sh
export REA_TSHARK_COMMAND=/absolute/path/tshark
export REA_CAPINFOS_COMMAND=/absolute/path/capinfos
# Linux only; default /usr/bin/prlimit
export REA_TSHARK_PRLIMIT_COMMAND=/absolute/path/prlimit
rea inspect-pcap-client
rea inspect-pcap-capture /captures/session.pcapng --json
```
Without explicit tool commands, this family resolves them through PATH and
records that selection source. Client inspection reads no capture file. Capture
inspection requires Linux with util-linux prlimit or Windows with owned Job
Objects. macOS capture inspection returns a capability gap because the new
native memory boundary is unverified; its version probes remain available.
Tools and installed libraries/plugins are caller-trusted. An empty private
personal Wireshark configuration is used; this is not an OS sandbox.
## Inspect only the needed facts
MCP `inspect_pcap_capture` accepts `path` and optional `detail: "summary"`.
Complete delivery remains the default. Summary commits and retains the same
complete Evidence, returning identity, capture metadata, observed counts,
retained counts, source coverage and diagnostic lengths without large lists.
Then use its `normalized_result.parent_evidence_id` with
`inspect_analysis_view` source kind `retained-evidence`:
```json
{"kind":"pcap","facet":"endpoints","offset":0,"limit":20}
{"kind":"pcap","facet":"dns-queries","offset":0,"limit":20}
{"kind":"pcap","facet":"dns-addresses","query":"example.com","offset":0,"limit":20}
{"kind":"pcap","facet":"tls-sni","offset":0,"limit":20}
{"kind":"pcap","facet":"diagnostics","stream":"tshark","offset":0,"limit":4096}
```
Other row facets are `protocols`, `http-uris` and `unattributed-addresses`.
Defaults are offset 0 and limit 64. DNS question pages give address counts;
select one exact retained question to page its addresses. Diagnostic windows
count UTF-16 code units; other windows count rows. Missing/ambiguous selectors
fail. Unknown legacy diagnostics remain unknown, not an observed empty stream.
Portable CLI views use the existing `inspect-analysis-view` workflow with inline
complete Evidence. Views never reread the source or launch Wireshark and work
after the original file is deleted. Page exhaustion covers the retained facet;
it does not establish network completeness or recover missing traffic.
## Interpret provenance and coverage
`target.sha256` binds observations to an admitted stable capture snapshot. Both
tools read that copy, which is hashed again after analysis. Capture metadata and
literal field values are producer observations; address/protocol counts and DNS
associations are derivations. Capture Evidence has historical-reference authority.
Addresses count once per packet, including loopback and nested IP occurrences.
DNS addresses are associated only with one question in the same response packet;
additional records may be included, so this does not prove final resolution.
Ambiguous addresses remain in a separate inventory. Protocol distribution counts
exact `frame.protocols` stack strings. TLS SNI is recorded handshake metadata,
not process identity or proof of the destination's authenticity.
All examined inventories are retained within the resource budget; no fixed
200/100/50-item truncation is applied. HTTP transport credentials are excluded
without rewriting other URI bytes; ambiguous credential spans cause omission.
Counts preserve distinct observed source URIs before omission or coalescing.
`coverage: "partial"` records unresolved DNS attribution or URI item loss;
component credential exclusion is also counted explicitly. Diagnostics retain
both tool stderr streams. Recorded fields do not establish decrypted payloads,
causality, process identity, absence of unrecorded traffic or current deployments.
## Resources and lifecycle
One inspection is admitted at a time per provider. Concurrent calls return a
capacity error; close drains active work before releasing resources. Captures
are streamed into a read-only owned snapshot with a 2 GiB disk budget. Native
capture commands enforce Linux address-space ceilings of at most 1 GiB,
300 CPU seconds, 64 MiB per file and no core dumps, preserving stricter inherited
limits. Windows uses a 1 GiB committed-memory limit for each owned tool tree.
These are not aggregate machine or filesystem quotas.
TShark output is bounded at 8 MiB; its known five-level selected-field shape and
128 MiB graph-expansion estimate are checked before allocation. Retained facts
have a separate 16 MiB estimate. Capinfos output is limited to 64 KiB. Command
wall deadlines are 300 s for dissection, 60 s for metadata and 20 s for probes.
Native ownership preparation has its own bounded lifecycle.
Malformed/oversized output returns no partial success. Unvalidated partial JSON
is not returned as facts. Cancellation reaches probes, acquisition, commands and
projection. Cleanup failure returns a typed error, retains completed Evidence
when available, and retains owned resources for retry on close.
## Verification scope
Current follow-up verification uses Linux/Node 24.18.0 and Wireshark 4.0.17.
Private real recorded corpora are checked against independent raw-frame/IP
parsing and Python aggregation, public CLI/stdio MCP parity, SDK schemas, source
hashes and retained views. Synthetic native tools separately test malformed
output, cancellation, source/snapshot mutation, capacity and cleanup; they do
not establish real Wireshark or other-platform behavior.
The original proposal reported Linux 4.6.6, macOS/Windows 4.6.9 verification on
its earlier implementation. Those reports do not verify the new final memory
boundary. Actual Windows/Bun Wireshark execution is unverified here; macOS
capture support needs a separate enforceable-bound decision.
```sh
npm run verify:pcap -- --pcap /absolute/saved-capture.pcapng
```
This verifier exercises built CLI/stdio MCP, schemas, artifact identity and
cleanup. Without `--pcap`, capture inspection is reported as unverified. Private
captures, keys and provider runtime files are not committed.
+8
View File
@@ -849,6 +849,14 @@ establish passing Android coverage; inspect its execution receipt.
The `termux-browser-diagnostics` artifact retains the Termux command log, exit
status, and Android logcat on success or failure. The emulator is disposable.
## Packet capture analysis
`npm run verify:pcap -- --pcap PATH` exercises caller-supplied tshark and
capinfos against a saved capture. Linux verification uses Wireshark 4.6.6
and captures containing DNS, TLS and HTTP traffic. Native Windows and macOS
engine behavior remains unverified; process fixtures do not establish it.
See [Packet capture analysis](pcap-analysis.md) for contracts and budgets.
## Apple native metadata and UI
`npm run verify:apple-dispatch` compiles the Objective-C fixture (classes,
+1
View File
@@ -167,6 +167,7 @@
"verify:legacy-simulator": "npm run build:cached && node scripts/verify/apple/legacy-simulator.mjs",
"verify:architecture-guards": "node scripts/verify/architecture-guards.mjs",
"verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs",
"verify:pcap": "npm run build:cached && node scripts/verify-pcap.mjs",
"verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs",
"verify:adb": "npm run build:cached && node scripts/verify-adb.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
+9
View File
@@ -52,6 +52,11 @@ export const toolFamilyCatalog = (sources) => {
surface: "android-provider",
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
},
{
id: "pcap",
surface: "pcap-provider",
contracts: sources.pcapContracts.PCAP_TOOL_CONTRACTS,
},
{
id: "flutter",
surface: "flutter-provider",
@@ -251,6 +256,10 @@ export const providerCatalog = (sources) => {
identity: sources.androidProvider.JADX_PROVIDER_IDENTITY,
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
},
{
identity: sources.pcapProvider.PCAP_PROVIDER_IDENTITY,
contracts: sources.pcapContracts.PCAP_TOOL_CONTRACTS,
},
{
identity: sources.flutterProvider.FLUTTER_PROVIDER_IDENTITY,
contracts: sources.flutterContracts.FLUTTER_TOOL_CONTRACTS,
+2 -2
View File
@@ -68,8 +68,8 @@ const SOURCE_PATHS = {
flutterProvider: "dist/flutter/FlutterBuildProvider.js",
androidProvider: "dist/android/JadxRelease.js",
apktoolContracts: "dist/contracts/apktool/apktoolToolContracts.js",
flutterContracts: "dist/contracts/flutter/flutterToolContracts.js",
flutterProvider: "dist/flutter/FlutterBuildProvider.js",
pcapContracts: "dist/contracts/pcap/pcapToolContracts.js",
pcapProvider: "dist/pcap/PcapProvider.js",
apktoolProvider: "dist/apktool/ApktoolProvider.js",
jebContracts: "dist/contracts/jeb/jebToolContracts.js",
jebProvider: "dist/jeb/JebProvider.js",
+46
View File
@@ -236,6 +236,52 @@ try {
const hci = await inspect({ path: bluetooth, fields: ["bthci_cmd.opcode"] });
assert.deepEqual(hci.packets[0]["bthci_cmd.opcode"], ["0x0c03"]);
assert.deepEqual(await readFile(path), source);
// Exercise the incremental summary workflow against the same owned bytes
// using real installed Wireshark tools; these inputs are format fixtures.
const pcapTool = (await client.listTools()).tools.find(
(t) => t.name === "inspect_pcap_capture",
);
assert.ok(pcapTool?.outputSchema);
const pcapAjv = new Ajv2020({ strict: false, validateFormats: false });
assert.ok(pcapAjv.validateSchema(pcapTool.inputSchema));
assert.ok(pcapAjv.validateSchema(pcapTool.outputSchema));
for (const capturePath of [path, bluetooth]) {
const response = await client.callTool({
name: pcapTool.name,
arguments: { path: capturePath, detail: "summary" },
});
assert.notEqual(response.isError, true, JSON.stringify(response));
assert.ok(
pcapAjv.validate(pcapTool.outputSchema, response.structuredContent),
pcapAjv.errorsText(),
);
const summary = parseEvidence(response.structuredContent).normalized_result;
assert.equal(
summary.summary.target.sha256,
createHash("sha256")
.update(await readFile(capturePath))
.digest("hex"),
);
const source = {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
};
const page = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source,
view: { kind: "pcap", facet: "protocols", limit: 10 },
},
});
assert.notEqual(page.isError, true, JSON.stringify(page));
assert.equal(
parseEvidence(page.structuredContent).normalized_result
.parent_evidence_id,
source.evidence_id,
);
cases++;
}
assert.deepEqual(await readFile(bluetooth), bluetoothSource);
for (const input of [
{ path, fields: ["frame.number"], display_filter: "this is not a filter" },
+131
View File
@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, readdir, rmdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { toolContract } from "../dist/contracts/toolContracts.js";
import { hashStableFile } from "../dist/filesystem/StableFileHash.js";
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
const args = process.argv.slice(2);
if (args.includes("--help") || args.includes("-h")) {
console.log(
"Usage: npm run verify:pcap -- [--pcap PATH]\nRequires caller-supplied tshark/capinfos. Without --pcap, only client readiness is verified.",
);
process.exit(0);
}
if (!(args.length === 0 || (args.length === 2 && args[0] === "--pcap")))
throw new Error("Expected --pcap PATH");
const path = args[1] === undefined ? null : resolve(args[1]);
// This parent is shared by independent CLI/MCP owners. A leased Windows
// runtime root would prevent their native admission of its directory handle.
const root = await mkdtemp(join(tmpdir(), "rea-pcap-verifier-"));
const environment = Object.fromEntries(
Object.entries(process.env).filter(([, value]) => typeof value === "string"),
);
Object.assign(environment, {
TMPDIR: root,
TMP: root,
TEMP: root,
});
const entrypoint = resolve("scripts/rea.mjs");
const execute = promisify(execFile);
const client = new Client({ name: "pcap-real-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: environment,
stderr: "pipe",
});
transport.stderr?.resume();
try {
await client.connect(transport);
const readiness = await run("inspect_pcap_client", {});
assert(readiness.client.tshark_version && readiness.client.capinfos_version);
if (path === null)
console.log("[unverified] Capture inspection: no --pcap was supplied");
else {
const identity = await hashStableFile(path);
const result = await run("inspect_pcap_capture", { path });
assert.deepEqual(result.target, { path, ...identity });
assert(result.packet_count >= 0);
assert(
result.endpoints.every(
(entry) => entry.packet_count <= result.packet_count,
),
);
assert(
result.protocol_distribution.every(
(entry) => entry.packet_count <= result.packet_count,
),
);
const partial =
result.endpoint_count > result.endpoints.length ||
result.dns_query_count > result.dns_queries.length ||
result.tls_sni_count > result.tls_sni_values.length ||
result.protocol_count > result.protocol_distribution.length ||
result.http_request_uri_count > result.http_request_uris.length ||
result.unattributed_dns_packet_count > 0 ||
result.dns_queries.some(
(entry) =>
entry.response_address_count > entry.response_addresses.length,
);
assert.equal(result.coverage, partial ? "partial" : "complete");
assert.deepEqual(
await hashStableFile(path),
identity,
"Input capture must remain unchanged",
);
console.log(
`[ok] ${result.packet_count} packets; ${result.endpoint_count} addresses; coverage=${result.coverage}`,
);
}
} finally {
try {
await client.close();
} finally {
await transport.close();
}
assert.deepEqual(
await readdir(root),
[],
`Owned capture resources remain under ${root}`,
);
await rmdir(root);
}
console.log("All asserted pcap CLI/MCP checks passed");
async function run(name, input) {
const contract = toolContract(name);
const { stdout } = await execute(
process.execPath,
[
entrypoint,
name.replaceAll("_", "-"),
...(input.path === undefined ? [] : [input.path]),
"--json",
],
{ env: environment, timeout: 360_000, maxBuffer: 8 * 1024 * 1024 },
);
const cli = contract.outputSchema.parse(JSON.parse(stdout));
const response = await client.callTool(
{ name, arguments: input },
{ timeout: 360_000 },
);
assert.notEqual(response.isError, true, mcpTextValue(response));
const mcp = contract.outputSchema.parse(JSON.parse(mcpTextValue(response)));
assert.deepEqual(mcp.normalized_result, cli.normalized_result);
assert.deepEqual(mcp.subject, cli.subject);
if (input.path !== undefined) {
assert.equal(cli.subject.local_path, input.path);
assert.equal(
cli.subject.digest.sha256,
cli.normalized_result.target.sha256,
);
}
console.log(`[ok] ${name}: named output schema and CLI/MCP Evidence parity`);
return cli.normalized_result;
}
+1 -1
View File
@@ -481,7 +481,7 @@ const workflowAvailabilityFor = ({
remediation: "Run process capture on a supported Linux or macOS host.",
};
if (kind === "application") return { reason: "available", remediation: null };
if (kind === "flutter-provider")
if (kind === "pcap-provider" || kind === "flutter-provider")
return { reason: "available", remediation: null };
const electron = electronProviderAvailability(name, kind, policy);
if (electron !== null) return electron;
+23
View File
@@ -0,0 +1,23 @@
import type {
AnalysisExecution,
ExecutionOptions,
ProviderAvailability,
} from "../AnalysisProvider.js";
import type { PcapRequest } from "../../domain/pcap/pcapAnalysis.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Result } from "../../domain/result.js";
/**
* Packet-capture analysis boundary over caller-supplied tshark/capinfos.
* Implementation details belong to the provider adapter.
*/
export interface PcapAnalysisPort {
/** Probe both tools without reading any capture file. */
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability>;
/** Retry cleanup of retained owned commands and capture snapshots. */
close(): Promise<void>;
execute(
request: PcapRequest,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>>;
}
@@ -0,0 +1,36 @@
import type { PcapAnalysisPort } from "./PcapAnalysisPort.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
pcapRequestSchema,
type PcapOperation,
} from "../../domain/pcap/pcapAnalysis.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import { createPcapEvidence } from "../../domain/pcap/pcapEvidence.js";
import { err, ok, type Result } from "../../domain/result.js";
/** Shared CLI/MCP admission, execution, and Evidence composition for pcap. */
export class PcapAnalysisService {
constructor(readonly provider: PcapAnalysisPort) {}
/** Run one caller-selected pcap request and retain capture identity inline. */
async execute(
operation: PcapOperation,
input: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
const request = pcapRequestSchema.safeParse({ operation, input });
if (!request.success)
return err(new AnalysisInputError(operation, { cause: request.error }));
const executed = await this.provider.execute(request.data, options);
if (!executed.ok) return executed;
const execution = executed.value;
return ok(createPcapEvidence(request.data, execution));
}
}
+2
View File
@@ -24,6 +24,7 @@ import { registerManagedCommands } from "./cli/managedCommands.js";
import { registerFirmwareCommands } from "./cli/firmwareCommands.js";
import { registerAndroidCommands } from "./cli/androidCommands.js";
import { registerApktoolCommands } from "./cli/apktoolCommands.js";
import { registerPcapCommands } from "./cli/pcapCommands.js";
import { registerFlutterCommands } from "./cli/flutterCommands.js";
import { registerJebCommands } from "./cli/jebCommands.js";
import { registerAdbCommands } from "./cli/adbCommands.js";
@@ -105,6 +106,7 @@ export const createCli = (
registerManagedCommands(cli, logger, analysis.runProviderAnalysis);
registerAndroidCommands(cli, logger, environment);
registerApktoolCommands(cli, logger, environment);
registerPcapCommands(cli, logger, environment);
registerFlutterCommands(cli, logger);
registerJebCommands(cli, logger, environment);
registerAdbCommands(cli, logger, environment);
+69
View File
@@ -0,0 +1,69 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
import { createPcapAnalysisProvider } from "../composition/pcap.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { PcapOperation } from "../domain/pcap/pcapAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
/** Expose the same explicit pcap requests through one-shot CLI commands. */
export const registerPcapCommands = (
cli: CliInstance,
logger: Logger,
environment: Readonly<Record<string, string | undefined>>,
provider: PcapAnalysisPort = createPcapAnalysisProvider(environment),
): void => {
const service = new PcapAnalysisService(provider);
const execute = (name: string, operation: PcapOperation, input: unknown) =>
withCommandCancellation((signal) =>
logCliCommand(logger, name, async () => {
const result = await service.execute(operation, input, { signal });
try {
await provider.close();
} catch (cause) {
if (cause instanceof AnalysisError) {
if (result.ok) cause.retainPartialObservation(result.value);
return projectAnalysisError(
result.ok
? cause
: analysisErrorWithCleanupFailure(
result.error,
cause,
operation,
),
);
}
throw cause;
}
return result.ok ? result.value : projectAnalysisError(result.error);
}),
);
cli.command(CLI_COMMANDS.inspectPcapClient, {
description:
"Inspect the caller-selected tshark and capinfos binaries without reading a capture",
args: z.object({}),
run: () =>
execute(CLI_COMMANDS.inspectPcapClient, "inspect_pcap_client", {}),
});
cli.command(CLI_COMMANDS.inspectPcapCapture, {
description:
"Inspect one pcap/pcapng capture: endpoints, DNS, TLS SNI, and protocol distribution",
args: z.object({
path: z
.string()
.min(1)
.describe("Local pcap or pcapng capture file to inspect"),
}),
run: ({ args }) =>
execute(CLI_COMMANDS.inspectPcapCapture, "inspect_pcap_capture", {
path: args.path,
}),
});
};
+2
View File
@@ -55,6 +55,8 @@ export const CLI_COMMANDS = Object.freeze({
inspectFirmwareRegions: "inspect-firmware-regions",
extractFirmware: "extract-firmware",
inspectAndroidPackage: "inspect-android-package",
inspectPcapClient: "inspect-pcap-client",
inspectPcapCapture: "inspect-pcap-capture",
identifyFlutterBuild: "identify-flutter-build",
inspectDartAot: "inspect-dart-aot",
inspectApktoolClient: "inspect-apktool-client",
+7
View File
@@ -0,0 +1,7 @@
import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
import { PcapProvider } from "../pcap/PcapProvider.js";
/** Construct one pcap provider bound to the caller-selected tools. */
export const createPcapAnalysisProvider = (
environment: Readonly<Record<string, string | undefined>> = process.env,
): PcapAnalysisPort => new PcapProvider({ environment });
+1 -1
View File
@@ -16,7 +16,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.',
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_evm_interface, inspect_managed_artifact, inspect_android_package, inspect_dart_aot or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. For layout, JavaScript, inventory, WASM, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose aLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+46
View File
@@ -0,0 +1,46 @@
import {
pcapInputSchemas,
pcapResultSchemas,
} from "../../domain/pcap/pcapAnalysis.js";
import type { ToolContract } from "../toolContractTypes.js";
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "../toolEffects.js";
import {
analysisDetailSchema,
analysisDetailOutputSchemaOf,
} from "../analysisDetail.js";
import { pcapCaptureSummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
/** Packet-capture analysis served by caller-supplied tshark and capinfos. */
export const PCAP_TOOL_CONTRACTS = [
{
name: "inspect_pcap_client",
...toolContractMetadata("inspect_pcap_client"),
description:
"Inspect the caller-selected tshark and capinfos binaries: selected commands, selection sources, and versions. Both tools must be usable. Reads no capture file. REA never installs the Wireshark suite; select binaries with REA_TSHARK_COMMAND and REA_CAPINFOS_COMMAND (absolute) or from PATH.",
kind: "pcap-provider",
inputSchema: pcapInputSchemas.inspect_pcap_client,
outputSchema: evidenceResultOf(pcapResultSchemas.inspect_pcap_client),
examples: [{ title: "Check the analysis tools", input: {} }],
},
{
name: "inspect_pcap_capture",
...toolContractMetadata("inspect_pcap_capture"),
description:
"Request detail summary for capture identity/counts, then page retained facts with inspect_analysis_view kind pcap. Default detail complete preserves inline delivery. Inspect one saved pcap or pcapng capture through an owned snapshot: metadata, exact packet count, SHA-256, IPv4/IPv6 address counts, DNS questions and address values from the same single-question response, TLS SNI, protocol distribution and HTTP URIs. All derived inventories are retained within declared resource budgets; unknown DNS attribution and excluded credentials remain explicit. Linux requires util-linux prlimit; Windows uses owned Job Objects. Requires caller-supplied capinfos and tshark; REA never captures traffic.",
kind: "pcap-provider",
inputSchema: pcapInputSchemas.inspect_pcap_capture.extend({
detail: analysisDetailSchema,
}),
outputSchema: analysisDetailOutputSchemaOf(
pcapResultSchemas.inspect_pcap_capture,
pcapCaptureSummaryViewResultSchema,
),
examples: [
{
title: "Inspect a capture file",
input: { path: "/captures/session.pcap" },
},
],
},
] as const satisfies readonly ToolContract[];
+1
View File
@@ -33,6 +33,7 @@ export const TOOL_KINDS = [
"browser-provider",
"electron-provider",
"runtime-provider",
"pcap-provider",
"application",
"session",
] as const;
+2
View File
@@ -12,6 +12,7 @@ import { MANAGED_TOOL_CONTRACTS } from "./managed/managedToolContracts.js";
import { FIRMWARE_TOOL_CONTRACTS } from "./firmware/firmwareToolContracts.js";
import { ANDROID_TOOL_CONTRACTS } from "./android/androidToolContracts.js";
import { APKTOOL_TOOL_CONTRACTS } from "./apktool/apktoolToolContracts.js";
import { PCAP_TOOL_CONTRACTS } from "./pcap/pcapToolContracts.js";
import { FLUTTER_TOOL_CONTRACTS } from "./flutter/flutterToolContracts.js";
import { JEB_TOOL_CONTRACTS } from "./jeb/jebToolContracts.js";
import { ADB_TOOL_CONTRACTS } from "./adb/adbToolContracts.js";
@@ -46,6 +47,7 @@ export const TOOL_CONTRACTS = [
...MANAGED_TOOL_CONTRACTS,
...ANDROID_TOOL_CONTRACTS,
...APKTOOL_TOOL_CONTRACTS,
...PCAP_TOOL_CONTRACTS,
...FLUTTER_TOOL_CONTRACTS,
...JEB_TOOL_CONTRACTS,
...ADB_TOOL_CONTRACTS,
+2
View File
@@ -44,6 +44,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
search_android_classes: "states it does not execute the APK",
inspect_apktool_client:
"inspects the operator-selected apktool launcher; never installs it",
inspect_pcap_client:
"probes the operator-selected tshark and capinfos; never installs them",
inspect_jeb_client: "talks to a caller-started JEB; never launches it",
open_jeb_project: "the engine analyzes the file; REA does not execute it",
inspect_adb_client:
+10
View File
@@ -182,6 +182,16 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
launchesProcess: true,
writesFilesystem: true,
}),
inspect_pcap_client: effects({
mutatesSession: true,
launchesProcess: true,
writesFilesystem: true,
}),
inspect_pcap_capture: effects({
mutatesSession: true,
launchesProcess: true,
writesFilesystem: true,
}),
identify_flutter_build: effects({
mutatesSession: true,
writesFilesystem: true,
+28
View File
@@ -62,6 +62,12 @@ import {
captureSummarySchema,
} from "./webNetworkCaptureView.js";
import type { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js";
import {
pcapViewRequestSchema,
pcapViewPageSchema,
pcapSummarySchema,
projectPcapCaptureView,
} from "./pcapCaptureView.js";
import {
qlogViewRequestSchema,
qlogViewPageSchema,
@@ -196,6 +202,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
apiSurfaceViewSchema,
captureViewRequestSchema,
qlogViewRequestSchema,
pcapViewRequestSchema,
dartAotViewRequestSchema,
]);
@@ -312,6 +319,7 @@ const viewResultBase = {
"inspect_managed_artifact",
"inspect_web_network_capture",
"inspect_android_package",
"inspect_pcap_capture",
"inspect_dart_aot",
]),
parent_digest: digestSchema,
@@ -384,6 +392,12 @@ export const androidPackageSummaryViewResultSchema = z.strictObject({
summary: androidPackageSummarySchema,
});
export const pcapCaptureSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("inspect_pcap_capture"),
summary: pcapSummarySchema,
});
/** Exact snapshot and pool-coverage carrier for selected AOT delivery. */
export const dartAotSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -415,6 +429,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
managedSummarySchema,
captureSummarySchema,
androidPackageSummarySchema,
pcapSummarySchema,
dartAotSummarySchema,
]),
}),
@@ -433,6 +448,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...viewResultBase,
parent_operation: z.literal("inspect_web_network_capture"),
}),
z.strictObject({
kind: z.literal("pcap"),
view: pcapViewRequestSchema,
page: pcapViewPageSchema,
source_coverage: z.enum(["complete", "partial"]),
...viewResultBase,
parent_operation: z.literal("inspect_pcap_capture"),
}),
z.strictObject({
kind: z.literal("facet"),
view: facetViewSchema,
@@ -562,6 +585,7 @@ export type UnsignedAnalysisView =
| Omit<Extract<AnalysisViewResult, { readonly kind: "wasm" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "evm" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "qlog" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "pcap" }>, "view_digest">
| Omit<
Extract<AnalysisViewResult, { readonly kind: "capture" }>,
"view_digest"
@@ -686,6 +710,10 @@ export const projectAnalysisView = (
const projected = projectWebNetworkCaptureView(parent, view);
return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
}
if (parent.operation === "inspect_pcap_capture") {
const projected = projectPcapCaptureView(parent, view);
return projected.ok ? ok(sealAnalysisView(projected.value)) : projected;
}
if (parent.operation === "inspect_binary_layout") {
const layout = binaryLayoutSchema.safeParse(parent.normalizedResult);
if (!layout.success)
+327
View File
@@ -0,0 +1,327 @@
import { z } from "zod";
import { pcapResultSchemas } from "../pcap/pcapAnalysis.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { err, ok, type Result } from "../result.js";
import {
analysisViewInputError,
type AnalysisViewParent,
type AnalysisViewRequest,
type UnsignedAnalysisView,
} from "./analysisView.js";
import {
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
const captureSchema = pcapResultSchemas.inspect_pcap_capture;
const fields = captureSchema.shape;
const pageShape = {
kind: z.literal("pcap"),
offset: z.number().int().nonnegative().default(0),
limit: z.number().int().positive().default(64),
};
export const pcapViewRequestSchema = z.discriminatedUnion("facet", [
z.strictObject({
...pageShape,
facet: z.enum([
"endpoints",
"dns-queries",
"tls-sni",
"protocols",
"http-uris",
"unattributed-addresses",
]),
}),
z.strictObject({
...pageShape,
facet: z.literal("dns-addresses"),
query: z.string().min(1),
}),
z.strictObject({
...pageShape,
facet: z.literal("diagnostics"),
stream: z.enum(["tshark", "capinfos"]),
}),
]);
export const pcapViewPageSchema = z.discriminatedUnion("facet", [
z.strictObject({ facet: z.literal("endpoints"), items: fields.endpoints }),
z.strictObject({
facet: z.literal("dns-queries"),
items: z.array(
fields.dns_queries.element.omit({ response_addresses: true }).extend({
retained_response_address_count: z.number().int().nonnegative(),
}),
),
}),
z.strictObject({ facet: z.literal("tls-sni"), items: fields.tls_sni_values }),
z.strictObject({
facet: z.literal("protocols"),
items: fields.protocol_distribution,
}),
z.strictObject({
facet: z.literal("http-uris"),
items: fields.http_request_uris,
}),
z.strictObject({
facet: z.literal("unattributed-addresses"),
items: fields.unattributed_dns_addresses,
}),
z.strictObject({
facet: z.literal("dns-addresses"),
items: fields.unattributed_dns_addresses,
}),
z.strictObject({
facet: z.literal("diagnostics"),
text: z.string(),
status: z.enum(["retained", "unknown"]),
}),
]);
export const pcapSummarySchema = captureSchema
.omit({
endpoints: true,
dns_queries: true,
tls_sni_values: true,
protocol_distribution: true,
http_request_uris: true,
unattributed_dns_addresses: true,
diagnostics: true,
})
.extend({
retained_counts: z.strictObject({
endpoints: z.number().int().nonnegative(),
dns_queries: z.number().int().nonnegative(),
tls_sni_values: z.number().int().nonnegative(),
protocol_distribution: z.number().int().nonnegative(),
http_request_uris: z.number().int().nonnegative(),
unattributed_dns_addresses: z.number().int().nonnegative(),
}),
diagnostics_code_units: z
.strictObject({
tshark: z.number().int().nonnegative(),
capinfos: z.number().int().nonnegative(),
})
.nullable(),
});
type Capture = z.output<typeof captureSchema>;
type PcapView = z.output<typeof pcapViewRequestSchema>;
type Page = z.output<typeof pcapViewPageSchema>;
const parseParent = (
parent: AnalysisViewParent,
): Result<Capture, AnalysisError> => {
const parsed = captureSchema.safeParse(parent.normalizedResult);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues,
parent.normalizedResult,
{ cause: parsed.error },
),
);
if (
parent.subject?.local_path !== parsed.data.target.path ||
parent.subject.digest.sha256 !== parsed.data.target.sha256
)
return err(
analysisViewInputError([
{
path: ["source", "subject"],
reason: "invalid_value",
message:
"Packet parent subject must identify the report's exact path and SHA-256.",
},
]),
);
return ok(parsed.data);
};
const summarize = ({
endpoints,
dns_queries,
tls_sni_values,
protocol_distribution,
http_request_uris,
unattributed_dns_addresses,
diagnostics,
...metadata
}: Capture) => ({
...metadata,
retained_counts: {
endpoints: endpoints.length,
dns_queries: dns_queries.length,
tls_sni_values: tls_sni_values.length,
protocol_distribution: protocol_distribution.length,
http_request_uris: http_request_uris.length,
unattributed_dns_addresses: unattributed_dns_addresses.length,
},
diagnostics_code_units:
diagnostics === undefined
? null
: {
tshark: diagnostics.tshark_stderr.length,
capinfos: diagnostics.capinfos_stderr.length,
},
});
const selectRows = (
capture: Capture,
view: Exclude<PcapView, { facet: "diagnostics" }>,
): Result<
{ page: Exclude<Page, { facet: "diagnostics" }>; total: number },
AnalysisError
> => {
const slice = <T>(items: readonly T[]) =>
items.slice(view.offset, view.offset + view.limit);
switch (view.facet) {
case "endpoints":
return ok({
page: { facet: view.facet, items: slice(capture.endpoints) },
total: capture.endpoints.length,
});
case "dns-queries":
return ok({
page: {
facet: view.facet,
items: slice(capture.dns_queries).map(
({ response_addresses, ...query }) => ({
...query,
retained_response_address_count: response_addresses.length,
}),
),
},
total: capture.dns_queries.length,
});
case "tls-sni":
return ok({
page: { facet: view.facet, items: slice(capture.tls_sni_values) },
total: capture.tls_sni_values.length,
});
case "protocols":
return ok({
page: {
facet: view.facet,
items: slice(capture.protocol_distribution),
},
total: capture.protocol_distribution.length,
});
case "http-uris":
return ok({
page: { facet: view.facet, items: slice(capture.http_request_uris) },
total: capture.http_request_uris.length,
});
case "unattributed-addresses":
return ok({
page: {
facet: view.facet,
items: slice(capture.unattributed_dns_addresses),
},
total: capture.unattributed_dns_addresses.length,
});
case "dns-addresses": {
const matches = capture.dns_queries.filter(
(query) => query.query === view.query,
);
if (matches.length !== 1)
return err(
analysisViewInputError([
{
path: ["view", "query"],
reason: "invalid_value",
message: "Select one unambiguous exact retained DNS question.",
},
]),
);
return ok({
page: {
facet: view.facet,
items: slice(matches[0]!.response_addresses),
},
total: matches[0]!.response_addresses.length,
});
}
}
};
/** Historical packet facts are paged without reopening the source or rerunning Wireshark. */
export const projectPcapCaptureView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (view.kind !== "summary" && view.kind !== "pcap")
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Packet summary parents support summary and pcap fact pages.",
},
]),
);
const parsed = parseParent(parent);
if (!parsed.ok) return parsed;
const capture = parsed.value;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "inspect_pcap_capture" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: { path: capture.target.path, sha256: capture.target.sha256 },
limitations: [
...parent.limitations,
"Page totals count retained rows or UTF-16 diagnostic code units. Exhaustion does not prove whole-network completeness; source coverage and examined counts remain separate.",
],
unknowns: [
"Saved packet observations do not establish process identity, causality, encrypted payload contents or traffic missing from the recording.",
],
};
if (view.kind === "summary")
return ok({
kind: "summary",
view,
...shared,
summary: summarize(capture),
coverage: completeWithinViewCoverage(1, 1),
});
if (view.facet === "diagnostics") {
const value =
view.stream === "tshark"
? capture.diagnostics?.tshark_stderr
: capture.diagnostics?.capinfos_stderr;
const text = value?.slice(view.offset, view.offset + view.limit) ?? "";
return ok({
kind: "pcap",
view,
...shared,
source_coverage: capture.coverage,
page: {
facet: "diagnostics",
text,
status: value === undefined ? "unknown" : "retained",
},
coverage: pageViewCoverage(view.offset, text.length, value?.length ?? 0),
unknowns:
value === undefined
? [
...shared.unknowns,
"The parent did not retain diagnostics; an empty text page does not establish their absence.",
]
: shared.unknowns,
});
}
const selected = selectRows(capture, view);
return selected.ok
? ok({
kind: "pcap",
view,
...shared,
source_coverage: capture.coverage,
page: selected.value.page,
coverage: pageViewCoverage(
view.offset,
selected.value.page.items.length,
selected.value.total,
),
})
: selected;
};
+110
View File
@@ -0,0 +1,110 @@
import { z } from "zod";
import { localPathStringSchema } from "../localPath.js";
import { digestSchema } from "../digests.js";
/**
* Caller intent for packet-capture analysis through caller-supplied tshark
* and capinfos. REA never installs Wireshark or its tools.
*/
export const pcapInputSchemas = {
inspect_pcap_client: z.strictObject({}),
inspect_pcap_capture: z.strictObject({
path: localPathStringSchema.describe(
"Local pcap or pcapng capture file to inspect",
),
}),
} as const;
/** Supported pcap-backed operations. */
export type PcapOperation = keyof typeof pcapInputSchemas;
/** Validated pcap requests carry their selected operation. */
export type PcapRequest = {
[Name in PcapOperation]: {
operation: Name;
input: z.infer<(typeof pcapInputSchemas)[Name]>;
};
}[PcapOperation];
/** Validate operation and input together so their types remain correlated. */
export const pcapRequestSchema = z.discriminatedUnion("operation", [
z.strictObject({
operation: z.literal("inspect_pcap_client"),
input: pcapInputSchemas.inspect_pcap_client,
}),
z.strictObject({
operation: z.literal("inspect_pcap_capture"),
input: pcapInputSchemas.inspect_pcap_capture,
}),
]);
const client = z.strictObject({
tshark_command: z.string().min(1),
tshark_source: z.enum(["environment", "path"]),
tshark_version: z.string().min(1).nullable(),
capinfos_command: z.string().min(1),
capinfos_source: z.enum(["environment", "path"]),
capinfos_version: z.string().min(1).nullable(),
});
const endpoint = z.strictObject({
address: z.string().min(1),
port: z.number().int().positive().nullable(),
packet_count: z.number().int().positive(),
});
const dnsQuery = z.strictObject({
query: z.string().min(1),
response_addresses: z.array(z.string().min(1)),
response_address_count: z.number().int().nonnegative(),
});
const protocolCount = z.strictObject({
protocol: z.string().min(1),
packet_count: z.number().int().positive(),
});
/** Portable pcap observations with explicit coverage limitations. */
export const pcapResultSchemas = {
inspect_pcap_client: z.strictObject({
client,
}),
inspect_pcap_capture: z.strictObject({
client,
target: z.strictObject({
path: localPathStringSchema,
bytes: z.number().int().nonnegative(),
sha256: digestSchema,
}),
/** File format as capinfos reports it (pcap vs pcapng). */
file_type: z.string().min(1).nullable(),
encapsulation: z.string().min(1).nullable(),
packet_count: z.number().int().nonnegative().nullable(),
capture_start: z.string().nullable(),
capture_end: z.string().nullable(),
capture_duration_seconds: z.number().nonnegative().nullable(),
snapshot_length: z.number().int().positive().nullable(),
/** Distinct endpoints with packet counts, bounded. */
endpoints: z.array(endpoint),
endpoint_count: z.number().int().nonnegative(),
/** DNS queries with resolved addresses, bounded. */
dns_queries: z.array(dnsQuery),
dns_query_count: z.number().int().nonnegative(),
/** TLS Server Name Indication values observed in handshakes. */
tls_sni_values: z.array(z.string().min(1)),
tls_sni_count: z.number().int().nonnegative(),
/** Protocol distribution (frame.protocols column counts). */
protocol_distribution: z.array(protocolCount),
protocol_count: z.number().int().nonnegative(),
/** HTTP request URIs seen on plain-text ports, bounded. */
http_request_uris: z.array(z.string().min(1)),
http_request_uri_count: z.number().int().nonnegative(),
http_uri_redaction_count: z.number().int().nonnegative(),
unattributed_dns_addresses: z.array(z.string().min(1)),
unattributed_dns_address_count: z.number().int().nonnegative(),
unattributed_dns_packet_count: z.number().int().nonnegative(),
coverage: z.enum(["complete", "partial"]),
diagnostics: z
.strictObject({ capinfos_stderr: z.string(), tshark_stderr: z.string() })
.optional(),
}),
} as const;
+34
View File
@@ -0,0 +1,34 @@
import { createEvidence } from "../evidence.js";
import { jsonObjectSchema, type JsonValue } from "../jsonValue.js";
import type { PcapRequest } from "./pcapAnalysis.js";
/** Domain facts needed by both successful and retained partial observations. */
interface PcapObservation {
readonly subject: Parameters<typeof createEvidence>[0] | null;
readonly provider: Parameters<typeof createEvidence>[1];
readonly result: JsonValue;
readonly rawResult: JsonValue | null;
readonly limitations: readonly string[];
readonly locations: NonNullable<
Parameters<typeof createEvidence>[2]["locations"]
>;
}
/** One owner commits packet aggregation provenance and caller intent into Evidence. */
export const createPcapEvidence = (
request: PcapRequest,
observation: PcapObservation,
) =>
createEvidence(observation.subject ?? undefined, observation.provider, {
operation: request.operation,
parameters: jsonObjectSchema.parse(request.input),
result: observation.result,
rawResult: observation.rawResult,
limitations: observation.limitations,
locations: observation.locations,
confidence:
request.operation === "inspect_pcap_capture" ? "derived" : "observed",
...(request.operation === "inspect_pcap_capture"
? { authority: "historical-reference" as const }
: {}),
});
+207
View File
@@ -0,0 +1,207 @@
import { randomUUID } from "node:crypto";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisProtocolError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js";
import {
isWindowsBatchFile,
windowsBatchCommand,
WindowsBatchCommandError,
} from "../process/WindowsBatchCommand.js";
export interface PcapCommandLimits {
readonly timeoutMs: number;
readonly maxOutputBytes: number;
}
/** Owned process execution shared by version probes and capture reads. */
export const runPcapCommand = async (options: {
readonly operation: string;
readonly command: string;
readonly arguments: readonly string[];
readonly limits: PcapCommandLimits;
readonly environment: Readonly<Record<string, string | undefined>>;
readonly signal: AbortSignal | undefined;
readonly inputPath?: string;
readonly nativeBound?: {
readonly limiter?: { command: string; arguments: readonly string[] };
readonly memoryLimitBytes?: number;
};
readonly retain: (
owner: ProviderProcessSupervisor,
resources: readonly string[],
) => void;
}): Promise<{ stdout: string; stderr: string }> => {
const { operation, command, limits, signal } = options;
if (signal?.aborted)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
const hostEnvironment = {
...process.env,
...options.environment,
LC_ALL: "C",
LANG: "C",
};
try {
const batch = process.platform === "win32" && isWindowsBatchFile(command);
const invocation = batch
? windowsBatchCommand({
environment: hostEnvironment,
batchPath: command,
arguments: options.arguments,
})
: { command, arguments: options.arguments };
const boundedInvocation =
options.nativeBound?.limiter === undefined
? invocation
: {
command: options.nativeBound.limiter.command,
arguments: [
...options.nativeBound.limiter.arguments,
invocation.command,
...invocation.arguments,
],
};
// Native ownership preparation has its own bounded compiler lifecycle;
// do not spend the tool's execution deadline rebuilding a cold SDK cache.
await prepareProcessOwnershipInspection(signal);
const output = await runOwnedCommand(
{
...boundedInvocation,
runId: randomUUID(),
hostEnvironment,
windowsVerbatimArguments: batch,
expectedCommand: null,
...(options.nativeBound?.memoryLimitBytes === undefined
? {}
: { memoryLimitBytes: options.nativeBound.memoryLimitBytes }),
},
{ timeoutMs: limits.timeoutMs, diagnosticBytes: limits.maxOutputBytes },
signal === undefined ? {} : { signal },
);
return { stdout: output.stdout.text, stderr: output.stderr.text };
} catch (cause) {
if (cause instanceof AnalysisError) throw cause;
if (
cause instanceof OwnedCommandFailure &&
cause.cleanupOwner !== undefined
)
options.retain(cause.cleanupOwner, cause.resources);
const details = {
cause,
...(cause instanceof OwnedCommandFailure && cause.snapshot !== null
? {
capturedOutput: {
stdout: "",
stderr: cause.snapshot.stderr.text,
truncated:
cause.snapshot.stdout.observedBytes > 0 ||
(cause.snapshot.diagnosticTruncated ?? false),
exit_code: cause.snapshot.exitCode ?? null,
signal: cause.snapshot.signal ?? null,
},
}
: {}),
...(cause instanceof OwnedCommandFailure && cause.cleanupFailure !== null
? {
cleanup: {
reason: cause.cleanupFailure,
resources: cause.resources,
},
}
: {}),
};
if (
signal?.aborted ||
(cause instanceof OwnedCommandFailure && cause.reason === "cancelled")
)
throw new AnalysisCancelledError(operation, details);
if (
cause instanceof OwnedCommandFailure &&
cause.snapshot?.memoryLimitReached === true
)
throw new AnalysisResourceConstraintError(
operation,
"memory",
"The owned Windows tool tree reached its enforced committed-memory limit.",
{
process_memory_limit_bytes:
options.nativeBound?.memoryLimitBytes ?? null,
},
details,
);
if (
cause instanceof OwnedCommandFailure &&
(cause.snapshot?.signal === "SIGXCPU" ||
cause.snapshot?.signal === "SIGXFSZ")
)
throw new AnalysisResourceConstraintError(
operation,
cause.snapshot.signal === "SIGXCPU" ? "cpu" : "file-size",
`Producer terminated with ${cause.snapshot.signal}; its exact cause is not independently established.`,
null,
details,
);
if (cause instanceof OwnedCommandFailure && cause.reason === "timeout")
throw new AnalysisTimeoutError(operation, limits.timeoutMs, details);
if (cause instanceof OwnedCommandFailure && cause.reason === "output-limit")
throw new AnalysisResourceConstraintError(
operation,
"memory",
"Tool output exceeded its complete-output budget",
{ max_output_bytes: limits.maxOutputBytes },
details,
);
if (
cause instanceof WindowsBatchCommandError ||
(cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "EACCES"))
)
throw new AnalysisCapabilityUnavailableError(
"pcap",
operation,
`Cannot run selected tool ${command}: ${cause.message}`,
details,
);
const diagnostic =
cause instanceof OwnedCommandFailure
? cause.snapshot?.stderr.text.trim() || cause.message
: cause instanceof Error
? cause.message
: String(cause);
if (options.inputPath === undefined)
throw new AnalysisCapabilityUnavailableError(
"pcap",
operation,
`The selected tool ${command} cannot report its version: ${diagnostic}`,
details,
);
if (
options.inputPath !== undefined &&
/not a capture file|file.*isn't a capture file|file format.*not supported/iu.test(
diagnostic,
)
)
throw new AnalysisUnsupportedTargetError(
operation,
options.inputPath,
diagnostic,
details,
);
throw new AnalysisProtocolError(
`Packet capture tool ${command} failed: ${diagnostic}`,
details,
);
}
};
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import {
CAPINFOS_COMMAND_ENV,
TSHARK_COMMAND_ENV,
resolvePcapTools,
} from "./PcapConfiguration.js";
import { parseCapinfosOutput, parseTsharkJsonOutput } from "./PcapOutput.js";
describe("resolvePcapTools", () => {
it("prefers absolute environment paths for both tools", () => {
expect(
resolvePcapTools({
[TSHARK_COMMAND_ENV]: "/usr/bin/tshark",
[CAPINFOS_COMMAND_ENV]: "/usr/bin/capinfos",
}),
).toEqual({
tshark: { command: "/usr/bin/tshark", source: "environment" },
capinfos: { command: "/usr/bin/capinfos", source: "environment" },
});
});
it("falls back to PATH resolution without the environment keys", () => {
expect(resolvePcapTools({})).toEqual({
tshark: { command: "tshark", source: "path" },
capinfos: { command: "capinfos", source: "path" },
});
});
it("rejects a relative tshark path explicitly", () => {
expect(() =>
resolvePcapTools({ [TSHARK_COMMAND_ENV]: "bin/tshark" }),
).toThrow(/must be an absolute path/u);
});
it("rejects a relative capinfos path explicitly", () => {
expect(() =>
resolvePcapTools({ [CAPINFOS_COMMAND_ENV]: "capinfos" }),
).toThrow(/must be an absolute path/u);
});
});
const REAL_CAPINFOS = [
"File name: /work/rich-capture.pcap",
"File type: Wireshark/tcpdump/... - pcap",
"File encapsulation: Linux cooked-mode capture v2",
"Packet size limit: file hdr: 262144 bytes",
"Number of packets: 50",
"File size: 23279 bytes",
"Capture duration: 0.373473 seconds",
"Earliest packet time: 2026-10-11 14:13:40.608505",
"Latest packet time: 2026-10-11 14:13:40.981978",
"SHA256: c94f94742b3288b9a979711509ed5eab3028576301f8938319de3c22a6f5c40b",
].join("\n");
describe("parseCapinfosOutput", () => {
it("projects metadata from real capinfos output", () => {
expect(parseCapinfosOutput(REAL_CAPINFOS)).toEqual({
fileType: "Wireshark/tcpdump/... - pcap",
encapsulation: "Linux cooked-mode capture v2",
packetCount: 50,
captureStart: "2026-10-11 14:13:40.608505",
captureEnd: "2026-10-11 14:13:40.981978",
captureDurationSeconds: 0.373473,
snapshotLength: 262144,
sha256:
"c94f94742b3288b9a979711509ed5eab3028576301f8938319de3c22a6f5c40b",
});
});
it("rejects empty metadata rather than reporting complete unknown facts", () => {
expect(() => parseCapinfosOutput("")).toThrow(/machine-readable/u);
});
});
const REAL_TSHARK_JSON = JSON.stringify([
{
_source: {
layers: {
"frame.number": ["1"],
"frame.protocols": ["sll:ethertype:ip:udp:dns"],
"ip.src": ["192.168.1.1"],
"ip.dst": ["8.8.8.8"],
"dns.qry.name": ["example.com"],
"dns.flags.response": ["1"],
"dns.a": ["93.184.216.34"],
},
},
},
{
_source: {
layers: {
"frame.number": ["2"],
"frame.protocols": ["sll:ethertype:ip:tcp:tls"],
"ip.src": ["192.168.1.1"],
"ip.dst": ["93.184.216.34"],
"tls.handshake.extensions_server_name": ["example.com"],
},
},
},
{
_source: {
layers: {
"frame.number": ["3"],
"frame.protocols": ["sll:ethertype:ip:tcp:http"],
"ip.src": ["192.168.1.1"],
"ip.dst": ["93.184.216.34"],
"http.request.full_uri": ["http://example.com/page"],
},
},
},
]);
describe("parseTsharkJsonOutput", () => {
it("projects endpoints, DNS, TLS SNI, protocols, and HTTP URIs", async () => {
const result = await parseTsharkJsonOutput(REAL_TSHARK_JSON);
expect(result.endpoints).toEqual(
expect.arrayContaining([
{ address: "192.168.1.1", port: null, packet_count: 3 },
{ address: "8.8.8.8", port: null, packet_count: 1 },
{ address: "93.184.216.34", port: null, packet_count: 2 },
]),
);
expect(result.dnsQueries).toEqual([
{
query: "example.com",
response_addresses: ["93.184.216.34"],
response_address_count: 1,
},
]);
expect(result.tlsSniValues).toEqual(["example.com"]);
expect(result.httpRequestUris).toEqual(["http://example.com/page"]);
expect(result.protocolDistribution).toEqual(
expect.arrayContaining([
{ protocol: "sll:ethertype:ip:udp:dns", packet_count: 1 },
{ protocol: "sll:ethertype:ip:tcp:tls", packet_count: 1 },
{ protocol: "sll:ethertype:ip:tcp:http", packet_count: 1 },
]),
);
});
it("retains every endpoint within the aggregate resource budget", async () => {
const packets = Array.from({ length: 10 }, (_, index) => ({
_source: {
layers: {
"frame.number": [String(index + 1)],
"frame.protocols": ["ip:tcp"],
"ip.src": [`10.0.0.${index}`],
"ip.dst": ["10.0.1.1"],
},
},
}));
const result = await parseTsharkJsonOutput(JSON.stringify(packets));
expect(result.endpoints).toHaveLength(11);
expect(result.endpointCount).toBe(11); // 10 src + 1 dst
});
it.each([
"",
"not json",
"{}",
"null",
"[null]",
'[{"_source":{"layers":{"ip.src":[3]}}}]',
])("rejects malformed producer output %s", async (text) => {
await expect(parseTsharkJsonOutput(text)).rejects.toMatchObject({
_tag: "AnalysisProtocolError",
});
});
});
+153
View File
@@ -0,0 +1,153 @@
import { isAbsolute } from "node:path";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import type { JsonValue } from "../domain/jsonValue.js";
/** Environment keys selecting the caller's tshark and capinfos binaries. */
export const TSHARK_COMMAND_ENV = "REA_TSHARK_COMMAND";
export const CAPINFOS_COMMAND_ENV = "REA_CAPINFOS_COMMAND";
/** How each tool was selected for this provider instance. */
export interface PcapToolSelection {
readonly tshark: { command: string; source: "environment" | "path" };
readonly capinfos: { command: string; source: "environment" | "path" };
}
/**
* Resolve the caller-supplied tshark and capinfos binaries. Each accepts an
* absolute environment path or falls back to PATH resolution, mirroring the
* mitmdump pattern. REA never installs the Wireshark suite.
*/
export const resolvePcapTools = (
environment: Readonly<Record<string, string | undefined>>,
): PcapToolSelection => {
const tsharkEnv = environment[TSHARK_COMMAND_ENV];
const capinfosEnv = environment[CAPINFOS_COMMAND_ENV];
if (
tsharkEnv !== undefined &&
tsharkEnv.trim() !== "" &&
!isAbsolute(tsharkEnv)
)
throw new PcapConfigurationFailure(
"tshark_not_absolute",
`${TSHARK_COMMAND_ENV} must be an absolute path to the tshark binary`,
{ configured: tsharkEnv },
);
if (
capinfosEnv !== undefined &&
capinfosEnv.trim() !== "" &&
!isAbsolute(capinfosEnv)
)
throw new PcapConfigurationFailure(
"capinfos_not_absolute",
`${CAPINFOS_COMMAND_ENV} must be an absolute path to the capinfos binary`,
{ configured: capinfosEnv },
);
return {
tshark: {
command:
tsharkEnv === undefined || tsharkEnv.trim() === ""
? "tshark"
: tsharkEnv,
source:
tsharkEnv === undefined || tsharkEnv.trim() === ""
? "path"
: "environment",
},
capinfos: {
command:
capinfosEnv === undefined || capinfosEnv.trim() === ""
? "capinfos"
: capinfosEnv,
source:
capinfosEnv === undefined || capinfosEnv.trim() === ""
? "path"
: "environment",
},
};
};
export type PcapConfigurationFailureCode =
| "tshark_not_absolute"
| "capinfos_not_absolute"
| "tshark_missing"
| "capinfos_missing"
| "tshark_version_unresolved"
| "capinfos_version_unresolved"
| "tshark_version_unsupported"
| "capinfos_version_unsupported";
export class PcapConfigurationFailure extends Error {
constructor(
readonly code: PcapConfigurationFailureCode,
message: string,
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
options?: ErrorOptions,
) {
super(message, options);
}
}
/** Recognize the selected Wireshark tool's version banner. */
const parseVersion = (
stdout: string,
tool: "tshark" | "capinfos",
): string | null => {
const line = stdout
.split(/\r?\n/u)
.map((entry) => entry.trim())
.find((entry) => entry !== "");
const parsed =
line === undefined
? null
: new RegExp(`^${tool} \\(Wireshark\\) (\\d+)\\.\\d+\\.\\d+`, "iu").exec(
line,
);
if (parsed === null || line === undefined) return null;
if (parsed[1] !== "4")
throw new PcapConfigurationFailure(
`${tool}_version_unsupported`,
"Packet summary inspection currently requires Wireshark 4.x tools; select an installation with the admitted output profile.",
{ version: line },
);
return line;
};
export interface PcapClientIdentity {
readonly tsharkVersion: string | null;
readonly capinfosVersion: string | null;
}
/** Probe both tools without reading any capture file. */
export const inspectPcapClient = async (
selection: PcapToolSelection,
probe: (command: string) => Promise<string>,
): Promise<PcapClientIdentity> => ({
tsharkVersion: parseVersion(await probe(selection.tshark.command), "tshark"),
capinfosVersion: parseVersion(
await probe(selection.capinfos.command),
"capinfos",
),
});
/** Availability in the shape shared by every provider. */
const rejectionCodeOf = (
code: PcapConfigurationFailureCode,
): ProviderRejectionCode =>
code.includes("missing")
? "executable_missing"
: code.includes("unsupported")
? "unsupported_version"
: code.includes("not_absolute")
? "not_configured"
: "version_unresolved";
export const pcapConfigurationAvailability = (
cause: PcapConfigurationFailure,
): ProviderAvailability => ({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
diagnostics: { ...cause.diagnostics },
});
+68
View File
@@ -0,0 +1,68 @@
import { access, realpath } from "node:fs/promises";
import { constants } from "node:fs";
import { isAbsolute } from "node:path";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import type { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js";
import type { runPcapCommand } from "./PcapCommand.js";
/** Own native capture admission and the exact enforced resource profile. */
export const preparePcapNativeBound = async (
environment: Readonly<Record<string, string | undefined>>,
resources: ArtifactAnalysisResources,
): Promise<{
nativeBound: NonNullable<Parameters<typeof runPcapCommand>[0]["nativeBound"]>;
limitations: readonly string[];
}> => {
if (process.platform === "win32") {
return {
nativeBound: { memoryLimitBytes: 1024 ** 3 },
limitations: [
"Each owned Windows tool tree has a 1 GiB Job Object committed-memory limit; this is not a machine-wide quota.",
],
};
}
if (process.platform !== "linux")
throw new AnalysisCapabilityUnavailableError(
"pcap",
"inspect_pcap_capture",
"Packet summaries require an enforceable native memory bound: Linux with util-linux prlimit, or Windows with owned Job Objects. This host's capture boundary remains unsupported.",
);
const configured =
environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
try {
if (!isAbsolute(configured))
throw new Error("REA_TSHARK_PRLIMIT_COMMAND must be absolute.");
const limiter = await realpath(configured);
await access(limiter, constants.X_OK);
const limits = await resources.readLimits({
address_space_bytes: 1024 ** 3,
file_bytes: 64 * 1024 ** 2,
cpu_seconds: 300,
});
const nativeBound = {
limiter: {
command: limiter,
arguments: [
`--as=${limits.address_space_bytes}:${limits.address_space_bytes}`,
`--fsize=${limits.file_bytes}:${limits.file_bytes}`,
`--cpu=${limits.cpu_seconds}:${limits.cpu_seconds}`,
"--core=0:0",
"--",
],
},
};
const limitations = [
`Linux prlimit preserves stricter inherited ceilings: ${limits.address_space_bytes} bytes of per-process address space, ${limits.file_bytes} bytes per file and ${limits.cpu_seconds} CPU seconds; core dumps are disabled. These are not aggregate machine quotas.`,
];
return { nativeBound, limitations };
} catch (cause) {
if (cause instanceof AnalysisError) throw cause;
throw new AnalysisCapabilityUnavailableError(
"pcap",
"inspect_pcap_capture",
"Cannot establish the selected Linux resource limiter before processing capture bytes.",
{ cause },
);
}
};
+149
View File
@@ -0,0 +1,149 @@
import { expect, it } from "vitest";
import { parseCapinfosOutput, parseTsharkJsonOutput } from "./PcapOutput.js";
const capture = (layers: ReadonlyArray<Record<string, string[]>>) =>
JSON.stringify(
layers.map((fields, index) => ({
_source: {
layers: {
"frame.number": [String(index + 1)],
"frame.protocols": ["ip:udp:dns"],
...fields,
},
},
})),
);
it("binds interleaved DNS answers to the question in their response packet", async () => {
const result = await parseTsharkJsonOutput(
capture([
{ "dns.qry.name": ["alpha.example"], "dns.flags.response": ["False"] },
{ "dns.qry.name": ["beta.example"], "dns.flags.response": ["False"] },
{
"dns.qry.name": ["alpha.example"],
"dns.flags.response": ["True"],
"dns.a": ["192.0.2.10"],
},
{
"dns.qry.name": ["beta.example"],
"dns.flags.response": ["True"],
"dns.a": ["192.0.2.20"],
},
]),
);
expect(result.dnsQueries).toEqual([
{
query: "alpha.example",
response_addresses: ["192.0.2.10"],
response_address_count: 1,
},
{
query: "beta.example",
response_addresses: ["192.0.2.20"],
response_address_count: 1,
},
]);
expect(result.partial).toBe(false);
});
it("retains unassignable DNS addresses without inventing a query relationship", async () => {
const result = await parseTsharkJsonOutput(
capture([
{
"dns.qry.name": ["alpha.example", "beta.example"],
"dns.flags.response": ["True"],
"dns.a": ["192.0.2.9"],
},
{ "dns.flags.response": ["True"], "dns.aaaa": ["2001:db8::9"] },
]),
);
expect(
result.dnsQueries.every((query) => query.response_addresses.length === 0),
).toBe(true);
expect(result.unattributedDnsAddresses).toEqual(["192.0.2.9", "2001:db8::9"]);
expect(result.unattributedDnsPackets).toBe(2);
expect(result.partial).toBe(true);
});
it("counts IPv6 endpoints once per packet and preserves every examined fact", async () => {
const packets = Array.from({ length: 51 }, (_, index) => ({
"ipv6.src": ["2001:db8::1"],
"ipv6.dst": ["2001:db8::1"],
"tls.handshake.extensions_server_name": [`host${String(index)}.example`],
"frame.protocols": [`ipv6:protocol${String(index)}`],
}));
const result = await parseTsharkJsonOutput(capture(packets));
expect(result.endpoints).toEqual([
{ address: "2001:db8::1", port: null, packet_count: 51 },
]);
expect(result.tlsSniCount).toBe(51);
expect(result.tlsSniValues).toHaveLength(51);
expect(result.protocolCount).toBe(51);
expect(result.protocolDistribution).toHaveLength(51);
expect(result.partial).toBe(false);
});
it("preserves all DNS addresses and their exact examined count", async () => {
const result = await parseTsharkJsonOutput(
capture(
Array.from({ length: 101 }, (_, index) => ({
"dns.qry.name": ["rotating.example"],
"dns.flags.response": ["True"],
"dns.a": [`192.0.2.${String(index)}`],
})),
),
);
expect(result.dnsQueries[0]?.response_addresses).toHaveLength(101);
expect(result.dnsQueries[0]?.response_address_count).toBe(101);
expect(result.partial).toBe(false);
});
it("accepts exact machine packet counts and rejects scaled human counts", () => {
const prefix =
"File type: pcap\nFile encapsulation: Ethernet\nNumber of packets: ";
expect(parseCapinfosOutput(`${prefix}20010`).packetCount).toBe(20010);
expect(() => parseCapinfosOutput(`${prefix}20 k`)).toThrow(
/machine-readable/u,
);
});
it("excludes transport credentials without normalizing observed URL bytes", async () => {
const result = await parseTsharkJsonOutput(
capture([
{
"http.request.full_uri": [
"http://user:password@Example.com/a/../b?token=local-evidence#keep",
],
},
{
"http.request.full_uri": [
"http://other:secret@Example.com/a/../b?token=local-evidence#keep",
],
},
]),
);
expect(result.httpRequestUris).toEqual([
"http://Example.com/a/../b?token=local-evidence#keep",
]);
expect(result.httpRequestUriCount).toBe(2);
expect(result.httpUriRedactionCount).toBe(2);
expect(result.partial).toBe(true);
});
it("refuses dense JSON before its expanded graph exceeds the decoding budget", async () => {
await expect(
parseTsharkJsonOutput(`[${"{},".repeat(600_000)}{}]`),
).rejects.toMatchObject({
_tag: "AnalysisResourceConstraintError",
});
});
it("permits cancellation while validating and aggregating producer output", async () => {
const controller = new AbortController();
const result = parseTsharkJsonOutput(
capture([{ "ip.src": ["192.0.2.1"] }]),
controller.signal,
);
controller.abort(new Error("stopped"));
await expect(result).rejects.toThrow("stopped");
});
+315
View File
@@ -0,0 +1,315 @@
import { setImmediate as yieldToEventLoop } from "node:timers/promises";
import {
AnalysisProtocolError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { parseRecordedTransportUrl } from "../domain/recordedTransportUrl.js";
const OPERATION = "inspect_pcap_capture";
/** Bounds JSON decoding plus its expanded object graph before JSON.parse. */
export const TSHARK_JSON_MAX_BYTES = 8 * 1024 * 1024;
const MAX_RETAINED_BYTES = 16 * 1024 * 1024;
const MAX_DECODED_BYTES = 128 * 1024 * 1024;
const protocolFailure = (reason: string): never => {
throw new AnalysisProtocolError(`Invalid packet-capture output: ${reason}`);
};
export interface CapinfosMetadata {
readonly fileType: string;
readonly encapsulation: string;
readonly packetCount: number;
readonly captureStart: string | null;
readonly captureEnd: string | null;
readonly captureDurationSeconds: number | null;
readonly snapshotLength: number | null;
readonly sha256: string | null;
}
/** capinfos -M with a C locale provides exact numbers instead of "20 k". */
export const parseCapinfosOutput = (stdout: string): CapinfosMetadata => {
const lines = new Map<string, string>();
for (const line of stdout.split(/\r?\n/u)) {
const at = line.indexOf(":");
if (at >= 0 && !/^\s/u.test(line))
lines.set(line.slice(0, at), line.slice(at + 1).trim());
}
const fileType = lines.get("File type");
const encapsulation = lines.get("File encapsulation");
const countText = lines.get("Number of packets");
if (
!fileType ||
!encapsulation ||
countText === undefined ||
!/^\d+$/u.test(countText)
)
return protocolFailure(
"capinfos did not provide machine-readable type, encapsulation and packet count",
);
const packetCount = Number(countText);
if (!Number.isSafeInteger(packetCount))
return protocolFailure("packet count is not representable");
const durationText = lines.get("Capture duration");
const duration =
durationText === undefined
? null
: /^(\d+(?:\.\d+)?)(?:\s+seconds?)?$/u.exec(durationText);
const snapshot = /^file hdr:\s*(\d+) bytes$/u.exec(
lines.get("Packet size limit") ?? "",
);
const snapshotLength = snapshot === null ? null : Number(snapshot[1]);
const sha256 = lines.get("SHA256") ?? null;
if (sha256 !== null && !/^[0-9a-f]{64}$/u.test(sha256))
return protocolFailure("capinfos SHA256 is malformed");
return {
fileType,
encapsulation,
packetCount,
captureStart: lines.get("Earliest packet time") ?? null,
captureEnd: lines.get("Latest packet time") ?? null,
captureDurationSeconds: duration === null ? null : Number(duration[1]),
snapshotLength:
snapshotLength !== null &&
Number.isSafeInteger(snapshotLength) &&
snapshotLength > 0
? snapshotLength
: null,
sha256,
};
};
export interface TsharkFields {
readonly endpoints: { address: string; port: null; packet_count: number }[];
readonly endpointCount: number;
readonly dnsQueries: {
query: string;
response_addresses: string[];
response_address_count: number;
}[];
readonly dnsQueryCount: number;
readonly tlsSniValues: string[];
readonly tlsSniCount: number;
readonly protocolDistribution: { protocol: string; packet_count: number }[];
readonly protocolCount: number;
readonly httpRequestUris: string[];
readonly httpRequestUriCount: number;
readonly httpUriRedactionCount: number;
readonly unattributedDnsAddresses: string[];
readonly unattributedDnsAddressCount: number;
readonly unattributedDnsPackets: number;
readonly packetCount: number;
readonly partial: boolean;
}
const object = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/** Validate the fixed JSON field representation before retaining aggregate facts. */
export const parseTsharkJsonOutput = async (
stdout: string,
signal?: AbortSignal,
): Promise<TsharkFields> => {
signal?.throwIfAborted();
if (Buffer.byteLength(stdout) > TSHARK_JSON_MAX_BYTES)
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
"tshark JSON exceeds the bounded decoding budget",
{ max_json_bytes: TSHARK_JSON_MAX_BYTES },
);
// The selected-field protocol has five container levels. Reject deeper
// graphs before materializing them; quoted brackets are string data.
let depth = 0,
quoted = false,
escaped = false;
let decodedBytes = stdout.length * 2;
for (let index = 0; index < stdout.length; index += 1) {
if (index % 65536 === 0) {
await yieldToEventLoop();
signal?.throwIfAborted();
}
const char = stdout[index];
if (quoted) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') quoted = false;
} else if (char === '"') {
quoted = true;
decodedBytes += 64;
} else if (char === "{" || char === "[") {
decodedBytes += 128;
if (++depth > 5) protocolFailure("unexpected JSON nesting");
} else if (char === "}" || char === "]") depth -= 1;
else if (char === ":" || char === ",") decodedBytes += 128;
if (decodedBytes > MAX_DECODED_BYTES)
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
"tshark JSON expansion exceeds the decoding budget",
{ max_decoded_bytes: MAX_DECODED_BYTES },
);
}
let packets: unknown;
try {
packets = JSON.parse(stdout) as unknown;
} catch (cause) {
throw new AnalysisProtocolError("tshark emitted malformed JSON", { cause });
}
if (!Array.isArray(packets))
return protocolFailure("tshark packet root must be an array");
const endpoints = new Map<string, number>();
const dns = new Map<string, Set<string>>();
const sni = new Set<string>();
const protocols = new Map<string, number>();
const uris = new Set<string>();
const observedUris = new Set<string>();
let uriRedactions = 0;
const unattributed = new Set<string>();
let unattributedPackets = 0;
let retainedBytes = 0;
const charge = (value: string): void => {
retainedBytes += 192 + 2 * Buffer.byteLength(value);
if (retainedBytes > MAX_RETAINED_BYTES)
throw new AnalysisResourceConstraintError(
OPERATION,
"memory",
"Packet inventory exceeds the retained-fact budget",
{ max_retained_bytes: MAX_RETAINED_BYTES },
);
};
const add = (set: Set<string>, value: string): void => {
if (!set.has(value)) {
charge(value);
set.add(value);
}
};
for (let index = 0; index < packets.length; index += 1) {
if (index % 256 === 0) {
await yieldToEventLoop();
signal?.throwIfAborted();
}
const packet: unknown = packets[index];
if (
!object(packet) ||
!object(packet._source) ||
!object(packet._source.layers)
)
return protocolFailure(
`packet ${String(index + 1)} lacks _source.layers`,
);
const layers = packet._source.layers;
for (const [key, value] of Object.entries(layers))
if (
!Array.isArray(value) ||
!value.every((item) => typeof item === "string")
)
return protocolFailure(
`packet ${String(index + 1)} field ${key} must be an array of strings`,
);
const field = (name: string): readonly string[] =>
(layers[name] as string[] | undefined) ?? [];
const frame = field("frame.number");
if (
frame.length !== 1 ||
!/^\d+$/u.test(frame[0]!) ||
Number(frame[0]) !== index + 1
)
return protocolFailure(
"frame inventory is missing, duplicated or out of order",
);
if (
field("frame.protocols").length !== 1 ||
field("frame.protocols")[0] === ""
)
return protocolFailure(
"packet protocol inventory is missing or ambiguous",
);
// Count one occurrence per packet even for loopback or nested IP layers.
for (const address of new Set([
...field("ip.src"),
...field("ip.dst"),
...field("ipv6.src"),
...field("ipv6.dst"),
])) {
if (address === "") continue;
if (!endpoints.has(address)) charge(address);
endpoints.set(address, (endpoints.get(address) ?? 0) + 1);
}
const questions = new Set(
field("dns.qry.name").filter((value) => value !== ""),
);
for (const question of questions)
if (!dns.has(question)) {
charge(question);
dns.set(question, new Set());
}
const addresses = [...field("dns.a"), ...field("dns.aaaa")].filter(
(value) => value !== "",
);
const response = field("dns.flags.response");
if (addresses.length > 0) {
if (
questions.size === 1 &&
response.length > 0 &&
response.every((value) => value === "True" || value === "1")
) {
const values = dns.get(questions.values().next().value!)!;
for (const address of addresses) add(values, address);
} else {
unattributedPackets += 1;
for (const address of addresses) add(unattributed, address);
}
}
for (const value of field("tls.handshake.extensions_server_name"))
if (value !== "") add(sni, value);
for (const value of new Set(field("frame.protocols"))) {
if (value === "") continue;
if (!protocols.has(value)) charge(value);
protocols.set(value, (protocols.get(value) ?? 0) + 1);
}
for (const value of field("http.request.full_uri")) {
if (value === "" || observedUris.has(value)) continue;
add(observedUris, value);
const { credentials } = parseRecordedTransportUrl(value);
if (credentials !== null) uriRedactions += 1;
if (credentials === "ambiguous") continue;
add(
uris,
credentials === null
? value
: value.slice(0, credentials.start) + value.slice(credentials.end),
);
}
}
signal?.throwIfAborted();
return {
endpoints: [...endpoints]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([address, packet_count]) => ({
address,
port: null,
packet_count,
})),
endpointCount: endpoints.size,
dnsQueries: [...dns].map(([query, addresses]) => ({
query,
response_addresses: [...addresses],
response_address_count: addresses.size,
})),
dnsQueryCount: dns.size,
tlsSniValues: [...sni],
tlsSniCount: sni.size,
protocolDistribution: [...protocols]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([protocol, packet_count]) => ({ protocol, packet_count })),
protocolCount: protocols.size,
httpRequestUris: [...uris],
httpRequestUriCount: observedUris.size,
httpUriRedactionCount: uriRedactions,
unattributedDnsAddresses: [...unattributed],
unattributedDnsAddressCount: unattributed.size,
unattributedDnsPackets: unattributedPackets,
packetCount: packets.length,
partial: observedUris.size > uris.size || unattributedPackets > 0,
};
};
+493
View File
@@ -0,0 +1,493 @@
import type {
ExecutionOptions,
ProviderAvailability,
} from "../application/AnalysisProvider.js";
import {
createAnalysisExecution,
type AnalysisExecution,
} from "../application/AnalysisProvider.js";
import {
pcapResultSchemas,
type PcapRequest,
type PcapOperation,
} from "../domain/pcap/pcapAnalysis.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisProtocolError,
} from "../domain/analysisErrorCore.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { err, ok, type Result } from "../domain/result.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import {
PcapConfigurationFailure,
inspectPcapClient,
resolvePcapTools,
pcapConfigurationAvailability,
type PcapToolSelection,
} from "./PcapConfiguration.js";
import {
parseCapinfosOutput,
parseTsharkJsonOutput,
TSHARK_JSON_MAX_BYTES,
} from "./PcapOutput.js";
import { runPcapCommand, type PcapCommandLimits } from "./PcapCommand.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { createPcapEvidence } from "../domain/pcap/pcapEvidence.js";
import { preparePcapNativeBound } from "./PcapNativeBound.js";
import { PcapSnapshot } from "./PcapSnapshot.js";
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
import { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js";
import { readLinuxResourceLimits } from "../process/LinuxResourceLimits.js";
export const PCAP_PROVIDER_IDENTITY = {
id: "pcap",
name: "Packet capture analysis",
version: null,
} as const;
const FIELDS = [
"frame.number",
"frame.protocols",
"ip.src",
"ip.dst",
"ipv6.src",
"ipv6.dst",
"dns.flags.response",
"dns.qry.name",
"dns.a",
"dns.aaaa",
"tls.handshake.extensions_server_name",
"http.request.full_uri",
];
export interface PcapProviderOptions {
readonly environment?: Readonly<Record<string, string | undefined>>;
}
/** Inspect saved captures through bounded, owned tool processes and one stable input copy. */
export class PcapProvider {
readonly #resources = new ArtifactAnalysisResources(
"pcap",
"inspect_pcap_capture",
readStableArtifact,
readLinuxResourceLimits,
{
message: "A packet summary is already active; retry after it completes.",
details: { max_concurrent_inspections: 1 },
},
);
private readonly environment: Readonly<Record<string, string | undefined>>;
private readonly snapshots = new Set<PcapSnapshot>();
private readonly commands = new Map<
ProviderProcessSupervisor,
{ snapshot: PcapSnapshot | undefined; resources: readonly string[] }
>();
constructor(options: PcapProviderOptions = {}) {
this.environment = options.environment ?? process.env;
}
private run(
operation: PcapOperation,
command: string,
args: readonly string[],
limits: PcapCommandLimits,
signal: AbortSignal | undefined,
snapshot?: PcapSnapshot,
inputPath?: string,
) {
return runPcapCommand({
operation,
command,
arguments: args,
limits,
signal,
environment:
snapshot === undefined
? this.environment
: {
...this.environment,
WIRESHARK_CONFIG_DIR: snapshot.configurationPath,
},
...(inputPath === undefined ? {} : { inputPath }),
...(inputPath === undefined || this.nativeBound === undefined
? {}
: { nativeBound: this.nativeBound }),
retain: (owner, resources) => {
this.commands.set(owner, { snapshot, resources });
},
});
}
private nativeBound: Parameters<typeof runPcapCommand>[0]["nativeBound"];
private nativeLimitations: readonly string[] = [];
private async prepareCaptureBound(): Promise<void> {
const prepared = await preparePcapNativeBound(
this.environment,
this.#resources,
);
this.nativeBound = prepared.nativeBound;
this.nativeLimitations = prepared.limitations;
}
private async client(operation: PcapOperation, signal?: AbortSignal) {
const selection = resolvePcapTools(this.environment);
const identity = await inspectPcapClient(
selection,
async (command) =>
(
await this.run(
operation,
command,
["--version"],
{ timeoutMs: 20_000, maxOutputBytes: 64 * 1024 },
signal,
)
).stdout,
);
for (const [name, version] of [
["tshark", identity.tsharkVersion],
["capinfos", identity.capinfosVersion],
] as const) {
if (version === null)
throw new PcapConfigurationFailure(
`${name}_version_unresolved`,
`The selected ${name} command did not report a version. Set REA_${name.toUpperCase()}_COMMAND to its absolute executable path.`,
);
}
return {
selection,
client: {
tshark_command: selection.tshark.command,
tshark_source: selection.tshark.source,
tshark_version: identity.tsharkVersion,
capinfos_command: selection.capinfos.command,
capinfos_source: selection.capinfos.source,
capinfos_version: identity.capinfosVersion,
},
};
}
async inspectAvailability(
signal?: AbortSignal,
): Promise<ProviderAvailability> {
const result = await this.#resources.run(
async () => ok(await this.inspectAvailabilityAdmitted(signal)),
signal,
);
if (!result.ok) throw result.error;
return result.value;
}
private async inspectAvailabilityAdmitted(
signal?: AbortSignal,
): Promise<ProviderAvailability> {
try {
const { client } = await this.client("inspect_pcap_client", signal);
if (process.platform !== "linux" && process.platform !== "win32")
return {
status: "unavailable",
code: "unsupported_host",
reason:
"No enforceable native memory boundary is available for capture summaries on this host.",
diagnostics: client,
};
await this.prepareCaptureBound();
return {
status: "available",
code: null,
reason: null,
diagnostics: client,
};
} catch (cause) {
if (signal?.aborted || cause instanceof AnalysisCancelledError)
throw new AnalysisCancelledError("inspect_pcap_client", { cause });
if (cause instanceof PcapConfigurationFailure)
return pcapConfigurationAvailability(cause);
return {
status: "unavailable",
code:
cause instanceof AnalysisCapabilityUnavailableError
? "executable_missing"
: "version_unresolved",
reason: cause instanceof Error ? cause.message : String(cause),
diagnostics: {},
};
}
}
private async discard(
snapshot: PcapSnapshot,
): Promise<AnalysisError | undefined> {
try {
if (
[...this.commands.values()].some(
(command) => command.snapshot === snapshot,
)
)
throw new Error(
"An unreleased command still owns the capture snapshot",
);
await snapshot.close();
this.snapshots.delete(snapshot);
return undefined;
} catch (cause) {
return new ProviderCleanupError(
"pcap",
[snapshot.path],
{ reason: String(cause) },
{ operation: "inspect_pcap_capture", cause },
);
}
}
async close(): Promise<void> {
const failures: unknown[] = [];
try {
await this.#resources.close();
} catch (cause) {
failures.push(cause);
}
for (const owner of this.commands.keys()) {
try {
const result = await owner.stop();
if (result.status === "incomplete") failures.push(result.reason);
else if (result.status === "not-owned")
failures.push("Command ownership remains unresolved");
else this.commands.delete(owner);
} catch (cause) {
failures.push(cause);
}
}
for (const snapshot of this.snapshots) {
const failure = await this.discard(snapshot);
if (failure !== undefined) failures.push(failure);
}
if (failures.length > 0)
throw new ProviderCleanupError(
"pcap",
[...this.snapshots]
.map((snapshot) => snapshot.path)
.concat(
[...this.commands.values()].flatMap((command) => command.resources),
),
{ reason: "Owned process or snapshot cleanup failed" },
{ operation: "pcap.close", cause: new AggregateError(failures) },
);
}
async execute(
request: PcapRequest,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>> {
return this.#resources.run(
() => this.executeAdmitted(request, options),
options?.signal,
);
}
private async executeAdmitted(
request: PcapRequest,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>> {
const signal = options?.signal;
let snapshot: PcapSnapshot | undefined;
let result: Result<AnalysisExecution, AnalysisError>;
try {
signal?.throwIfAborted();
const { selection, client } = await this.client(
request.operation,
signal,
);
const provider = {
...PCAP_PROVIDER_IDENTITY,
version: client.tshark_version,
};
if (request.operation === "inspect_pcap_client")
result = ok(
createAnalysisExecution(
pcapResultSchemas.inspect_pcap_client.parse({ client }),
provider,
),
);
else {
await this.prepareCaptureBound();
snapshot = await PcapSnapshot.create();
this.snapshots.add(snapshot);
const identity = await snapshot.capture(request.input.path, signal);
const captured = await this.inspectCapture(
selection,
snapshot,
request.input.path,
identity,
signal,
);
const data = pcapResultSchemas.inspect_pcap_capture.parse({
client,
...captured.result,
});
result = ok(
createAnalysisExecution(data, provider, {
subject: {
path: request.input.path,
format: "file",
sha256: identity.sha256,
},
limitations: captured.limitations,
}),
);
}
} catch (cause) {
result = err(
cause instanceof AnalysisError
? cause
: signal?.aborted
? new AnalysisCancelledError(request.operation, { cause })
: cause instanceof PcapConfigurationFailure
? new AnalysisCapabilityUnavailableError(
"pcap",
request.operation,
cause.message,
{ cause },
)
: new AnalysisProtocolError(
`Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
),
);
}
if (signal?.aborted && result.ok)
result = err(
new AnalysisCancelledError(request.operation, {
cause: signal.reason,
partialObservation: createPcapEvidence(request, result.value),
}),
);
if (snapshot !== undefined) {
const cleanup = await this.discard(snapshot);
if (signal?.aborted && result.ok)
result = err(
new AnalysisCancelledError(request.operation, {
cause: signal.reason,
partialObservation: createPcapEvidence(request, result.value),
}),
);
if (cleanup !== undefined)
result = result.ok
? err(
cleanup.retainPartialObservation(
createPcapEvidence(request, result.value),
),
)
: err(
analysisErrorWithCleanupFailure(
result.error,
cleanup,
request.operation,
),
);
}
return result;
}
private async inspectCapture(
selection: PcapToolSelection,
snapshot: PcapSnapshot,
path: string,
identity: { bytes: number; sha256: string },
signal: AbortSignal | undefined,
) {
const info = await this.run(
"inspect_pcap_capture",
selection.capinfos.command,
["-M", "--", snapshot.path],
{ timeoutMs: 60_000, maxOutputBytes: 64 * 1024 },
signal,
snapshot,
path,
);
const metadata = parseCapinfosOutput(info.stdout);
if (metadata.sha256 !== null && metadata.sha256 !== identity.sha256)
throw new AnalysisProtocolError(
"capinfos SHA256 disagrees with the owned capture snapshot",
);
const dissected = await this.run(
"inspect_pcap_capture",
selection.tshark.command,
[
"-n",
"-r",
snapshot.path,
"-T",
"json",
"--no-duplicate-keys",
`--temp-dir=${snapshot.configurationPath}`,
...FIELDS.flatMap((field) => ["-e", field]),
],
{ timeoutMs: 300_000, maxOutputBytes: TSHARK_JSON_MAX_BYTES },
signal,
snapshot,
path,
);
const fields = await parseTsharkJsonOutput(dissected.stdout, signal);
await snapshot.verify(identity, path, signal);
if (fields.packetCount !== metadata.packetCount)
throw new AnalysisProtocolError(
`capinfos counted ${String(metadata.packetCount)} packets but tshark returned ${String(fields.packetCount)}`,
);
return {
result: {
target: { path, ...identity },
file_type: metadata.fileType,
encapsulation: metadata.encapsulation,
packet_count: metadata.packetCount,
capture_start: metadata.captureStart,
capture_end: metadata.captureEnd,
capture_duration_seconds: metadata.captureDurationSeconds,
diagnostics: {
capinfos_stderr: info.stderr,
tshark_stderr: dissected.stderr,
},
snapshot_length: metadata.snapshotLength,
endpoints: fields.endpoints,
endpoint_count: fields.endpointCount,
dns_queries: fields.dnsQueries,
dns_query_count: fields.dnsQueryCount,
tls_sni_values: fields.tlsSniValues,
tls_sni_count: fields.tlsSniCount,
protocol_distribution: fields.protocolDistribution,
protocol_count: fields.protocolCount,
http_request_uris: fields.httpRequestUris,
http_request_uri_count: fields.httpRequestUriCount,
http_uri_redaction_count: fields.httpUriRedactionCount,
unattributed_dns_addresses: fields.unattributedDnsAddresses,
unattributed_dns_address_count: fields.unattributedDnsAddressCount,
unattributed_dns_packet_count: fields.unattributedDnsPackets,
coverage: fields.partial ? "partial" : "complete",
},
limitations: [
...this.nativeLimitations,
"Both tools read one owned snapshot; target identity comes from its admitted source descriptor",
"DNS address values are associated only with a single question in the same response packet; values may include additional DNS records, not only final resolution answers",
"Only bytes present in the saved capture are examined; encryption and capture truncation can hide higher-level fields",
...(fields.partial
? [
"One or more projected categories were capped, credential-bearing URIs were omitted or coalesced, or DNS address attribution remains unresolved; counts retain the examined totals",
]
: []),
...(fields.httpUriRedactionCount > 0
? [
"HTTP transport credentials were excluded; ambiguous credential spans cause the URI to be omitted",
]
: []),
...(fields.unattributedDnsPackets > 0
? [
`DNS address attribution was unresolved in ${String(fields.unattributedDnsPackets)} packet(s); unattributed addresses are retained separately`,
]
: []),
],
};
}
}
+269
View File
@@ -0,0 +1,269 @@
import { createHash } from "node:crypto";
import { chmod, open, stat, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import {
openRegularFile,
RegularFileAdmissionFailure,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js";
const OPERATION = "inspect_pcap_capture";
const MAX_CAPTURE_BYTES = 2 * 1024 ** 3;
/** An owned capture copy and every descriptor needed to release it safely. */
export class PcapSnapshot {
private readonly handles = new Set<OwnedFileHandle>();
readonly path: string;
readonly configurationPath: string;
private constructor(private readonly root: PrivateRuntimeRoot) {
this.path = join(root.path, "capture.pcap");
this.configurationPath = join(root.path, "configuration");
}
static async create(): Promise<PcapSnapshot> {
try {
return new PcapSnapshot(
await PrivateRuntimeRoot.create({ prefix: "rea-pcap-" }),
);
} catch (cause) {
if (cause instanceof PrivateRuntimeRootUnavailableError)
throw new AnalysisCapabilityUnavailableError(
"pcap",
OPERATION,
cause.message,
{ cause },
);
throw new AnalysisResourceConstraintError(
OPERATION,
"file-size",
"Cannot allocate the owned capture workspace",
null,
{ cause },
);
}
}
private own(handle: OwnedFileHandle): OwnedFileHandle {
this.handles.add(handle);
return handle;
}
private async release(handle: OwnedFileHandle): Promise<void> {
await handle.close();
this.handles.delete(handle);
}
async capture(
path: string,
signal?: AbortSignal,
): Promise<{ bytes: number; sha256: string }> {
const sourceRead = async <T>(read: () => Promise<T>): Promise<T> => {
try {
return await read();
} catch (cause) {
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause });
if (cause instanceof AnalysisError) throw cause;
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "EACCES" || cause.code === "EPERM")
)
throw new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
cause,
});
throw new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message: `Cannot read selected capture ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
},
]);
}
};
try {
signal?.throwIfAborted();
await mkdir(this.configurationPath, { mode: 0o700 });
const source = await sourceRead(async () => {
try {
return this.own(
new OwnedFileHandle(
await openRegularFile(path, { symlinks: "follow", signal }),
),
);
} catch (cause) {
if (cause instanceof RegularFileAdmissionFailure) {
this.own(cause.owner);
throw cause.cause;
}
throw cause;
}
});
const before = await sourceRead(() =>
source.handle.stat({ bigint: true }),
);
if (before.size > BigInt(MAX_CAPTURE_BYTES))
throw new AnalysisResourceConstraintError(
OPERATION,
"file-size",
"Capture exceeds the owned-snapshot disk budget",
{ max_capture_bytes: MAX_CAPTURE_BYTES },
);
const destination = this.own(
new OwnedFileHandle(await open(this.path, "wx", 0o600)),
);
const buffer = Buffer.alloc(64 * 1024);
const hash = createHash("sha256");
let copied = 0;
while (copied <= Number(before.size)) {
signal?.throwIfAborted();
const { bytesRead } = await sourceRead(() =>
source.handle.read(
buffer,
0,
Math.min(buffer.length, Number(before.size) - copied + 1),
copied,
),
);
signal?.throwIfAborted();
if (bytesRead === 0) break;
copied += bytesRead;
if (BigInt(copied) > before.size)
throw new AnalysisArtifactChangedError(
OPERATION,
path,
"Capture grew during acquisition",
);
const chunk = buffer.subarray(0, bytesRead);
await destination.handle.writeFile(chunk);
hash.update(chunk);
}
const after = await sourceRead(() =>
source.handle.stat({ bigint: true }),
);
const selected = await stat(path, { bigint: true }).catch(
(cause: unknown) => {
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "EACCES" || cause.code === "EPERM")
)
throw new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
cause,
});
throw new AnalysisArtifactChangedError(
OPERATION,
path,
"Selected path became unavailable during acquisition",
{ cause },
);
},
);
if (
BigInt(copied) !== before.size ||
!sameRegularFileState(before, after) ||
!sameRegularFileState(before, selected)
)
throw new AnalysisArtifactChangedError(
OPERATION,
path,
"Capture identity or contents changed during acquisition",
);
await this.release(source);
await this.release(destination);
await chmod(this.path, 0o400);
signal?.throwIfAborted();
return { bytes: copied, sha256: hash.digest("hex") };
} catch (cause) {
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause });
if (cause instanceof AnalysisError) throw cause;
throw new AnalysisResourceConstraintError(
OPERATION,
"file-size",
"Owned capture snapshot I/O failed",
null,
{ cause },
);
}
}
/** Recheck byte identity after both tools; a read-only mode alone is not proof. */
async verify(
expected: { bytes: number; sha256: string },
selectedPath: string,
signal?: AbortSignal,
): Promise<void> {
try {
const file = this.own(
new OwnedFileHandle(
await openRegularFile(this.path, { symlinks: "reject", signal }),
),
);
const before = await file.handle.stat({ bigint: true });
const buffer = Buffer.alloc(64 * 1024);
const hash = createHash("sha256");
let bytes = 0;
while (bytes <= expected.bytes) {
signal?.throwIfAborted();
const read = await file.handle.read(
buffer,
0,
Math.min(buffer.length, expected.bytes - bytes + 1),
bytes,
);
if (read.bytesRead === 0) break;
bytes += read.bytesRead;
hash.update(buffer.subarray(0, read.bytesRead));
}
const after = await file.handle.stat({ bigint: true });
const selected = await stat(this.path, { bigint: true });
if (
bytes !== expected.bytes ||
hash.digest("hex") !== expected.sha256 ||
!sameRegularFileState(before, after) ||
!sameRegularFileState(before, selected)
)
throw new AnalysisArtifactChangedError(
OPERATION,
selectedPath,
"Owned packet snapshot changed during tool analysis.",
);
await this.release(file);
} catch (cause) {
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause });
if (cause instanceof RegularFileAdmissionFailure) this.own(cause.owner);
if (cause instanceof AnalysisError) throw cause;
throw new AnalysisArtifactChangedError(
OPERATION,
selectedPath,
"Cannot establish the owned packet snapshot's post-analysis identity.",
{ cause },
);
}
}
async close(): Promise<void> {
const results = await Promise.allSettled(
[...this.handles].map((handle) => this.release(handle)),
);
const failures = results.flatMap((result) =>
result.status === "rejected" ? [result.reason as unknown] : [],
);
if (failures.length > 0)
throw new AggregateError(failures, "Capture descriptor cleanup failed");
await this.root.close();
}
}
+4 -2
View File
@@ -74,7 +74,9 @@ const sourceFiles = [
export const SWIFTC_INTERRUPT = "SIGINT";
/** Deadline for the one-shot helper compilation. */
const COMPILER_TIMEOUT_MS = 60_000;
// A fresh Swift SDK cache took 88 seconds on the supported Intel macOS VM.
// Keep compilation bounded independently of short provider version probes.
const COMPILER_TIMEOUT_MS = 120_000;
/** Bound on compiler diagnostics embedded in the inspection failure text. */
const STDERR_EXCERPT_LIMIT = 512;
@@ -253,7 +255,7 @@ export type DarwinProcessReaderCompiler =
export const createDarwinProcessRunTokenReader = (
options: {
readonly compiler?: DarwinProcessReaderCompiler;
/** Deadline for the helper compilation; the default keeps verification fast. */
/** Deadline for helper compilation, independent of provider execution. */
readonly compileTimeoutMs?: number;
/**
* Caller-owned directory holding the compiler's module cache. Unset keeps
+15
View File
@@ -84,6 +84,7 @@ import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnaly
import { createFirmwareAnalysisProvider } from "../composition/firmware.js";
import { registerAndroidTools } from "./registerAndroidTools.js";
import { registerApktoolTools } from "./registerApktoolTools.js";
import { registerPcapTools } from "./registerPcapTools.js";
import { registerFlutterTools } from "./registerFlutterTools.js";
import { registerJebTools } from "./registerJebTools.js";
import { registerAdbTools } from "./registerAdbTools.js";
@@ -91,11 +92,14 @@ import { AndroidAnalysisService } from "../application/android/AndroidAnalysisSe
import { JebAnalysisService } from "../application/jeb/JebAnalysisService.js";
import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysisPort.js";
import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js";
import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js";
import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js";
import { createAndroidAnalysisProvider } from "../composition/android.js";
import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js";
import { createPcapAnalysisProvider } from "../composition/pcap.js";
import { createFlutterBuildAnalysisProvider } from "../composition/flutter.js";
import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js";
import { createJebAnalysisProvider } from "../composition/jeb.js";
@@ -146,6 +150,7 @@ export interface CreateServerOptions {
readonly webNetworkCapture?: WebNetworkCaptureService;
readonly androidAnalysis?: AndroidAnalysisPort;
readonly apktoolAnalysis?: ApktoolResourceAnalysisPort;
readonly pcapAnalysis?: PcapAnalysisPort;
readonly flutterAnalysis?: FlutterBuildAnalysisPort;
readonly jebAnalysis?: JebAnalysisPort;
readonly adbDeviceAnalysis?: AdbDeviceAnalysisPort;
@@ -303,6 +308,7 @@ export const createServer = (
);
const android =
options.androidAnalysis ?? createAndroidAnalysisProvider(environment);
const pcap = options.pcapAnalysis ?? createPcapAnalysisProvider(environment);
const flutter =
options.flutterAnalysis ?? createFlutterBuildAnalysisProvider();
const fridaInstrumentation =
@@ -333,6 +339,7 @@ export const createServer = (
jeb.close(),
adbDevice.close(),
flutter.close(),
pcap.close(),
evmInterface.close(),
sqliteDatabase.close(),
binaryLayout.close(),
@@ -438,6 +445,14 @@ export const createServer = (
recordEvidence,
analysisAdmission,
);
registerPcapTools(
server,
new PcapAnalysisService(pcap),
toolLogger,
recordEvidence,
analysisAdmission,
evidenceById,
);
registerFlutterTools(
server,
new FlutterBuildAnalysisService(flutter),
+83
View File
@@ -0,0 +1,83 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import type { Logger } from "pino";
import {
createContractToolHandler,
createEvidenceToolHandler,
} from "./contractToolHandler.js";
import {
toolRegistrationOptions,
applicationValidatedInputSchema,
} from "./toolRegistrationOptions.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
} from "../application/analysisView/AnalysisViewService.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { err } from "../domain/result.js";
/** Bind pcap handlers to their exact named contracts through admission. */
export const registerPcapTools = (
server: EvidenceMcpServer,
service: PcapAnalysisService,
logger: Logger,
recordEvidence?: EvidenceWriter["recordEvidence"],
withAdmittedAnalysis?: WithAdmittedAnalysis,
evidenceById?: EvidenceLookup,
): void => {
const handler = createContractToolHandler(
server,
service,
logger,
recordEvidence,
withAdmittedAnalysis,
);
const clientContract = toolContract("inspect_pcap_client");
server.registerTool(
clientContract.name,
toolRegistrationOptions(clientContract),
handler(clientContract),
);
const contract = toolContract("inspect_pcap_capture");
const registration = toolRegistrationOptions(contract);
const capture = createEvidenceToolHandler(
server,
async (rawInput, options) => {
const parsed = contract.inputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
contract.name,
parsed.error.issues,
rawInput,
{ cause: parsed.error },
),
);
const { detail, ...input } = parsed.data;
const retention = { recordEvidence, evidenceById };
if (detail === "summary") {
const ready = requireAnalysisViewRetention(contract.name, retention);
if (!ready.ok) return ready;
}
const result = await service.execute(contract.name, input, options);
return detail === "summary" && result.ok
? summarizeRetainedAnalysis(result.value, retention)
: result;
},
logger,
recordEvidence,
withAdmittedAnalysis,
);
server.registerTool(
contract.name,
{
...registration,
inputSchema: applicationValidatedInputSchema(registration.inputSchema),
},
capture(contract),
);
};
+23
View File
@@ -0,0 +1,23 @@
import { expect, it } from "vitest";
import { serveCli } from "./cliServeHarness.js";
it.each(["inspect-pcap-client", "inspect-pcap-capture"])(
"%s advertises a schema through the public CLI",
async (command) => {
const result = await serveCli([command, "--schema", "--json"]);
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toBeTruthy();
},
);
it("reports missing tools with a capability refusal", async () => {
const result = await serveCli(
["inspect-pcap-capture", "/nonexistent/capture.pcap", "--json"],
{
REA_TSHARK_COMMAND: "/nonexistent/tshark",
REA_CAPINFOS_COMMAND: "/nonexistent/capinfos",
},
);
expect(result.stdout).toContain("capability_unavailable");
});
+118
View File
@@ -0,0 +1,118 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, onTestFinished } from "vitest";
import type { PcapAnalysisPort } from "../../../src/application/pcap/PcapAnalysisPort.js";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { ok } from "../../../src/domain/result.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
const identity = { id: "pcap", name: "Packet capture analysis", version: null };
const clientIdentity = {
tshark_command: "/usr/bin/tshark",
tshark_source: "path",
tshark_version: "TShark (Wireshark) 4.6.6",
capinfos_command: "/usr/bin/capinfos",
capinfos_source: "path",
capinfos_version: "Capinfos (Wireshark) 4.6.6",
};
it("advertises exact valid pcap schemas and records inline evidence through MCP", async () => {
const provider: PcapAnalysisPort = {
async inspectAvailability() {
return { status: "available", code: null, reason: null, diagnostics: {} };
},
async close() {},
async execute(request) {
if (request.operation === "inspect_pcap_client")
return ok(
createAnalysisExecution({ client: clientIdentity }, identity),
);
return ok(
createAnalysisExecution(
{
client: clientIdentity,
target: {
path: "/captures/session.pcap",
bytes: 23279,
sha256: "a".repeat(64),
},
file_type: "Wireshark/tcpdump/... - pcap",
encapsulation: "Linux cooked-mode capture v2",
packet_count: 50,
capture_start: "2026-10-11 14:13:40.608505",
capture_end: "2026-10-11 14:13:40.981978",
capture_duration_seconds: 0.373473,
snapshot_length: 262144,
endpoints: [
{ address: "192.168.1.100", port: null, packet_count: 30 },
{ address: "8.8.8.8", port: 53, packet_count: 5 },
],
endpoint_count: 2,
dns_queries: [
{
query: "example.com",
response_addresses: ["93.184.216.34"],
response_address_count: 1,
},
],
dns_query_count: 1,
tls_sni_values: ["example.com"],
tls_sni_count: 1,
protocol_distribution: [
{ protocol: "ip:tcp:tls", packet_count: 25 },
{ protocol: "ip:udp:dns", packet_count: 5 },
],
protocol_count: 2,
http_request_uris: ["http://example.com/api"],
http_request_uri_count: 1,
http_uri_redaction_count: 0,
unattributed_dns_addresses: [],
unattributed_dns_address_count: 0,
unattributed_dns_packet_count: 0,
coverage: "complete",
},
identity,
),
);
},
};
const session = createTestBinarySession(() => {
throw new Error("deep provider must not start");
});
const server = createServer(
{ kind: "session", session },
{ pcapAnalysis: provider },
);
onTestFinished(() => server.close());
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "pcap-boundary", version: "0.0.0" });
onTestFinished(() => client.close());
await client.connect(clientTransport);
const { tools } = await client.listTools();
assertAdvertisedMcpContracts(
["inspect_pcap_client", "inspect_pcap_capture"],
tools,
);
const inspected = await client.callTool({
name: "inspect_pcap_capture",
arguments: { path: "/captures/session.pcap" },
});
expect(inspected.isError).not.toBe(true);
const text = (inspected.content as { readonly text?: string }[])
.map((part) => part.text ?? "")
.join("");
const evidence = parseEvidence(JSON.parse(text));
expect(evidence.normalized_result).toMatchObject({
packet_count: 50,
tls_sni_values: ["example.com"],
coverage: "complete",
});
});
+53
View File
@@ -0,0 +1,53 @@
import { setTimeout as delay } from "node:timers/promises";
import { expect, it, onTestFinished, vi } from "vitest";
import { runPcapCommand } from "../../../src/pcap/PcapCommand.js";
import * as ownership from "../../../src/process/ProcessOwnershipObservation.js";
it("does not spend the tool execution deadline preparing native ownership", async () => {
const original = ownership.prepareProcessOwnershipInspection;
let entered!: () => void;
let release!: () => void;
const preparing = new Promise<void>((resolve) => {
entered = resolve;
});
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let first = true;
const prepare = vi
.spyOn(ownership, "prepareProcessOwnershipInspection")
.mockImplementation(async (signal) => {
await original(signal);
if (first) {
first = false;
entered();
await gate;
}
});
onTestFinished(() => {
prepare.mockRestore();
release();
});
const pending = runPcapCommand({
operation: "inspect_pcap_client",
command: process.execPath,
arguments: ["--version"],
limits: { timeoutMs: 1000, maxOutputBytes: 1024 },
environment: {},
signal: undefined,
retain: (owner) => {
onTestFinished(async () => {
expect((await owner.stop()).status).not.toBe("incomplete");
});
},
}).then(
(value) => ({ ok: true as const, value }),
(error: unknown) => ({ ok: false as const, error }),
);
await preparing;
await delay(1100);
release();
const result = await pending;
if (!result.ok) throw result.error;
expect(result.value.stdout.trim()).toBe(process.version);
});
+508
View File
@@ -0,0 +1,508 @@
import { createHash } from "node:crypto";
import { watch } from "node:fs";
import { chmod, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, onTestFinished, test, vi } from "vitest";
import { PcapProvider } from "../../../src/pcap/PcapProvider.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import {
pcapResultSchemas,
type PcapRequest,
} from "../../../src/domain/pcap/pcapAnalysis.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { holdFirstFileRead } from "../../support/holdFirstFileRead.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
const it = test.skipIf(process.platform !== "linux");
const CAPINFOS_OUTPUT = [
"File name: /work/capture.pcap",
"File type: Wireshark/tcpdump/... - pcap",
"File encapsulation: Ethernet",
"Packet size limit: file hdr: 262144 bytes",
"Number of packets: 3",
"Capture duration: 0.123 seconds",
].join("\n");
const TSHARK_JSON = JSON.stringify([
{
_source: {
layers: {
"frame.number": ["1"],
"frame.protocols": ["eth:ethertype:ip:udp:dns"],
"ip.src": ["192.168.1.100"],
"ip.dst": ["8.8.8.8"],
"dns.qry.name": ["api.example.com"],
"dns.flags.response": ["1"],
"dns.a": ["93.184.216.34"],
},
},
},
{
_source: {
layers: {
"frame.number": ["2"],
"frame.protocols": ["eth:ethertype:ip:tcp:tls"],
"ip.src": ["192.168.1.100"],
"ip.dst": ["93.184.216.34"],
"tls.handshake.extensions_server_name": ["api.example.com"],
},
},
},
{
_source: {
layers: {
"frame.number": ["3"],
"frame.protocols": ["eth:ethertype:ip:tcp:http"],
"ip.src": ["192.168.1.100"],
"ip.dst": ["93.184.216.34"],
"http.request.full_uri": ["http://api.example.com/v1/data"],
},
},
},
]);
const writeStubTools = async (
root: string,
options: { readonly failCapinfos?: boolean } = {},
): Promise<{ tshark: string; capinfos: string }> => {
const tshark = join(root, "tshark");
const capinfos = join(root, "capinfos");
await writeFile(
tshark,
`#!/bin/sh
if [ "$1" = "--version" ]; then printf 'TShark (Wireshark) 4.6.6-stub\\n'; exit 0; fi
[ "$1" = "-n" ] && [ "$2" = "-r" ] && [ "$4" = "-T" ] && [ "$5" = "json" ] || exit 2
printf '%s' '${TSHARK_JSON.replace(/'/gu, "'\\''")}'
exit 0
`,
{ mode: 0o755 },
);
await writeFile(
capinfos,
`#!/bin/sh
if [ "$1" = "--version" ]; then printf 'Capinfos (Wireshark) 4.6.6-stub\\n'; exit 0; fi
[ "$1" = "-M" ] && [ "$2" = "--" ] && [ "$#" = "3" ] || exit 2
if [ "${String(options.failCapinfos === true)}" = "true" ]; then
printf 'capinfos: The file is not a capture file' >&2; exit 1
fi
printf '%s\\n' '${CAPINFOS_OUTPUT.replace(/'/gu, "'\\''")}'
exit 0
`,
{ mode: 0o755 },
);
await chmod(tshark, 0o755);
await chmod(capinfos, 0o755);
return { tshark, capinfos };
};
const providerWith = async (options: { failCapinfos?: boolean } = {}) => {
const root = await createTestTempDirectory("rea-pcap-");
const { tshark, capinfos } = await writeStubTools(root, options);
const provider = new PcapProvider({
environment: { REA_TSHARK_COMMAND: tshark, REA_CAPINFOS_COMMAND: capinfos },
});
onTestFinished(() => provider.close());
return {
provider,
root,
};
};
const evidenceResult = async (
provider: PcapProvider,
request: PcapRequest,
): Promise<Record<string, unknown>> => {
const result = await provider.execute(request);
if (!result.ok) throw new Error(result.error.message);
return pcapResultSchemas[request.operation].parse(result.value.result);
};
const captureFixture = async (root: string): Promise<string> => {
const path = join(root, "capture.pcap");
await writeFile(path, Buffer.from("pcap-fixture-bytes"));
return path;
};
it("inspects both tools without reading a capture", async () => {
const { provider } = await providerWith();
const result = await evidenceResult(provider, {
operation: "inspect_pcap_client",
input: {},
});
expect(result.client).toMatchObject({
tshark_source: "environment",
tshark_version: "TShark (Wireshark) 4.6.6-stub",
capinfos_source: "environment",
capinfos_version: "Capinfos (Wireshark) 4.6.6-stub",
});
});
it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => {
const { provider, root } = await providerWith();
const path = await captureFixture(root);
const result = await evidenceResult(provider, {
operation: "inspect_pcap_capture",
input: { path },
});
expect(result.target).toMatchObject({
path,
bytes: 18,
sha256: createHash("sha256").update("pcap-fixture-bytes").digest("hex"),
});
expect(result.file_type).toBe("Wireshark/tcpdump/... - pcap");
expect(result.packet_count).toBe(3);
expect(result.endpoints).toEqual(
expect.arrayContaining([
{ address: "192.168.1.100", port: null, packet_count: 3 },
{ address: "8.8.8.8", port: null, packet_count: 1 },
]),
);
expect(result.dns_queries).toEqual([
{
query: "api.example.com",
response_addresses: ["93.184.216.34"],
response_address_count: 1,
},
]);
expect(result.tls_sni_values).toEqual(["api.example.com"]);
expect(result.http_request_uris).toEqual(["http://api.example.com/v1/data"]);
expect(result.coverage).toBe("complete");
});
it("reports a non-capture file as unsupported", async () => {
const { provider, root } = await providerWith({ failCapinfos: true });
const failPath = join(root, "fail.pcap");
await writeFile(failPath, "not a capture file");
const result = await provider.execute({
operation: "inspect_pcap_capture",
input: { path: failPath },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unsupported_target",
});
});
it("reports a missing tshark binary as a capability gap", async () => {
const provider = new PcapProvider({
environment: { REA_TSHARK_COMMAND: "/nonexistent/tshark" },
});
expect(await provider.inspectAvailability()).toMatchObject({
status: "unavailable",
code: "executable_missing",
});
});
it("rejects a capture that is not a readable file", async () => {
const { provider } = await providerWith();
const result = await provider.execute({
operation: "inspect_pcap_capture",
input: { path: "/nonexistent/capture.pcap" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
});
});
it("cancels before spawning anything", async () => {
const { provider, root } = await providerWith();
await writeFile(join(root, "spawned"), "");
const controller = new AbortController();
controller.abort();
const result = await provider.execute(
{
operation: "inspect_pcap_capture",
input: { path: "/some/capture.pcap" },
},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
});
});
it("reports relative configuration and missing capinfos as capability gaps", async () => {
const root = await createTestTempDirectory("rea-pcap-config-");
const { tshark } = await writeStubTools(root);
for (const environment of [
{ REA_TSHARK_COMMAND: "relative/tshark" },
{
REA_TSHARK_COMMAND: tshark,
REA_CAPINFOS_COMMAND: join(root, "missing-capinfos"),
},
]) {
const provider = new PcapProvider({ environment });
onTestFinished(() => provider.close());
expect((await provider.inspectAvailability()).status).toBe("unavailable");
const result = await provider.execute({
operation: "inspect_pcap_client",
input: {},
});
if (result.ok) throw new Error("expected unavailable tools");
expect(projectAnalysisError(result.error).code).toBe(
"capability_unavailable",
);
}
});
const nodeTool = async (
root: string,
name: string,
body: string,
): Promise<string> => {
const path = join(root, name);
await writeFile(
path,
`#!/usr/bin/env -S ${process.execPath} --jitless --max-old-space-size=64\nconst fs = require('node:fs'); const crypto = require('node:crypto');\n${body}\n`,
{ mode: 0o755 },
);
return path;
};
const versionProbe =
"if (process.argv[2] === '--version') { console.log((process.argv[1].includes('capinfos') ? 'Capinfos' : 'TShark') + ' (Wireshark) 4.6.6-test'); process.exit(0); }";
it("refuses observations whose owned snapshot changed during dissection", async () => {
const root = await createTestTempDirectory("rea-pcap-mutated-");
const { capinfos } = await writeStubTools(root);
const path = await captureFixture(root);
const before = await readFile(path);
const tshark = await nodeTool(
root,
"tshark.cjs",
`${versionProbe}
const selected = process.argv[process.argv.indexOf('-r') + 1];
fs.chmodSync(selected, 0o600); fs.writeFileSync(selected, 'changed private snapshot');
console.log(${JSON.stringify(TSHARK_JSON)});`,
);
const provider = new PcapProvider({
environment: { REA_TSHARK_COMMAND: tshark, REA_CAPINFOS_COMMAND: capinfos },
});
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "inspect_pcap_capture",
input: { path },
});
expect(result.ok).toBe(false);
if (!result.ok)
expect(projectAnalysisError(result.error)).toMatchObject({
code: "artifact_changed",
});
expect(await readFile(path)).toEqual(before);
});
it("binds both tools to the snapshot when the original capture changes", async () => {
const root = await createTestTempDirectory("rea-pcap-identity-");
const path = join(root, "caller capture.pcap");
await writeFile(path, "192.0.2.1");
const capinfos = await nodeTool(
root,
"capinfos.cjs",
`${versionProbe}
const selected = process.argv.at(-1); const bytes = fs.readFileSync(selected);
console.log('File type: pcap\\nFile encapsulation: Raw IP\\nNumber of packets: 1\\nSHA256: ' + crypto.createHash('sha256').update(bytes).digest('hex'));
fs.writeFileSync(${JSON.stringify(path)}, '192.0.2.2');`,
);
const tshark = await nodeTool(
root,
"tshark.cjs",
`${versionProbe}
const selected = process.argv[process.argv.indexOf('-r') + 1];
console.log(JSON.stringify([{_source:{layers:{'frame.number':['1'],'frame.protocols':['ip'],'ip.src':[fs.readFileSync(selected,'utf8')]}}}]));`,
);
const provider = new PcapProvider({
environment: { REA_TSHARK_COMMAND: tshark, REA_CAPINFOS_COMMAND: capinfos },
});
onTestFinished(() => provider.close());
const result = await provider.execute({
operation: "inspect_pcap_capture",
input: { path },
});
if (!result.ok) throw result.error;
const value = pcapResultSchemas.inspect_pcap_capture.parse(
result.value.result,
);
expect(value.target.sha256).toBe(
createHash("sha256").update("192.0.2.1").digest("hex"),
);
expect(value.endpoints).toEqual([
{ address: "192.0.2.1", port: null, packet_count: 1 },
]);
expect(await readFile(path, "utf8")).toBe("192.0.2.2");
});
it.each(["probe", "capinfos", "tshark"] as const)(
"preserves in-flight %s cancellation and cleans up",
async (phase) => {
const root = await createTestTempDirectory("rea-pcap-cancel-");
const marker = join(root, "ready");
const slow = await nodeTool(
root,
`slow-${phase}.cjs`,
`${phase !== "probe" ? versionProbe : ""}
fs.writeFileSync(${JSON.stringify(marker)}, 'ready'); setInterval(() => {}, 1000);`,
);
const { capinfos, tshark } = await writeStubTools(root);
const path = await captureFixture(root);
const provider = new PcapProvider({
environment: {
REA_TSHARK_COMMAND: phase === "capinfos" ? tshark : slow,
REA_CAPINFOS_COMMAND: phase === "capinfos" ? slow : capinfos,
},
});
onTestFinished(() => provider.close());
const controller = new AbortController();
let watcher: ReturnType<typeof watch> | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const ready = new Promise<void>((resolve, reject) => {
watcher = watch(root, (_event, name) => {
if (name === "ready") resolve();
});
timer = setTimeout(() => {
controller.abort();
reject(new Error("tool did not start"));
}, 5000);
});
try {
const pending = provider.execute(
{ operation: "inspect_pcap_capture", input: { path } },
{ signal: controller.signal },
);
await ready;
controller.abort(new Error("caller cancellation"));
const result = await pending;
if (result.ok) throw new Error("expected cancellation");
expect(projectAnalysisError(result.error).code).toBe("cancelled");
await provider.close();
} finally {
watcher?.close();
clearTimeout(timer);
}
},
);
it.each(["change", "cancel"] as const)(
"preserves %s during snapshot acquisition",
async (action) => {
const { provider, root } = await providerWith();
const path = await captureFixture(root);
const controller = new AbortController();
const held = await holdFirstFileRead(path);
onTestFinished(() => {
vi.restoreAllMocks();
});
const pending = provider.execute(
{ operation: "inspect_pcap_capture", input: { path } },
{ signal: controller.signal },
);
try {
await held.firstRead;
if (action === "cancel") controller.abort();
else await writeFile(path, "changed source bytes");
} finally {
held.release();
}
const result = await pending;
if (result.ok) throw new Error("expected interrupted acquisition");
expect(result.error._tag).toBe(
action === "cancel"
? "AnalysisCancelledError"
: "AnalysisArtifactChangedError",
);
},
);
it("admits one capture and drains it before close releases resources", async () => {
const { provider, root } = await providerWith();
const path = await captureFixture(root);
const held = await holdFirstFileRead(path);
onTestFinished(() => {
vi.restoreAllMocks();
});
const pending = provider.execute({
operation: "inspect_pcap_capture",
input: { path },
});
let closing: Promise<void> | undefined;
try {
await held.firstRead;
const concurrent = await provider.execute({
operation: "inspect_pcap_capture",
input: { path },
});
expect(concurrent.ok).toBe(false);
if (!concurrent.ok)
expect(projectAnalysisError(concurrent.error)).toMatchObject({
code: "resource_constraint",
details: { resource: "capacity" },
});
let closed = false;
closing = provider.close().then(() => {
closed = true;
});
await Promise.resolve();
expect(closed).toBe(false);
} finally {
held.release();
}
expect((await pending).ok).toBe(true);
await closing;
const afterClose = await provider.execute({
operation: "inspect_pcap_capture",
input: { path },
});
expect(afterClose.ok).toBe(false);
});
it.each([false, true])(
"retains failed snapshot cleanup after an analysis failure=%s",
async (failCapinfos) => {
const { provider, root } = await providerWith({ failCapinfos });
const path = await captureFixture(root);
let retained: PrivateRuntimeRoot | undefined;
const spy = vi
.spyOn(PrivateRuntimeRoot.prototype, "close")
.mockImplementation(function (this: PrivateRuntimeRoot) {
retained = this;
return Promise.reject(new Error("controlled cleanup failure"));
});
const result = await provider.execute({
operation: "inspect_pcap_capture",
input: { path },
});
try {
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cleanup failure cannot return success");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
});
if (failCapinfos)
expect(projectAnalysisError(result.error)).toMatchObject({
details: {
diagnostics: { primary_error: { code: "unsupported_target" } },
},
});
else
expect(result.error.partialObservation).toMatchObject({
operation: "inspect_pcap_capture",
normalized_result: { packet_count: 3 },
});
expect(retained).toBeDefined();
await expect(provider.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
} finally {
spy.mockRestore();
}
await provider.close();
await expect(
readFile(join(retained!.path, "capture.pcap")),
).rejects.toMatchObject({ code: "ENOENT" });
},
);
@@ -0,0 +1,144 @@
/**
* Cross-platform pcap provider tests.
*
* POSIX hosts run the #!/bin/sh fixtures;
* Windows runs the .cmd stubs from tests/fixtures/pcap/. Both validate
* the same provider contract surface: tool probing, capture inspection,
* refusal paths, and cancellation before spawn.
*/
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { PcapProvider } from "../../../src/pcap/PcapProvider.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import type { PcapRequest } from "../../../src/domain/pcap/pcapAnalysis.js";
const isWindows = process.platform === "win32";
const hasNativeBound = process.platform === "linux" || isWindows;
const stubDir = join(import.meta.dirname, "../../fixtures/pcap");
const tsharkStub = isWindows
? join(stubDir, "tshark-stub.cmd")
: join(stubDir, "tshark-stub.sh");
const capinfosStub = isWindows
? join(stubDir, "capinfos-stub.cmd")
: join(stubDir, "capinfos-stub.sh");
const providerOver = (): PcapProvider => {
const provider = new PcapProvider({
environment: {
REA_TSHARK_COMMAND: tsharkStub,
REA_CAPINFOS_COMMAND: capinfosStub,
},
});
onTestFinished(() => provider.close());
return provider;
};
const evidenceResult = async (
provider: PcapProvider,
request: PcapRequest,
): Promise<Record<string, unknown>> => {
const result = await provider.execute(request);
if (!result.ok) throw new Error(result.error.message);
return result.value.result as Record<string, unknown>;
};
it("inspects both tools through cross-platform stubs", async () => {
const result = await evidenceResult(providerOver(), {
operation: "inspect_pcap_client",
input: {},
});
expect(result.client).toMatchObject({
tshark_source: "environment",
tshark_version: expect.stringContaining("4.6.6") as unknown,
capinfos_source: "environment",
capinfos_version: expect.stringContaining("4.6.6") as unknown,
});
});
it.runIf(hasNativeBound)(
"inspects a capture with metadata, endpoints, DNS, and protocol facts",
async () => {
const root = await createTestTempDirectory("rea-pcap-x-");
const capturePath = join(root, "capture.pcap");
const captureBytes = "cross-platform-pcap-fixture";
await writeFile(capturePath, captureBytes);
const result = await evidenceResult(providerOver(), {
operation: "inspect_pcap_capture",
input: { path: capturePath },
});
expect(result.target).toMatchObject({
path: capturePath,
bytes: captureBytes.length,
sha256: createHash("sha256").update(captureBytes).digest("hex"),
});
expect(result.file_type).toContain("pcap");
expect(result.packet_count).toBe(3);
expect((result.endpoints as unknown[]).length).toBeGreaterThan(0);
expect((result.protocol_distribution as unknown[]).length).toBeGreaterThan(
0,
);
expect(result.coverage).toBe("complete");
},
);
it("rejects a path that is not a readable file", async () => {
const result = await providerOver().execute({
operation: "inspect_pcap_capture",
input: { path: "/nonexistent/definitely-missing.pcap" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
});
});
it("reports a missing binary as a capability gap", async () => {
const provider = new PcapProvider({
environment: { REA_TSHARK_COMMAND: "/nonexistent/tshark" },
});
expect(await provider.inspectAvailability()).toMatchObject({
status: "unavailable",
code: "executable_missing",
});
});
it("cancels before spawning anything", async () => {
const controller = new AbortController();
controller.abort();
const result = await providerOver().execute(
{ operation: "inspect_pcap_capture", input: { path: "/any/capture.pcap" } },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
});
});
it.runIf(!hasNativeBound)(
"reports an unsupported native memory boundary before reading the capture",
async () => {
const provider = providerOver();
expect(await provider.inspectAvailability()).toMatchObject({
status: "unavailable",
code: "unsupported_host",
});
const result = await provider.execute({
operation: "inspect_pcap_capture",
input: { path: "/not-opened/capture.pcap" },
});
expect(result.ok).toBe(false);
if (!result.ok)
expect(projectAnalysisError(result.error)).toMatchObject({
code: "capability_unavailable",
});
},
);
@@ -0,0 +1,196 @@
import { createHash } from "node:crypto";
import { readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
const createPcapToolFixture = async (root: string) => {
const path = join(root, "caller capture.pcap");
const bytes = Buffer.from("recording fixture bytes");
const sha = createHash("sha256").update(bytes).digest("hex");
await writeFile(path, bytes);
const tshark = join(root, "tshark");
const capinfos = join(root, "capinfos");
const marker = join(root, "reads");
const frames = Array.from({ length: 132 }, (_, index) => ({
_source: {
layers: {
"frame.number": [String(index + 1)],
"frame.protocols": ["ip:udp:dns"],
"ip.src": ["192.0.2.1"],
"ip.dst": ["192.0.2.2"],
"dns.qry.name": ["many.example"],
"dns.flags.response": ["True"],
"dns.a": [`198.51.100.${index}`],
"tls.handshake.extensions_server_name": [`host${index}.example`],
"http.request.full_uri": [
"http://user:password@example.invalid/p?q=keep",
],
},
},
}));
await writeFile(
tshark,
`#!/bin/sh\nif [ "$1" = "--version" ]; then printf 'TShark (Wireshark) 4.6.6-emulated\\n'; exit 0; fi\nprintf 'read\\n' >> ${quote(marker)}\nprintf '%s' ${quote(JSON.stringify(frames))}\nprintf 'retained warning' >&2\n`,
{ mode: 0o755 },
);
await writeFile(
capinfos,
`#!/bin/sh\nif [ "$1" = "--version" ]; then printf 'Capinfos (Wireshark) 4.6.6-emulated\\n'; exit 0; fi\nprintf '%s\\n' ${quote(`File type: pcap\nFile encapsulation: Ethernet\nNumber of packets: 132\nSHA256: ${sha}`)}\n`,
{ mode: 0o755 },
);
const environment = {
REA_TSHARK_COMMAND: tshark,
REA_CAPINFOS_COMMAND: capinfos,
};
return { path, marker, environment };
};
// Native tools are emulated here; this proves the production CLI/MCP boundary,
// retention and paging, not Wireshark's real dissector semantics.
it.runIf(process.platform === "linux")(
"retains every fact and pages late DNS/SNI rows after source deletion without another tool launch",
async () => {
const root = await createTestTempDirectory("rea-pcap-view-");
const { path, marker, environment } = await createPcapToolFixture(root);
const complete = await promisify(execFile)(
process.execPath,
[
join(import.meta.dirname, "../../../scripts/rea.mjs"),
"inspect-pcap-capture",
path,
"--json",
],
{ env: { ...process.env, ...environment } },
);
const parent = parseEvidence(JSON.parse(complete.stdout));
expect(parent.normalized_result).toMatchObject({
tls_sni_count: 132,
tls_sni_values: expect.arrayContaining(["host131.example"]),
dns_queries: [{ query: "many.example", response_address_count: 132 }],
});
expect(JSON.stringify(parent)).not.toContain("user:password");
const { call } = await connectLocalToolsMcp(environment);
const response = await call("inspect_pcap_capture", {
path,
detail: "summary",
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const summary = parseEvidence(response.structuredContent).normalized_result;
expect(summary).toMatchObject({
parent_evidence_id: parent.evidence_id,
summary: {
tls_sni_count: 132,
dns_query_count: 1,
retained_counts: { tls_sni_values: 132 },
diagnostics_code_units: { tshark: 16, capinfos: 0 },
},
});
expect(JSON.stringify(summary)).not.toContain("host131.example");
await rm(path);
const source = {
kind: "retained-evidence",
evidence_id: parent.evidence_id,
};
const before = await readFile(marker, "utf8");
const cases = [
[
{ kind: "pcap", facet: "endpoints", limit: 1 },
{ items: [{ address: "192.0.2.1", packet_count: 132 }] },
],
[
{ kind: "pcap", facet: "dns-queries", limit: 1 },
{
items: [
{ query: "many.example", retained_response_address_count: 132 },
],
},
],
[
{
kind: "pcap",
facet: "dns-addresses",
query: "many.example",
offset: 130,
limit: 2,
},
{ items: ["198.51.100.130", "198.51.100.131"] },
],
[
{ kind: "pcap", facet: "tls-sni", offset: 130, limit: 2 },
{ items: ["host130.example", "host131.example"] },
],
[
{ kind: "pcap", facet: "protocols" },
{ items: [{ protocol: "ip:udp:dns", packet_count: 132 }] },
],
[
{ kind: "pcap", facet: "http-uris" },
{ items: ["http://example.invalid/p?q=keep"] },
],
[{ kind: "pcap", facet: "unattributed-addresses" }, { items: [] }],
[
{
kind: "pcap",
facet: "diagnostics",
stream: "tshark",
offset: 9,
limit: 7,
},
{ text: "warning", status: "retained" },
],
] as const;
for (const [view, expected] of cases) {
const response = await call("inspect_analysis_view", { source, view });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(
parseEvidence(response.structuredContent).normalized_result,
).toMatchObject({ page: expected, source_coverage: "complete" });
}
const workflow = join(root, "portable.json");
const view = {
kind: "pcap",
facet: "dns-addresses",
query: "many.example",
offset: 130,
limit: 2,
};
await writeFile(
workflow,
JSON.stringify({ source: { kind: "inline", evidence: parent }, view }),
);
const portable = await promisify(execFile)(process.execPath, [
join(import.meta.dirname, "../../../scripts/rea.mjs"),
"inspect-analysis-view",
workflow,
"--json",
]);
const retained = parseEvidence(
(await call("inspect_analysis_view", { source, view })).structuredContent,
);
expect(
parseEvidence(JSON.parse(portable.stdout)).normalized_result,
).toEqual(retained.normalized_result);
expect(await readFile(marker, "utf8")).toBe(before);
expect(
parseMcpToolError(
await call("inspect_analysis_view", {
source,
view: {
kind: "pcap",
facet: "dns-addresses",
query: "missing.example",
},
}),
),
).toMatchObject({ error: { code: "invalid_request" } });
},
60_000,
);
@@ -26,6 +26,7 @@ import {
import {
createDarwinProcessRunTokenReader,
DarwinProcessOwnershipInspectionError,
SWIFTC_INTERRUPT,
} from "../../../src/process/DarwinProcessRunTokenReader.js";
import { execFileOutput } from "../../../src/process/ExecFileOutput.js";
import {
@@ -1110,16 +1111,8 @@ it.skipIf(!onDarwin)(
try {
await execFileOutput(
"/usr/bin/xcrun",
[
"swiftc",
"-module-cache-path",
join(root, "modules"),
core,
fixture,
"-o",
executable,
],
{ timeout: 60_000 },
["swiftc", core, fixture, "-o", executable],
{ timeout: 120_000, stopSignal: SWIFTC_INTERRUPT },
);
const { stdout } = await execFileAsync(executable, [], {
encoding: "utf8",
@@ -1179,6 +1172,7 @@ it.skipIf(!onDarwin)(
await rm(root, { recursive: true, force: true });
}
},
150_000,
);
it.skipIf(!onDarwin)(
@@ -1203,16 +1197,8 @@ it.skipIf(!onDarwin)(
try {
await execFileOutput(
"/usr/bin/xcrun",
[
"swiftc",
"-module-cache-path",
join(root, "modules"),
core,
fixture,
"-o",
executable,
],
{ timeout: 60_000 },
["swiftc", core, fixture, "-o", executable],
{ timeout: 120_000, stopSignal: SWIFTC_INTERRUPT },
);
const { stdout } = await execFileAsync(executable, [], {
encoding: "utf8",
@@ -1226,6 +1212,7 @@ it.skipIf(!onDarwin)(
await rm(root, { recursive: true, force: true });
}
},
150_000,
);
it.skipIf(!onDarwin)(
+4 -1
View File
@@ -34,7 +34,9 @@ const compileValidator = (schema: unknown): ValidateFunction<unknown> => {
};
/** Start the built production server without configuring deep binary providers. */
export async function connectLocalToolsMcp() {
export async function connectLocalToolsMcp(
environment: Readonly<Record<string, string>> = {},
) {
const transport = new StdioClientTransport({
command: process.execPath,
args: [
@@ -45,6 +47,7 @@ export async function connectLocalToolsMcp() {
PATH: process.env.PATH ?? "/usr/bin:/bin",
REA_LOG_LEVEL: "silent",
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
...environment,
},
stderr: "pipe",
});
+19
View File
@@ -0,0 +1,19 @@
@echo off
rem capinfos stub for Windows: emulates capinfos --version and metadata output.
if "%~1"=="--version" (
echo Capinfos ^(Wireshark^) 4.6.6-windows-stub
exit /b 0
)
if not "%~1"=="-M" exit /b 2
if not "%~2"=="--" exit /b 2
if "%~3"=="" exit /b 2
echo File name: capture.pcap
echo File type: Wireshark/tcpdump/... - pcap
echo File encapsulation: Ethernet
echo Packet size limit: file hdr: 262144 bytes
echo Number of packets: 3
echo Capture duration: 0.123 seconds
exit /b 0
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# POSIX capinfos stub (mirrors capinfos-stub.cmd for Windows parity).
if [ "$1" = "--version" ]; then printf 'Capinfos (Wireshark) 4.6.6-posix-stub\n'; exit 0; fi
[ "$1" = "-M" ] && [ "$2" = "--" ] && [ "$#" = "3" ] || exit 2
printf '%s\n' 'File name: capture.pcap'
printf '%s\n' 'File type: Wireshark/tcpdump/... - pcap'
printf '%s\n' 'File encapsulation: Ethernet'
printf '%s\n' 'Packet size limit: file hdr: 262144 bytes'
printf '%s\n' 'Number of packets: 3'
printf '%s\n' 'Capture duration: 0.123 seconds'
exit 0
+17
View File
@@ -0,0 +1,17 @@
@echo off
rem Pcap tool stub for Windows: emulates tshark --version and field output.
rem %1 is the first argument; the capture path arrives as %2 for -r/-c.
if "%~1"=="--version" (
echo TShark ^(Wireshark^) 4.6.6-windows-stub
exit /b 0
)
if not "%~1"=="-n" exit /b 2
if not "%~2"=="-r" exit /b 2
if not "%~4"=="-T" exit /b 2
if not "%~5"=="json" exit /b 2
rem Emit the same JSON as the POSIX stub
echo [{"_source":{"layers":{"frame.number":["1"],"frame.protocols":["eth:ethertype:ip:udp:dns"],"ip.src":["192.168.1.100"],"ip.dst":["8.8.8.8"],"dns.qry.name":["api.example.com"],"dns.flags.response":["1"],"dns.a":["93.184.216.34"]}}},{"_source":{"layers":{"frame.number":["2"],"frame.protocols":["eth:ethertype:ip:tcp:tls"],"ip.src":["192.168.1.100"],"ip.dst":["93.184.216.34"],"tls.handshake.extensions_server_name":["api.example.com"]}}},{"_source":{"layers":{"frame.number":["3"],"frame.protocols":["eth:ethertype:ip:tcp:http"],"ip.src":["192.168.1.100"],"ip.dst":["93.184.216.34"],"http.request.full_uri":["http://api.example.com/v1/data"]}}}]
exit /b 0
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# POSIX pcap tool stub (mirrors tshark-stub.cmd for Windows parity).
if [ "$1" = "--version" ]; then printf 'TShark (Wireshark) 4.6.6-posix-stub\n'; exit 0; fi
[ "$1" = "-n" ] && [ "$2" = "-r" ] && [ "$4" = "-T" ] && [ "$5" = "json" ] || exit 2
printf '%s' '[{"_source":{"layers":{"frame.number":["1"],"frame.protocols":["eth:ethertype:ip:udp:dns"],"ip.src":["192.168.1.100"],"ip.dst":["8.8.8.8"],"dns.qry.name":["api.example.com"],"dns.flags.response":["1"],"dns.a":["93.184.216.34"]}}},{"_source":{"layers":{"frame.number":["2"],"frame.protocols":["eth:ethertype:ip:tcp:tls"],"ip.src":["192.168.1.100"],"ip.dst":["93.184.216.34"],"tls.handshake.extensions_server_name":["api.example.com"]}}},{"_source":{"layers":{"frame.number":["3"],"frame.protocols":["eth:ethertype:ip:tcp:http"],"ip.src":["192.168.1.100"],"ip.dst":["93.184.216.34"],"http.request.full_uri":["http://api.example.com/v1/data"]}}}]'
exit 0
+1 -1
View File
@@ -51,7 +51,7 @@ const TEST_PROJECTS = [
name: "adapters",
include: [
"src/*.test.ts",
"src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts",
"src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts",
],
pool: "forks" as const,
maxWorkers: MAX_TEST_WORKERS,