mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(firmware): integrate Binwalk and Unblob analysis
* feat(firmware): integrate Binwalk and Unblob analysis * fix(firmware): verify published files and namespace source references * test(firmware): isolate extractor dependency failure from process cleanup * fix(firmware): retain actionable report and cleanup diagnostics * test: assert retained batch output failure diagnostics
This commit is contained in:
1 parent
8d7071da4c
commit
5a7c769692
58 files changed
+3230
-16
No files matched your search
@@ -1,3 +1,9 @@
|
|||||||
[submodule "third_party/jadx-headless-mcp"]
|
[submodule "third_party/jadx-headless-mcp"]
|
||||||
path = third_party/jadx-headless-mcp
|
path = third_party/jadx-headless-mcp
|
||||||
url = https://github.com/1013503897/jadx-headless-mcp.git
|
url = https://github.com/1013503897/jadx-headless-mcp.git
|
||||||
|
[submodule "third_party/binwalk"]
|
||||||
|
path = third_party/binwalk
|
||||||
|
url = https://github.com/ReFirmLabs/binwalk.git
|
||||||
|
[submodule "third_party/unblob"]
|
||||||
|
path = third_party/unblob
|
||||||
|
url = https://github.com/onekey-sec/unblob.git
|
||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
".codex/**",
|
".codex/**",
|
||||||
".devin/**",
|
".devin/**",
|
||||||
"tests/fixtures/**",
|
"tests/fixtures/**",
|
||||||
"third_party/jadx-headless-mcp/**",
|
"third_party/**",
|
||||||
"tests/conformance/**/*.c",
|
"tests/conformance/**/*.c",
|
||||||
"tests/conformance/**/*.m",
|
"tests/conformance/**/*.m",
|
||||||
"tests/conformance/**/*.swift",
|
"tests/conformance/**/*.swift",
|
||||||
|
|||||||
+1
-1
@@ -11,6 +11,6 @@
|
|||||||
"CHANGELOG.md",
|
"CHANGELOG.md",
|
||||||
"package-lock.json",
|
"package-lock.json",
|
||||||
"_reference/**",
|
"_reference/**",
|
||||||
"third_party/jadx-headless-mcp/**"
|
"third_party/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+1
-1
@@ -140,6 +140,6 @@
|
|||||||
".codex",
|
".codex",
|
||||||
".devin",
|
".devin",
|
||||||
".factory",
|
".factory",
|
||||||
"third_party/jadx-headless-mcp"
|
"third_party"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -151,6 +151,8 @@ Update either installation with `rea update`.
|
|||||||
|
|
||||||
Native binary analysis requires [Hopper](https://www.hopperapp.com/) or [Ghidra](#ghidra-analysis-provider). Hopper is separate software with its own license; its demo supports analysis with vendor-defined limits. REA can use Ghidra that you have already installed.
|
Native binary analysis requires [Hopper](https://www.hopperapp.com/) or [Ghidra](#ghidra-analysis-provider). Hopper is separate software with its own license; its demo supports analysis with vendor-defined limits. REA can use Ghidra that you have already installed.
|
||||||
|
|
||||||
|
Firmware region inspection and explicit extraction use caller-supplied Binwalk and Unblob on Linux. See [Firmware analysis](docs/firmware-analysis.md) for setup, provenance, resource limits and native handoff.
|
||||||
|
|
||||||
Static APK analysis uses a separately supplied headless JADX JAR and Java, with no emulator or APK execution. See [Android analysis](docs/android-analysis.md) for setup, CLI/MCP operations, coverage and public test fixtures.
|
Static APK analysis uses a separately supplied headless JADX JAR and Java, with no emulator or APK execution. See [Android analysis](docs/android-analysis.md) for setup, CLI/MCP operations, coverage and public test fixtures.
|
||||||
|
|
||||||
Windows Ghidra support is experimental for native x86-64 PE applications on local NTFS. The package bundles native Job Object, private-DACL, and path-admission controls. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for prerequisites and verified scope.
|
Windows Ghidra support is experimental for native x86-64 PE applications on local NTFS. The package bundles native Job Object, private-DACL, and path-admission controls. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for prerequisites and verified scope.
|
||||||
@@ -316,6 +318,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
|||||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||||
|
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||||
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
||||||
@@ -468,6 +471,8 @@ flowchart LR
|
|||||||
REA --> Browser["Browser CDP provider"]
|
REA --> Browser["Browser CDP provider"]
|
||||||
REA --> Android["Android static provider<br/>headless JADX adapter"]
|
REA --> Android["Android static provider<br/>headless JADX adapter"]
|
||||||
Android --> Runtime
|
Android --> Runtime
|
||||||
|
REA --> Firmware["Firmware providers<br/>Binwalk / Unblob adapters"]
|
||||||
|
Firmware --> Runtime
|
||||||
REA --> Process["Process capture provider"]
|
REA --> Process["Process capture provider"]
|
||||||
Runtime --> Target["Target software"]
|
Runtime --> Target["Target software"]
|
||||||
Process --> Target
|
Process --> Target
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ rea setup
|
|||||||
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift |
|
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift |
|
||||||
| الملفات والحزم | 5 | فحص الدلائل والحزم وInterface Builder وموارد Apple والاستخراج |
|
| الملفات والحزم | 5 | فحص الدلائل والحزم وInterface Builder وموارد Apple والاستخراج |
|
||||||
| .NET PE/CLI | 7 | هوية التجميع والبيانات الوصفية وCIL والاستدعاءات الأصلية والمقارنة واستيراد إعادة البناء |
|
| .NET PE/CLI | 7 | هوية التجميع والبيانات الوصفية وCIL والاستدعاءات الأصلية والمقارنة واستيراد إعادة البناء |
|
||||||
|
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة واستخراجها الصريح على Linux |
|
||||||
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف والأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف والأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||||
| مراقبة المتصفح | 9 | الصفحات والبرامج النصية وخرائط المصدر وWebMCP والصور ومقارنة الالتقاط |
|
| مراقبة المتصفح | 9 | الصفحات والبرامج النصية وخرائط المصدر وWebMCP والصور ومقارنة الالتقاط |
|
||||||
| تحليل Electron | 5 | مراقبة الصفحات وبنية التطبيق وربط النتائج الثابتة بنتائج التشغيل |
|
| تحليل Electron | 5 | مراقبة الصفحات وبنية التطبيق وربط النتائج الثابتة بنتائج التشغيل |
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
|||||||
| macOS ネイティブ | 7 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
|
| macOS ネイティブ | 7 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
|
||||||
| ファイルとパッケージ | 5 | ディレクトリとパッケージ、Interface Builder、Apple リソース、抽出 |
|
| ファイルとパッケージ | 5 | ディレクトリとパッケージ、Interface Builder、Apple リソース、抽出 |
|
||||||
| .NET PE/CLI | 7 | アセンブリ識別、メタデータ、CIL、ネイティブ呼び出し、比較、再構築の取り込み |
|
| .NET PE/CLI | 7 | アセンブリ識別、メタデータ、CIL、ネイティブ呼び出し、比較、再構築の取り込み |
|
||||||
|
| ファームウェア | 2 | Linux の領域検査と明示的な展開 |
|
||||||
| Android APK | 5 | パッケージと manifest、クラス検索、メンバー一覧、メソッド逆コンパイル、静的参照 |
|
| Android APK | 5 | パッケージと manifest、クラス検索、メンバー一覧、メソッド逆コンパイル、静的参照 |
|
||||||
| ブラウザー観察 | 9 | ページ、スクリプト、ソースマップ、WebMCP、スクリーンショット、比較 |
|
| ブラウザー観察 | 9 | ページ、スクリプト、ソースマップ、WebMCP、スクリーンショット、比較 |
|
||||||
| Electron 解析 | 5 | ページ観察、アプリ構造、静的・実行時結果の対応付け |
|
| Electron 解析 | 5 | ページ観察、アプリ構造、静的・実行時結果の対応付け |
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
|||||||
| macOS 네이티브 | 7 | Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
|
| macOS 네이티브 | 7 | Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
|
||||||
| 파일과 패키지 | 5 | 디렉터리와 패키지 검사, Interface Builder, Apple 리소스, 추출 |
|
| 파일과 패키지 | 5 | 디렉터리와 패키지 검사, Interface Builder, Apple 리소스, 추출 |
|
||||||
| .NET PE/CLI | 7 | 어셈블리 식별, 메타데이터, CIL, 네이티브 호출, 비교, 재구성 가져오기 |
|
| .NET PE/CLI | 7 | 어셈블리 식별, 메타데이터, CIL, 네이티브 호출, 비교, 재구성 가져오기 |
|
||||||
|
| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
|
||||||
| Android APK | 5 | 패키지와 manifest, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
|
| Android APK | 5 | 패키지와 manifest, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
|
||||||
| 브라우저 관찰 | 9 | 페이지, 스크립트, 소스 맵, WebMCP, 스크린샷, 캡처 비교 |
|
| 브라우저 관찰 | 9 | 페이지, 스크립트, 소스 맵, WebMCP, 스크린샷, 캡처 비교 |
|
||||||
| Electron 분석 | 5 | 페이지 관찰, 앱 구조, 정적·런타임 결과 연결 |
|
| Electron 분석 | 5 | 페이지 관찰, 앱 구조, 정적·런타임 결과 연결 |
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
|||||||
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原 |
|
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原 |
|
||||||
| 制品检查 | 5 | 目录与软件包检查、Interface Builder、Apple 资源目录与提取 |
|
| 制品检查 | 5 | 目录与软件包检查、Interface Builder、Apple 资源目录与提取 |
|
||||||
| .NET PE/CLI | 7 | 程序集身份、元数据、CIL、原生调用声明、成员比较与重建导入 |
|
| .NET PE/CLI | 7 | 程序集身份、元数据、CIL、原生调用声明、成员比较与重建导入 |
|
||||||
|
| 韧体 | 2 | Linux 韧体区域识别与显式解包 |
|
||||||
| Android APK | 5 | 包与 manifest 声明、class 搜索、成员清单、方法反编译与静态引用 |
|
| Android APK | 5 | 包与 manifest 声明、class 搜索、成员清单、方法反编译与静态引用 |
|
||||||
| 浏览器观察 | 9 | 页面、脚本、来源映射、WebMCP 工具、截图与捕获比较 |
|
| 浏览器观察 | 9 | 页面、脚本、来源映射、WebMCP 工具、截图与捕获比较 |
|
||||||
| Electron 分析 | 5 | 页面观察、应用结构与静态/运行时结果关联 |
|
| Electron 分析 | 5 | 页面观察、应用结构与静态/运行时结果关联 |
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ graph TD
|
|||||||
AUX["src/artifacts/ + src/native/<br/>disjoint artifact and native capabilities"]
|
AUX["src/artifacts/ + src/native/<br/>disjoint artifact and native capabilities"]
|
||||||
BROWSER["src/browser/<br/>Loopback CDP transport<br/>exact-origin passive observation"]
|
BROWSER["src/browser/<br/>Loopback CDP transport<br/>exact-origin passive observation"]
|
||||||
DOTNET["src/dotnet/<br/>Managed static metadata provider<br/>PE/CLI metadata inventory, members, IL"]
|
DOTNET["src/dotnet/<br/>Managed static metadata provider<br/>PE/CLI metadata inventory, members, IL"]
|
||||||
|
FIRMWARE["src/firmware/<br/>Binwalk / Unblob adapters<br/>owned processes, report normalization, private extraction"]
|
||||||
ANDROID["src/android/<br/>Static APK adapter<br/>owned SDK stdio, pinned headless JADX, normalization"]
|
ANDROID["src/android/<br/>Static APK adapter<br/>owned SDK stdio, pinned headless JADX, normalization"]
|
||||||
REFERENCE["src/reference/<br/>Filesystem reference-source reader<br/>bounded traversal, validation, errors"]
|
REFERENCE["src/reference/<br/>Filesystem reference-source reader<br/>bounded traversal, validation, errors"]
|
||||||
end
|
end
|
||||||
@@ -62,6 +63,9 @@ graph TD
|
|||||||
MAIN --> SERVER
|
MAIN --> SERVER
|
||||||
MAIN --> APP
|
MAIN --> APP
|
||||||
CLI --> APP
|
CLI --> APP
|
||||||
|
APP --> FIRMWARE
|
||||||
|
FIRMWARE --> PROCESS
|
||||||
|
FIRMWARE --> CONTRACTS
|
||||||
APP --> ANDROID
|
APP --> ANDROID
|
||||||
ANDROID --> PROCESS
|
ANDROID --> PROCESS
|
||||||
ANDROID --> CONTRACTS
|
ANDROID --> CONTRACTS
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Firmware analysis
|
||||||
|
|
||||||
|
REA integrates **Binwalk 3.1.0** for region inspection and **Unblob 26.6.4** for
|
||||||
|
explicit extraction. This first firmware milestone is verified on Linux x64.
|
||||||
|
No device, root privilege, mount, emulation or execution of extracted programs
|
||||||
|
is needed. Arbitrary firmware bytes do not pass native executable admission;
|
||||||
|
select a returned ELF separately for supported Ghidra analysis.
|
||||||
|
|
||||||
|
## Caller-supplied tools
|
||||||
|
|
||||||
|
Provide existing tools using absolute executable paths, scoped to your shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export REA_BINWALK_COMMAND=/absolute/path/to/binwalk
|
||||||
|
export REA_UNBLOB_COMMAND=/absolute/path/to/unblob
|
||||||
|
```
|
||||||
|
|
||||||
|
REA does not install these tools, their dependencies or system packages. It
|
||||||
|
checks the actual tool version when executing a request. Inspection and
|
||||||
|
extraction have independent prerequisites; one does not require the other.
|
||||||
|
Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its
|
||||||
|
absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed.
|
||||||
|
|
||||||
|
Unblob needs the external extractor for the selected format on its inherited
|
||||||
|
`PATH`: for example, `debugfs` for ext2/ext3/ext4. Its default Landlock sandbox
|
||||||
|
and skip rules stay enabled. Sandbox startup failures remain actionable errors;
|
||||||
|
REA does not retry with `--no-sandbox`. Missing extractors reported in a valid
|
||||||
|
analysis report retain partial results and dependency names inline.
|
||||||
|
|
||||||
|
Upstream sources are preserved unmodified as optional pinned submodules:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git submodule update --init third_party/binwalk third_party/unblob
|
||||||
|
```
|
||||||
|
|
||||||
|
Normal REA builds and npm packages do not include or build these sources or
|
||||||
|
engine binaries. See [upstream provenance](../third_party/README.md).
|
||||||
|
|
||||||
|
## CLI and MCP
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rea inspect-firmware-regions /path/to/firmware.bin
|
||||||
|
rea extract-firmware /path/to/firmware.bin /path/to/new-output
|
||||||
|
rea extract-firmware /path/to/firmware.bin /path/to/selected-output \
|
||||||
|
--offset 64 --length 4096 --max-depth 3
|
||||||
|
```
|
||||||
|
|
||||||
|
The corresponding MCP tools accept the same intent:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "inspect_firmware_regions",
|
||||||
|
"arguments": { "path": "/path/to/firmware.bin" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "extract_firmware",
|
||||||
|
"arguments": {
|
||||||
|
"path": "/path/to/firmware.bin",
|
||||||
|
"output_directory": "/path/to/new-output",
|
||||||
|
"range": { "offset": 64, "length": 4096 },
|
||||||
|
"max_depth": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both return Evidence with the original path and SHA-256, raw producer reports,
|
||||||
|
actual executable path/digest, version and limitations. Binwalk signature UUIDs
|
||||||
|
and Unblob blob IDs are producer-local identities; compare content hashes and
|
||||||
|
source ranges across runs. A matching version does not attest an installed
|
||||||
|
build's source revision, so that field remains unknown.
|
||||||
|
|
||||||
|
### Interpret the results
|
||||||
|
|
||||||
|
- Region offsets refer to bytes in the original firmware. `reported_size`
|
||||||
|
preserves Binwalk's output; `size_basis=provider_reported_validation_unknown`
|
||||||
|
reflects that Binwalk can replace unknown lengths with the next signature or
|
||||||
|
EOF before serialization. Neither a signature nor its confidence byte proves
|
||||||
|
a valid partition, encryption or runtime behavior.
|
||||||
|
- Extraction returns regular file paths, independently verified SHA-256 values,
|
||||||
|
producer hashes when available, chunks, diagnostics and task derivations.
|
||||||
|
Unblob's task report is unordered; REA resolves relationships by paths and
|
||||||
|
blob IDs. Logical references use `$input`, `$output`, or `$output/<relative-path>`
|
||||||
|
so a real file named `$input` stays distinct from the original input.
|
||||||
|
Chunk offsets belong to the task's input file. Only chunks in the
|
||||||
|
selected input have a known `root_file_range`, adjusted by the selected offset.
|
||||||
|
- Decompressed children have unknown original byte ranges and runtime addresses.
|
||||||
|
Task lineage does not establish a byte-for-byte mapping through decompression.
|
||||||
|
- `partial` marks unknown/encrypted chunks, reported extraction problems,
|
||||||
|
unpublished links/special files or reached depth limits. `complete` means the
|
||||||
|
engine finished its configured extraction without those reported conditions;
|
||||||
|
default skip rules and format coverage still apply. It does not establish
|
||||||
|
complete firmware reconstruction.
|
||||||
|
- The destination must be absent, absolute, with an existing parent. Publication
|
||||||
|
uses exclusive, symlink-resistant writes and durable digest readback. Existing
|
||||||
|
destinations are never overwritten. This is not an atomic directory rename:
|
||||||
|
a new tree can be visible while files are written. Failed uncommitted output
|
||||||
|
is rolled back. Symlinks and special files are recorded without publication;
|
||||||
|
empty directories, ownership and executable permission bits are not reproduced.
|
||||||
|
|
||||||
|
Select a verified native child for the existing tools:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "open_binary",
|
||||||
|
"arguments": {
|
||||||
|
"path": "/path/from/extraction/usr/bin/program",
|
||||||
|
"provider_id": "ghidra"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use `analyze_function`, `inspect_native_api` or the other native primitives.
|
||||||
|
Native architecture and executable validation still apply. This milestone does
|
||||||
|
not add MIPS/PPC/RISC-V decompilation, raw flash load profiles, bootloader/device
|
||||||
|
tree semantics, vulnerability scanning, emulation or hardware runtime capture.
|
||||||
|
|
||||||
|
## Resource and lifecycle policy
|
||||||
|
|
||||||
|
- Nonempty regular firmware inputs: at most **128 MiB**, copied and hashed into
|
||||||
|
a private workspace before analysis. No in-place source modification.
|
||||||
|
- One queued heavy operation per provider instance; Binwalk one thread and
|
||||||
|
Unblob one worker. No automatic parallel extraction engines.
|
||||||
|
- **120-second** deadline; inherited **1 GiB per-process address-space** limit,
|
||||||
|
120 CPU seconds and zero core-dump allowance through `prlimit`.
|
||||||
|
- JSON reports: at most **8 MiB**; oversized reports fail without a truncated
|
||||||
|
success. Diagnostic output is bounded with exact captured byte counts.
|
||||||
|
- Default staging budget: **256 MiB** and **10,000 entries**, configurable using
|
||||||
|
`max_output_bytes` / `max_output_files` (CLI `--max-output-bytes` /
|
||||||
|
`--max-output-files`). Depth defaults to 3, allowed range 1–10.
|
||||||
|
|
||||||
|
Staging budgets are polled and rechecked after exit; short-lived overshoot is
|
||||||
|
possible. The inherited file-size limit bounds each file, not total storage;
|
||||||
|
address-space limits are per process, not an aggregate memory quota. Worker
|
||||||
|
count is not CPU affinity for every external extractor. These limits do not
|
||||||
|
claim cgroup/container containment. Cancellation cleans the owned process group
|
||||||
|
before removing its workspace; uncertain cleanup retains resources and blocks
|
||||||
|
further operations in that provider instance.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Generate small public, source-derived fixtures into ignored `_reference/`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run fixtures:firmware
|
||||||
|
npm run verify:firmware
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixture generation requires existing Python 3 and a host C compiler (`cc`);
|
||||||
|
override with `REA_FIRMWARE_PYTHON` / `REA_FIRMWARE_CC`. The independent oracle
|
||||||
|
records gzip offset/length and extracted file hashes. Real verification checks
|
||||||
|
CLI/MCP, full-file and selected-range extraction, unknown chunks, depth limits,
|
||||||
|
content parity and unchanged source bytes. No binary fixture is committed.
|
||||||
|
|
||||||
|
Additional lanes are explicit, so the base lane stays small:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Existing mke2fs and debugfs required; put debugfs on the scoped PATH.
|
||||||
|
REA_FIRMWARE_VERIFY_EXT4=1 npm run fixtures:firmware
|
||||||
|
REA_FIRMWARE_VERIFY_EXT4=1 npm run verify:firmware
|
||||||
|
|
||||||
|
# Existing Ghidra 12.1.4 and Java required.
|
||||||
|
REA_FIRMWARE_VERIFY_GHIDRA=1 npm run verify:firmware
|
||||||
|
```
|
||||||
|
|
||||||
|
`REA_FIRMWARE_MKE2FS_COMMAND` selects the optional ext4 fixture producer.
|
||||||
|
`REA_FIRMWARE_FIXTURE_ROOT` selects generated data storage.
|
||||||
|
`REA_FIRMWARE_TEST_ENTRYPOINT` can select an isolated installed npm package's
|
||||||
|
`scripts/rea.mjs` to verify the packaged CLI/MCP workflow. Real coverage here is
|
||||||
|
generated gzip/USTAR/ext4 with a Linux x64 ELF child; SquashFS, UBIFS, proprietary
|
||||||
|
firmware, other hosts and architectures remain unverified.
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"skill_version": "24"
|
"skill_version": "24"
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"total": 123,
|
"total": 125,
|
||||||
"families": [
|
"families": [
|
||||||
{
|
{
|
||||||
"id": "direct",
|
"id": "direct",
|
||||||
@@ -121,6 +121,12 @@
|
|||||||
"verify_managed_native_boundaries"
|
"verify_managed_native_boundaries"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "firmware",
|
||||||
|
"surface": "firmware-provider",
|
||||||
|
"count": 2,
|
||||||
|
"tools": ["extract_firmware", "inspect_firmware_regions"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "android",
|
"id": "android",
|
||||||
"surface": "android-provider",
|
"surface": "android-provider",
|
||||||
@@ -215,6 +221,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"providers": [
|
"providers": [
|
||||||
|
{
|
||||||
|
"id": "binwalk",
|
||||||
|
"name": "Binwalk",
|
||||||
|
"version": "3.1.0",
|
||||||
|
"capabilities": ["inspect_firmware_regions"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "ghidra",
|
"id": "ghidra",
|
||||||
"name": "Ghidra",
|
"name": "Ghidra",
|
||||||
@@ -418,6 +430,12 @@
|
|||||||
"list_javascript_runtime_targets",
|
"list_javascript_runtime_targets",
|
||||||
"observe_javascript_runtime"
|
"observe_javascript_runtime"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unblob",
|
||||||
|
"name": "Unblob",
|
||||||
|
"version": "26.6.4",
|
||||||
|
"capabilities": ["extract_firmware"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"setup_clients": [
|
"setup_clients": [
|
||||||
@@ -495,7 +513,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"cli": {
|
"cli": {
|
||||||
"primary_count": 78,
|
"primary_count": 80,
|
||||||
"commands": [
|
"commands": [
|
||||||
"address-to-file-offset",
|
"address-to-file-offset",
|
||||||
"analyze",
|
"analyze",
|
||||||
@@ -526,6 +544,7 @@
|
|||||||
"evidence-export",
|
"evidence-export",
|
||||||
"evidence-import",
|
"evidence-import",
|
||||||
"extract-artifact",
|
"extract-artifact",
|
||||||
|
"extract-firmware",
|
||||||
"function",
|
"function",
|
||||||
"import-managed-reconstruction",
|
"import-managed-reconstruction",
|
||||||
"import-reference-source",
|
"import-reference-source",
|
||||||
@@ -536,6 +555,7 @@
|
|||||||
"inspect-artifact",
|
"inspect-artifact",
|
||||||
"inspect-asset-catalog",
|
"inspect-asset-catalog",
|
||||||
"inspect-electron-page",
|
"inspect-electron-page",
|
||||||
|
"inspect-firmware-regions",
|
||||||
"inspect-keyed-archive",
|
"inspect-keyed-archive",
|
||||||
"inspect-macho",
|
"inspect-macho",
|
||||||
"inspect-managed-artifact",
|
"inspect-managed-artifact",
|
||||||
@@ -585,15 +605,15 @@
|
|||||||
},
|
},
|
||||||
"runtime_catalog": {
|
"runtime_catalog": {
|
||||||
"counts": {
|
"counts": {
|
||||||
"cli_commands": 78,
|
"cli_commands": 80,
|
||||||
"mcp_tools": 123,
|
"mcp_tools": 125,
|
||||||
"mcp_prompts": 6
|
"mcp_prompts": 6
|
||||||
},
|
},
|
||||||
"digests": {
|
"digests": {
|
||||||
"tools_sha256": "6e60913fad4c67f3a12aa8bf5113971ce6a57e9492ce402c8cd9e539d6fde01f",
|
"tools_sha256": "45a8aea00dba2a8ad8563b4943cd177d07b14889bc9b823bc39b89b6ddb26d2c",
|
||||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||||
"combined_sha256": "d5fbda3a283916ab3489a62fe88aeb89da8b0a7b19c248e3742c970b1b51600e",
|
"combined_sha256": "297aced29e7ce5e845c505d5f2e9e2dee42b7ed3e628f088f424fe8088c9e8dc",
|
||||||
"providers_sha256": "e60c6e949f499fe8a015a20ff36752516ce57edcb1ce355f0308361c8d322dd6"
|
"providers_sha256": "dfba0ee59f12c82cdfb5320e7e0d70a114e533b2cb88afd15db9b9a4a175c9b0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,3 +311,15 @@ allows a host-permission-boundary-only result and explicitly reports
|
|||||||
Both commands reject a changed executable digest and clean up the fixture
|
Both commands reject a changed executable digest and clean up the fixture
|
||||||
process and helper. These lanes require an interactive macOS desktop. See [native investigation](native-investigation.md)
|
process and helper. These lanes require an interactive macOS desktop. See [native investigation](native-investigation.md)
|
||||||
for the exact ABI, authority, graph and observation boundaries.
|
for the exact ABI, authority, graph and observation boundaries.
|
||||||
|
|
||||||
|
### Firmware adapters
|
||||||
|
|
||||||
|
`npm run fixtures:firmware` uses existing Python 3 and a host C compiler to make
|
||||||
|
an ignored gzip/USTAR firmware fixture and independent offset/hash oracle.
|
||||||
|
`npm run verify:firmware` requires caller-supplied Binwalk 3.1.0, Unblob 26.6.4
|
||||||
|
and util-linux prlimit on Linux. It verifies CLI/MCP parity, selected ranges,
|
||||||
|
unknown chunks, depth limits and extracted child digests. The optional
|
||||||
|
`REA_FIRMWARE_VERIFY_EXT4=1` lane requires existing mke2fs/debugfs; the separate
|
||||||
|
`REA_FIRMWARE_VERIFY_GHIDRA=1` lane checks a selected host ELF through real Ghidra.
|
||||||
|
Neither optional toolchain is a base-lane prerequisite. See
|
||||||
|
[firmware analysis](firmware-analysis.md) for limits and unverified formats.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifest_id": "ecm_42bc9f06a00e9c510cb57605d7f1cde613e2762ea0c59bf3915e032147a7c493",
|
"manifest_id": "ecm_18ee96f3c7c08df6a29b2075b8dadf6e7053e88c52a65cecb35aea374112cbc6",
|
||||||
"verifier": {
|
"verifier": {
|
||||||
"id": "rea-managed-conformance",
|
"id": "rea-managed-conformance",
|
||||||
"version": "1"
|
"version": "1"
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"skill_digests": [
|
"skill_digests": [
|
||||||
{
|
{
|
||||||
"skill_id": "reverse-engineer-anything",
|
"skill_id": "reverse-engineer-anything",
|
||||||
"sha256": "ace0a167b9161d9d96c9f5cd65ff0bb39e483c3426e6cc86aac5c7c9893c31cb"
|
"sha256": "f72e03be44a47ba176e6aac47c85e930fe1fe5723b650dacbd5873f28a3b3f8f"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"claims": [
|
"claims": [
|
||||||
|
|||||||
@@ -136,6 +136,8 @@
|
|||||||
"verify:javascript:digests": "npm run build:cached && node scripts/verify-large-javascript-digests.mjs",
|
"verify:javascript:digests": "npm run build:cached && node scripts/verify-large-javascript-digests.mjs",
|
||||||
"verify:application-workflows": "npm run build:cached && node scripts/verify-local-application-workflows.mjs",
|
"verify:application-workflows": "npm run build:cached && node scripts/verify-local-application-workflows.mjs",
|
||||||
"verify:managed": "npm run build:cached && node scripts/verify-managed-conformance.mjs",
|
"verify:managed": "npm run build:cached && node scripts/verify-managed-conformance.mjs",
|
||||||
|
"verify:firmware": "npm run build:cached && node scripts/verify-real-firmware.mjs",
|
||||||
|
"fixtures:firmware": "node scripts/generate-firmware-fixtures.mjs",
|
||||||
"verify:android": "npm run build:cached && node scripts/verify-real-android.mjs",
|
"verify:android": "npm run build:cached && node scripts/verify-real-android.mjs",
|
||||||
"fixtures:android": "node scripts/fetch-android-fixtures.mjs",
|
"fixtures:android": "node scripts/fetch-android-fixtures.mjs",
|
||||||
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
|
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const exec = promisify(execFile);
|
||||||
|
const root = resolve(
|
||||||
|
process.env.REA_FIRMWARE_FIXTURE_ROOT ??
|
||||||
|
"_reference/firmware-integration/generated",
|
||||||
|
);
|
||||||
|
for (const [command, args, purpose] of [
|
||||||
|
[
|
||||||
|
process.env.REA_FIRMWARE_PYTHON ?? "python3",
|
||||||
|
["--version"],
|
||||||
|
"Python 3 standard-library fixture generator",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
process.env.REA_FIRMWARE_CC ?? "cc",
|
||||||
|
["--version"],
|
||||||
|
"host ELF fixture compiler",
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
await exec(command, args);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new Error(`fixtures:firmware requires ${purpose}: ${command}`, {
|
||||||
|
cause,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ext4 =
|
||||||
|
process.env.REA_FIRMWARE_VERIFY_EXT4 === "1"
|
||||||
|
? [process.env.REA_FIRMWARE_MKE2FS_COMMAND ?? "mke2fs"]
|
||||||
|
: [];
|
||||||
|
for (const command of ext4) await exec(command, ["-V"]);
|
||||||
|
const generated = await exec(process.env.REA_FIRMWARE_PYTHON ?? "python3", [
|
||||||
|
fileURLToPath(new URL("./lib/generate-firmware-fixture.py", import.meta.url)),
|
||||||
|
root,
|
||||||
|
process.env.REA_FIRMWARE_CC ?? "cc",
|
||||||
|
...ext4,
|
||||||
|
]);
|
||||||
|
console.log(generated.stdout.trim());
|
||||||
@@ -31,6 +31,11 @@ export const toolFamilyCatalog = (sources) => {
|
|||||||
...sources.managedWorkflowContracts.MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
...sources.managedWorkflowContracts.MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "firmware",
|
||||||
|
surface: "firmware-provider",
|
||||||
|
contracts: sources.firmwareContracts.FIRMWARE_TOOL_CONTRACTS,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "android",
|
id: "android",
|
||||||
surface: "android-provider",
|
surface: "android-provider",
|
||||||
@@ -118,6 +123,18 @@ export const providerCatalog = (sources) => {
|
|||||||
contracts:
|
contracts:
|
||||||
sources.managedWorkflowContracts.MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
sources.managedWorkflowContracts.MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
identity: sources.firmwareProvider.BINWALK_PROVIDER_IDENTITY,
|
||||||
|
contracts: sources.firmwareContracts.FIRMWARE_TOOL_CONTRACTS.filter(
|
||||||
|
({ name }) => name === "inspect_firmware_regions",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
identity: sources.firmwareProvider.UNBLOB_PROVIDER_IDENTITY,
|
||||||
|
contracts: sources.firmwareContracts.FIRMWARE_TOOL_CONTRACTS.filter(
|
||||||
|
({ name }) => name === "extract_firmware",
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
identity: sources.androidProvider.JADX_PROVIDER_IDENTITY,
|
identity: sources.androidProvider.JADX_PROVIDER_IDENTITY,
|
||||||
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
|
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const SOURCE_PATHS = {
|
|||||||
nativeContracts: "dist/contracts/nativeToolContracts.js",
|
nativeContracts: "dist/contracts/nativeToolContracts.js",
|
||||||
artifactContracts: "dist/contracts/artifactToolContracts.js",
|
artifactContracts: "dist/contracts/artifactToolContracts.js",
|
||||||
managedContracts: "dist/contracts/managedToolContracts.js",
|
managedContracts: "dist/contracts/managedToolContracts.js",
|
||||||
|
firmwareContracts: "dist/contracts/firmwareToolContracts.js",
|
||||||
|
firmwareProvider: "dist/firmware/FirmwareRelease.js",
|
||||||
androidContracts: "dist/contracts/androidToolContracts.js",
|
androidContracts: "dist/contracts/androidToolContracts.js",
|
||||||
androidProvider: "dist/android/JadxRelease.js",
|
androidProvider: "dist/android/JadxRelease.js",
|
||||||
managedWorkflowContracts: "dist/contracts/managedWorkflowToolContracts.js",
|
managedWorkflowContracts: "dist/contracts/managedWorkflowToolContracts.js",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Generate firmware bytes using standard format producers, with an independent oracle."""
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
root = Path(sys.argv[1]).resolve()
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
source = root / "probe.c"
|
||||||
|
source.write_text("int firmware_probe(int x){return x*7+3;} int main(void){return firmware_probe(4);}\n")
|
||||||
|
subprocess.run([sys.argv[2], "-O0", "-g", "-fno-inline", str(source), "-o", str(root / "probe")], check=True)
|
||||||
|
payloads = {"etc/rea.conf": b"rea_fixture=true\n", "usr/bin/probe": (root / "probe").read_bytes()}
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buffer, mode="w", format=tarfile.USTAR_FORMAT) as archive:
|
||||||
|
for name, data in payloads.items():
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
info.size = len(data)
|
||||||
|
info.mode = 0o644
|
||||||
|
info.mtime = 0
|
||||||
|
archive.addfile(info, io.BytesIO(data))
|
||||||
|
compressed = gzip.compress(buffer.getvalue(), mtime=0)
|
||||||
|
image = b"REA firmware fixture\n".ljust(64, b"\xff") + compressed + b"\x00" * 128
|
||||||
|
(root / "firmware.bin").write_bytes(image)
|
||||||
|
(root / "oracle.json").write_text(json.dumps({
|
||||||
|
"generator": "Python tarfile USTAR + gzip; host cc; no target execution",
|
||||||
|
"offset": 64, "length": len(compressed), "size": len(image),
|
||||||
|
"sha256": hashlib.sha256(image).hexdigest(),
|
||||||
|
"files": {name: {"sha256": hashlib.sha256(data).hexdigest(), "size": len(data)} for name, data in payloads.items()},
|
||||||
|
}, indent=2) + "\n")
|
||||||
|
print(root)
|
||||||
|
|
||||||
|
if len(sys.argv) > 3:
|
||||||
|
tree = root / "ext4-root"
|
||||||
|
tree.mkdir(exist_ok=True)
|
||||||
|
for name, data in payloads.items():
|
||||||
|
target = tree / name
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(data)
|
||||||
|
subprocess.run([sys.argv[3], "-q", "-t", "ext4", "-F", "-d", str(tree), str(root / "ext4.bin"), "8192"], check=True)
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import {
|
||||||
|
access,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
symlink,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { Client } from "@modelcontextprotocol/client";
|
||||||
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||||
|
import { firmwareResultSchemas } from "../dist/domain/firmwareAnalysis.js";
|
||||||
|
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||||
|
import { hashFirmwareFile } from "../dist/firmware/FirmwareFiles.js";
|
||||||
|
|
||||||
|
const exec = promisify(execFile);
|
||||||
|
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const entrypoint = resolve(
|
||||||
|
process.env.REA_FIRMWARE_TEST_ENTRYPOINT ??
|
||||||
|
join(repository, "scripts/rea.mjs"),
|
||||||
|
);
|
||||||
|
if (process.platform !== "linux")
|
||||||
|
throw new Error(
|
||||||
|
"verify:firmware requires Linux; other hosts are not verified",
|
||||||
|
);
|
||||||
|
for (const variable of ["REA_BINWALK_COMMAND", "REA_UNBLOB_COMMAND"]) {
|
||||||
|
const path = process.env[variable];
|
||||||
|
if (path === undefined)
|
||||||
|
throw new Error(
|
||||||
|
`verify:firmware requires ${variable}; see docs/firmware-analysis.md`,
|
||||||
|
);
|
||||||
|
await access(path);
|
||||||
|
}
|
||||||
|
const fixtureRoot = resolve(
|
||||||
|
process.env.REA_FIRMWARE_FIXTURE_ROOT ??
|
||||||
|
join(repository, "_reference/firmware-integration/generated"),
|
||||||
|
);
|
||||||
|
await access(join(fixtureRoot, "oracle.json")).catch((cause) => {
|
||||||
|
throw new Error(
|
||||||
|
"verify:firmware requires generated fixtures; run npm run fixtures:firmware",
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const oracle = JSON.parse(
|
||||||
|
await readFile(join(fixtureRoot, "oracle.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const path = join(fixtureRoot, "firmware.bin");
|
||||||
|
assert.equal(await hashFirmwareFile(path), oracle.sha256);
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "rea-real-firmware-"));
|
||||||
|
const environment = Object.fromEntries(
|
||||||
|
Object.entries(process.env).filter(([, value]) => value !== undefined),
|
||||||
|
);
|
||||||
|
const client = new Client({ name: "rea-real-firmware-verifier", version: "1" });
|
||||||
|
const cli = async (args, env = environment) => {
|
||||||
|
const response = await exec(
|
||||||
|
process.execPath,
|
||||||
|
[entrypoint, ...args, "--format", "json"],
|
||||||
|
{
|
||||||
|
env,
|
||||||
|
cwd: repository,
|
||||||
|
timeout: 150_000,
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return parseEvidence(JSON.parse(response.stdout));
|
||||||
|
};
|
||||||
|
const call = async (name, args) => {
|
||||||
|
const response = await client.callTool(
|
||||||
|
{ name, arguments: args },
|
||||||
|
{ timeout: 150_000 },
|
||||||
|
);
|
||||||
|
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||||
|
assert.ok(response.structuredContent !== undefined);
|
||||||
|
return response.structuredContent;
|
||||||
|
};
|
||||||
|
const verifyRegions = (evidence) => {
|
||||||
|
assert.equal(evidence.subject.digest.sha256, oracle.sha256);
|
||||||
|
const result = firmwareResultSchemas.inspect_firmware_regions.parse(
|
||||||
|
evidence.normalized_result,
|
||||||
|
);
|
||||||
|
const gzip = result.regions.find(
|
||||||
|
(region) => region.signature === "gzip" && region.offset === oracle.offset,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
gzip !== undefined,
|
||||||
|
"Binwalk must find the independently generated gzip offset",
|
||||||
|
);
|
||||||
|
assert.equal(gzip.reported_size, oracle.length);
|
||||||
|
assert.equal(gzip.size_basis, "provider_reported_validation_unknown");
|
||||||
|
return result.regions.map(({ provider_id, ...region }) => region);
|
||||||
|
};
|
||||||
|
const verifyFiles = async (evidence) => {
|
||||||
|
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||||
|
evidence.normalized_result,
|
||||||
|
);
|
||||||
|
assert.ok(result.chunks.some((chunk) => chunk.handler === "gzip"));
|
||||||
|
for (const [suffix, expected] of Object.entries(oracle.files)) {
|
||||||
|
const file = result.files.find((candidate) =>
|
||||||
|
candidate.relative_path.endsWith(`/${suffix}`),
|
||||||
|
);
|
||||||
|
assert.ok(file !== undefined, `Missing extracted ${suffix}`);
|
||||||
|
assert.equal(file.sha256, expected.sha256);
|
||||||
|
assert.equal(file.size, expected.size);
|
||||||
|
assert.equal(await hashFirmwareFile(file.path), expected.sha256);
|
||||||
|
assert.equal(file.original_file_range, null);
|
||||||
|
assert.equal(file.runtime_address, null);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const inspected = await cli(["inspect-firmware-regions", path]);
|
||||||
|
const cliRegions = verifyRegions(inspected);
|
||||||
|
const extracted = await cli([
|
||||||
|
"extract-firmware",
|
||||||
|
path,
|
||||||
|
join(root, "cli-output"),
|
||||||
|
]);
|
||||||
|
const cliFiles = await verifyFiles(extracted);
|
||||||
|
assert.equal(cliFiles.coverage, "partial");
|
||||||
|
assert.ok(
|
||||||
|
cliFiles.chunks.some(
|
||||||
|
(chunk) =>
|
||||||
|
chunk.handler === null &&
|
||||||
|
chunk.range.offset === 0 &&
|
||||||
|
chunk.range.length === 64,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await client.connect(
|
||||||
|
new StdioClientTransport({
|
||||||
|
command: process.execPath,
|
||||||
|
args: [entrypoint, "mcp"],
|
||||||
|
cwd: repository,
|
||||||
|
env: environment,
|
||||||
|
stderr: "pipe",
|
||||||
|
}),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
const inspectedMcp = await call("inspect_firmware_regions", { path });
|
||||||
|
const mcpEvidence = parseEvidence(inspectedMcp.evidence);
|
||||||
|
assert.deepEqual(verifyRegions(mcpEvidence), cliRegions);
|
||||||
|
const extractedMcp = await call("extract_firmware", {
|
||||||
|
path,
|
||||||
|
output_directory: join(root, "mcp-output"),
|
||||||
|
});
|
||||||
|
const mcpFiles = await verifyFiles(parseEvidence(extractedMcp.evidence));
|
||||||
|
const contentIdentity = (result) =>
|
||||||
|
result.files.map(({ relative_path, sha256, size }) => ({
|
||||||
|
relative_path,
|
||||||
|
sha256,
|
||||||
|
size,
|
||||||
|
}));
|
||||||
|
assert.deepEqual(contentIdentity(mcpFiles), contentIdentity(cliFiles));
|
||||||
|
const selected = await call("extract_firmware", {
|
||||||
|
path,
|
||||||
|
output_directory: join(root, "selected-output"),
|
||||||
|
range: { offset: oracle.offset, length: oracle.length },
|
||||||
|
});
|
||||||
|
const selectedFiles = await verifyFiles(parseEvidence(selected.evidence));
|
||||||
|
assert.equal(selectedFiles.selection.offset, oracle.offset);
|
||||||
|
assert.equal(selectedFiles.selection.length, oracle.length);
|
||||||
|
const depth = await call("extract_firmware", {
|
||||||
|
path,
|
||||||
|
output_directory: join(root, "depth-output"),
|
||||||
|
max_depth: 1,
|
||||||
|
});
|
||||||
|
const depthResult = firmwareResultSchemas.extract_firmware.parse(
|
||||||
|
depth.result,
|
||||||
|
);
|
||||||
|
assert.equal(depthResult.coverage, "partial");
|
||||||
|
assert.ok(depthResult.depth_limited_paths.length > 0);
|
||||||
|
console.log(
|
||||||
|
"PASS real Binwalk/Unblob: CLI, MCP, independent offsets/digests, range extraction, unknown chunks and depth limits",
|
||||||
|
);
|
||||||
|
if (process.env.REA_FIRMWARE_VERIFY_EXT4 === "1") {
|
||||||
|
const ext4 = join(fixtureRoot, "ext4.bin");
|
||||||
|
await access(ext4);
|
||||||
|
const response = await call("extract_firmware", {
|
||||||
|
path: ext4,
|
||||||
|
output_directory: join(root, "ext4-output"),
|
||||||
|
});
|
||||||
|
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||||
|
response.result,
|
||||||
|
);
|
||||||
|
assert.ok(result.chunks.some((chunk) => chunk.handler === "extfs"));
|
||||||
|
assert.equal(result.coverage, "complete");
|
||||||
|
for (const [suffix, expected] of Object.entries(oracle.files))
|
||||||
|
assert.equal(
|
||||||
|
result.files.find((file) => file.relative_path.endsWith(`/${suffix}`))
|
||||||
|
?.sha256,
|
||||||
|
expected.sha256,
|
||||||
|
);
|
||||||
|
console.log("PASS real ext4 extraction with debugfs; no mounts");
|
||||||
|
// Preserve the process-ownership utility while withholding all extractors.
|
||||||
|
const withoutExtractors = join(root, "without-extractors");
|
||||||
|
await access("/bin/ps");
|
||||||
|
await mkdir(withoutExtractors);
|
||||||
|
await symlink("/bin/ps", join(withoutExtractors, "ps"));
|
||||||
|
const missing = await cli(
|
||||||
|
["extract-firmware", ext4, join(root, "missing-extractor-output")],
|
||||||
|
{ ...environment, PATH: withoutExtractors },
|
||||||
|
);
|
||||||
|
const partial = firmwareResultSchemas.extract_firmware.parse(
|
||||||
|
missing.normalized_result,
|
||||||
|
);
|
||||||
|
assert.equal(partial.coverage, "partial");
|
||||||
|
assert.ok(JSON.stringify(partial.diagnostics).includes("debugfs"));
|
||||||
|
console.log(
|
||||||
|
"PASS real missing-extractor diagnostics retained despite Unblob exit 1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (process.env.REA_FIRMWARE_VERIFY_GHIDRA === "1") {
|
||||||
|
if (process.env.GHIDRA_INSTALL_DIR === undefined)
|
||||||
|
throw new Error(
|
||||||
|
"Firmware Ghidra handoff requires GHIDRA_INSTALL_DIR and Java; see docs/ghidra.md",
|
||||||
|
);
|
||||||
|
const child = mcpFiles.files.find((file) =>
|
||||||
|
file.relative_path.endsWith("/usr/bin/probe"),
|
||||||
|
);
|
||||||
|
assert.ok(child !== undefined);
|
||||||
|
await call("open_binary", { path: child.path, provider_id: "ghidra" });
|
||||||
|
const dossier = await call("analyze_function", {
|
||||||
|
procedure: "firmware_probe",
|
||||||
|
});
|
||||||
|
const evidence = parseEvidence(dossier.evidence);
|
||||||
|
assert.equal(
|
||||||
|
evidence.subject.digest.sha256,
|
||||||
|
oracle.files["usr/bin/probe"].sha256,
|
||||||
|
);
|
||||||
|
const pseudocode = await call("procedure_pseudo_code", {
|
||||||
|
procedure: "firmware_probe",
|
||||||
|
});
|
||||||
|
assert.equal(parseEvidence(pseudocode.evidence).provider.id, "ghidra");
|
||||||
|
assert.ok(
|
||||||
|
JSON.stringify(evidence.normalized_result).includes("firmware_probe"),
|
||||||
|
);
|
||||||
|
await call("close_binary", {});
|
||||||
|
console.log(
|
||||||
|
"PASS real Ghidra handoff of the selected extracted host ELF, bound to its digest",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
await hashFirmwareFile(path),
|
||||||
|
oracle.sha256,
|
||||||
|
"Original firmware bytes must remain unchanged",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ name: reverse-engineer-anything
|
|||||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||||
metadata:
|
metadata:
|
||||||
version: "24"
|
version: "24"
|
||||||
tool_count: 123
|
tool_count: 125
|
||||||
catalog_digest: "d5fbda3a283916ab3489a62fe88aeb89da8b0a7b19c248e3742c970b1b51600e"
|
catalog_digest: "297aced29e7ce5e845c505d5f2e9e2dee42b7ed3e628f088f424fe8088c9e8dc"
|
||||||
---
|
---
|
||||||
|
|
||||||
# REA
|
# REA
|
||||||
|
|||||||
@@ -55,6 +55,27 @@ const entry = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("capability inventory: provider status", () => {
|
describe("capability inventory: provider status", () => {
|
||||||
|
it("reports firmware inspection and extraction prerequisites independently", () => {
|
||||||
|
const policy = {
|
||||||
|
processCaptureEnabled: true,
|
||||||
|
firmwareInspectionEnabled: true,
|
||||||
|
};
|
||||||
|
expect(entry("inspect_firmware_regions", status(), policy)).toMatchObject({
|
||||||
|
available: true,
|
||||||
|
surface: "firmware-provider",
|
||||||
|
});
|
||||||
|
expect(entry("extract_firmware", status(), policy)).toMatchObject({
|
||||||
|
available: false,
|
||||||
|
reason: "provider_missing",
|
||||||
|
remediation: expect.stringContaining("REA_UNBLOB_COMMAND"),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
entry("extract_firmware", status(), {
|
||||||
|
processCaptureEnabled: true,
|
||||||
|
firmwareExtractionEnabled: true,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ available: true });
|
||||||
|
});
|
||||||
it("keeps Android APK operations target-free and reports their explicit engine prerequisite", () => {
|
it("keeps Android APK operations target-free and reports their explicit engine prerequisite", () => {
|
||||||
expect(
|
expect(
|
||||||
entry("inspect_android_package", status(), {
|
entry("inspect_android_package", status(), {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
|
|||||||
type ProviderDescriptor = ProviderCapability;
|
type ProviderDescriptor = ProviderCapability;
|
||||||
export type AvailabilityPolicy = {
|
export type AvailabilityPolicy = {
|
||||||
readonly processCaptureEnabled: boolean;
|
readonly processCaptureEnabled: boolean;
|
||||||
|
readonly firmwareInspectionEnabled?: boolean;
|
||||||
|
readonly firmwareExtractionEnabled?: boolean;
|
||||||
readonly androidAnalysisEnabled?: boolean;
|
readonly androidAnalysisEnabled?: boolean;
|
||||||
readonly browserObservationEnabled?: boolean;
|
readonly browserObservationEnabled?: boolean;
|
||||||
readonly browserScenarioEnabled?: boolean;
|
readonly browserScenarioEnabled?: boolean;
|
||||||
@@ -206,6 +208,19 @@ const workflowAvailabilityFor = ({
|
|||||||
kind,
|
kind,
|
||||||
policy,
|
policy,
|
||||||
}: AvailabilityContext): Availability | null => {
|
}: AvailabilityContext): Availability | null => {
|
||||||
|
if (kind === "firmware-provider") {
|
||||||
|
const enabled =
|
||||||
|
name === "inspect_firmware_regions"
|
||||||
|
? policy.firmwareInspectionEnabled
|
||||||
|
: policy.firmwareExtractionEnabled;
|
||||||
|
return enabled === true
|
||||||
|
? { reason: "available", remediation: null }
|
||||||
|
: {
|
||||||
|
reason: "provider_missing",
|
||||||
|
remediation:
|
||||||
|
"On Linux, provide an absolute REA_BINWALK_COMMAND (Binwalk 3.1.0) or REA_UNBLOB_COMMAND (Unblob 26.6.4) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.",
|
||||||
|
};
|
||||||
|
}
|
||||||
if (kind === "android-provider")
|
if (kind === "android-provider")
|
||||||
return policy.androidAnalysisEnabled === true
|
return policy.androidAnalysisEnabled === true
|
||||||
? { reason: "available", remediation: null }
|
? { reason: "available", remediation: null }
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type {
|
||||||
|
AnalysisExecution,
|
||||||
|
ExecutionOptions,
|
||||||
|
} from "./AnalysisProvider.js";
|
||||||
|
import type { FirmwareRequest } from "../domain/firmwareAnalysis.js";
|
||||||
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
|
import type { Result } from "../domain/result.js";
|
||||||
|
|
||||||
|
/** File-oriented firmware engines; executable admission remains a separate operation. */
|
||||||
|
export interface FirmwareAnalysisPort {
|
||||||
|
execute(
|
||||||
|
request: FirmwareRequest,
|
||||||
|
options?: ExecutionOptions,
|
||||||
|
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||||
|
import type { FirmwareAnalysisPort } from "./FirmwareAnalysisPort.js";
|
||||||
|
import {
|
||||||
|
firmwareRequestSchema,
|
||||||
|
type FirmwareOperation,
|
||||||
|
} from "../domain/firmwareAnalysis.js";
|
||||||
|
import {
|
||||||
|
AnalysisCancelledError,
|
||||||
|
AnalysisInputError,
|
||||||
|
AnalysisOutputError,
|
||||||
|
} from "../domain/analysisErrorCore.js";
|
||||||
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
|
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||||
|
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||||
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
|
|
||||||
|
/** Shared CLI/MCP validation and Evidence composition for arbitrary firmware files. */
|
||||||
|
export class FirmwareAnalysisService {
|
||||||
|
constructor(readonly provider: FirmwareAnalysisPort) {}
|
||||||
|
|
||||||
|
/** Execute an explicit inspection or extraction and preserve its producing identity. */
|
||||||
|
async execute(
|
||||||
|
operation: FirmwareOperation,
|
||||||
|
input: unknown,
|
||||||
|
options?: ExecutionOptions,
|
||||||
|
): Promise<Result<Evidence, AnalysisError>> {
|
||||||
|
if (options?.signal?.aborted === true)
|
||||||
|
return err(new AnalysisCancelledError(operation));
|
||||||
|
const parsed = firmwareRequestSchema.safeParse({ operation, input });
|
||||||
|
if (!parsed.success)
|
||||||
|
return err(new AnalysisInputError(operation, { cause: parsed.error }));
|
||||||
|
const executed = await this.provider.execute(parsed.data, options);
|
||||||
|
if (!executed.ok) return executed;
|
||||||
|
const execution = executed.value;
|
||||||
|
if (execution.subject === null)
|
||||||
|
return err(
|
||||||
|
new AnalysisOutputError(
|
||||||
|
operation,
|
||||||
|
"Firmware execution did not bind its source artifact",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return ok(
|
||||||
|
createEvidence(execution.subject, execution.provider, {
|
||||||
|
operation,
|
||||||
|
parameters: jsonObjectSchema.parse(parsed.data.input),
|
||||||
|
result: execution.result,
|
||||||
|
rawResult: execution.rawResult,
|
||||||
|
locations: execution.locations,
|
||||||
|
limitations: execution.limitations,
|
||||||
|
confidence: "observed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js";
|
|||||||
import { registerUtilityCommands } from "./cli/utilityCommands.js";
|
import { registerUtilityCommands } from "./cli/utilityCommands.js";
|
||||||
import { registerArtifactCommands } from "./cli/artifactCommands.js";
|
import { registerArtifactCommands } from "./cli/artifactCommands.js";
|
||||||
import { registerManagedCommands } from "./cli/managedCommands.js";
|
import { registerManagedCommands } from "./cli/managedCommands.js";
|
||||||
|
import { registerFirmwareCommands } from "./cli/firmwareCommands.js";
|
||||||
import { registerAndroidCommands } from "./cli/androidCommands.js";
|
import { registerAndroidCommands } from "./cli/androidCommands.js";
|
||||||
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
||||||
import { registerProcessCommands } from "./cli/processCommands.js";
|
import { registerProcessCommands } from "./cli/processCommands.js";
|
||||||
@@ -49,6 +50,7 @@ export const createCli = (
|
|||||||
registerArtifactCommands(cli, logger);
|
registerArtifactCommands(cli, logger);
|
||||||
registerManagedCommands(cli, logger);
|
registerManagedCommands(cli, logger);
|
||||||
registerAndroidCommands(cli, logger, environment);
|
registerAndroidCommands(cli, logger, environment);
|
||||||
|
registerFirmwareCommands(cli, logger, environment);
|
||||||
registerEvidenceCommands(cli, logger);
|
registerEvidenceCommands(cli, logger);
|
||||||
registerProcessCommands(cli, logger, environment);
|
registerProcessCommands(cli, logger, environment);
|
||||||
registerBrowserCommands(cli, logger);
|
registerBrowserCommands(cli, logger);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { z } from "incur";
|
||||||
|
import { FirmwareAnalysisService } from "../application/FirmwareAnalysisService.js";
|
||||||
|
import { FirmwareProvider } from "../firmware/FirmwareProvider.js";
|
||||||
|
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||||
|
import { logCliCommand } from "../cliLogging.js";
|
||||||
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||||
|
import type { FirmwareOperation } from "../domain/firmwareAnalysis.js";
|
||||||
|
import type { Logger } from "../logger.js";
|
||||||
|
import type { CliInstance } from "./types.js";
|
||||||
|
import { withCommandCancellation } from "./commandCancellation.js";
|
||||||
|
|
||||||
|
/** One-shot firmware CLI commands backed by the same workflows as MCP. */
|
||||||
|
export const registerFirmwareCommands = (
|
||||||
|
cli: CliInstance,
|
||||||
|
logger: Logger,
|
||||||
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
|
): void => {
|
||||||
|
const service = new FirmwareAnalysisService(
|
||||||
|
new FirmwareProvider(environment),
|
||||||
|
);
|
||||||
|
const execute = (
|
||||||
|
name: string,
|
||||||
|
operation: FirmwareOperation,
|
||||||
|
input: unknown,
|
||||||
|
) =>
|
||||||
|
withCommandCancellation((signal) =>
|
||||||
|
logCliCommand(logger, name, async () => {
|
||||||
|
const result = await service.execute(operation, input, { signal });
|
||||||
|
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const path = z.string().describe("Local firmware path; never executed");
|
||||||
|
cli.command(CLI_COMMANDS.inspectFirmwareRegions, {
|
||||||
|
description:
|
||||||
|
"Inspect firmware signature candidates without extracting them",
|
||||||
|
args: z.object({ path }),
|
||||||
|
run: ({ args }) =>
|
||||||
|
execute(
|
||||||
|
CLI_COMMANDS.inspectFirmwareRegions,
|
||||||
|
"inspect_firmware_regions",
|
||||||
|
args,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
cli.command(CLI_COMMANDS.extractFirmware, {
|
||||||
|
description:
|
||||||
|
"Extract selected firmware bytes and return verified files and provenance",
|
||||||
|
args: z.object({
|
||||||
|
path,
|
||||||
|
outputDirectory: z
|
||||||
|
.string()
|
||||||
|
.describe("Absolute absent output directory; parent must exist"),
|
||||||
|
}),
|
||||||
|
options: z.object({
|
||||||
|
offset: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.optional()
|
||||||
|
.describe("Selected byte offset; requires length"),
|
||||||
|
length: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.describe("Selected byte length; requires offset"),
|
||||||
|
maxDepth: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.describe("Extraction depth, 1–10; default 3"),
|
||||||
|
maxOutputBytes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.describe("Staging byte budget; default 256 MiB, maximum 512 MiB"),
|
||||||
|
maxOutputFiles: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.describe("Staging entry budget; default 10000"),
|
||||||
|
}),
|
||||||
|
run: ({ args, options }) =>
|
||||||
|
execute(CLI_COMMANDS.extractFirmware, "extract_firmware", {
|
||||||
|
path: args.path,
|
||||||
|
output_directory: args.outputDirectory,
|
||||||
|
...(options.offset === undefined && options.length === undefined
|
||||||
|
? {}
|
||||||
|
: { range: { offset: options.offset, length: options.length } }),
|
||||||
|
...(options.maxDepth === undefined
|
||||||
|
? {}
|
||||||
|
: { max_depth: options.maxDepth }),
|
||||||
|
...(options.maxOutputBytes === undefined
|
||||||
|
? {}
|
||||||
|
: { max_output_bytes: options.maxOutputBytes }),
|
||||||
|
...(options.maxOutputFiles === undefined
|
||||||
|
? {}
|
||||||
|
: { max_output_files: options.maxOutputFiles }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -32,6 +32,8 @@ export const CLI_COMMANDS = Object.freeze({
|
|||||||
resolveNativeCallTargets: "resolve-native-call-targets",
|
resolveNativeCallTargets: "resolve-native-call-targets",
|
||||||
inspectAssetCatalog: "inspect-asset-catalog",
|
inspectAssetCatalog: "inspect-asset-catalog",
|
||||||
inspectManagedArtifact: "inspect-managed-artifact",
|
inspectManagedArtifact: "inspect-managed-artifact",
|
||||||
|
inspectFirmwareRegions: "inspect-firmware-regions",
|
||||||
|
extractFirmware: "extract-firmware",
|
||||||
inspectAndroidPackage: "inspect-android-package",
|
inspectAndroidPackage: "inspect-android-package",
|
||||||
searchAndroidClasses: "search-android-classes",
|
searchAndroidClasses: "search-android-classes",
|
||||||
inspectAndroidClass: "inspect-android-class",
|
inspectAndroidClass: "inspect-android-class",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { OFFICIAL_TOOL_CONTRACTS } from "./officialToolContracts.js";
|
|||||||
import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
|
import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
|
||||||
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
||||||
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||||
|
import { FIRMWARE_TOOL_CONTRACTS } from "./firmwareToolContracts.js";
|
||||||
import { ANDROID_TOOL_CONTRACTS } from "./androidToolContracts.js";
|
import { ANDROID_TOOL_CONTRACTS } from "./androidToolContracts.js";
|
||||||
import { MANAGED_TOOL_CONTRACTS } from "./managedToolContracts.js";
|
import { MANAGED_TOOL_CONTRACTS } from "./managedToolContracts.js";
|
||||||
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managedWorkflowToolContracts.js";
|
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managedWorkflowToolContracts.js";
|
||||||
@@ -75,6 +76,7 @@ describe("tool contract surface", () => {
|
|||||||
...NATIVE_TOOL_CONTRACTS,
|
...NATIVE_TOOL_CONTRACTS,
|
||||||
...ARTIFACT_TOOL_CONTRACTS,
|
...ARTIFACT_TOOL_CONTRACTS,
|
||||||
...ANDROID_TOOL_CONTRACTS,
|
...ANDROID_TOOL_CONTRACTS,
|
||||||
|
...FIRMWARE_TOOL_CONTRACTS,
|
||||||
...MANAGED_TOOL_CONTRACTS,
|
...MANAGED_TOOL_CONTRACTS,
|
||||||
...MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
...MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||||
...BROWSER_TOOL_CONTRACTS,
|
...BROWSER_TOOL_CONTRACTS,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
firmwareInputSchemas,
|
||||||
|
firmwareResultSchemas,
|
||||||
|
} from "../domain/firmwareAnalysis.js";
|
||||||
|
import type { ToolContract } from "./toolContractTypes.js";
|
||||||
|
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||||
|
import { toolContractMetadata } from "./toolEffects.js";
|
||||||
|
|
||||||
|
/** Explicit local firmware observations and extraction, backed by caller-supplied tools. */
|
||||||
|
export const FIRMWARE_TOOL_CONTRACTS = [
|
||||||
|
{
|
||||||
|
name: "inspect_firmware_regions",
|
||||||
|
...toolContractMetadata("inspect_firmware_regions"),
|
||||||
|
kind: "firmware-provider",
|
||||||
|
description:
|
||||||
|
"Inspect signature candidates in a local firmware file with Binwalk 3.1.0. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool and util-linux prlimit required; 128 MiB input limit, one worker and 120-second deadline.",
|
||||||
|
inputSchema: firmwareInputSchemas.inspect_firmware_regions,
|
||||||
|
outputSchema: evidenceResultOf(
|
||||||
|
firmwareResultSchemas.inspect_firmware_regions,
|
||||||
|
),
|
||||||
|
examples: [
|
||||||
|
{
|
||||||
|
title: "Inspect firmware regions",
|
||||||
|
input: { path: "/tmp/firmware.bin" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract_firmware",
|
||||||
|
...toolContractMetadata("extract_firmware"),
|
||||||
|
kind: "firmware-provider",
|
||||||
|
description:
|
||||||
|
"Extract caller-selected firmware bytes with Unblob 26.6.4 into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and extraction diagnostics inline. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors and util-linux prlimit required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.",
|
||||||
|
inputSchema: firmwareInputSchemas.extract_firmware,
|
||||||
|
outputSchema: evidenceResultOf(firmwareResultSchemas.extract_firmware),
|
||||||
|
examples: [
|
||||||
|
{
|
||||||
|
title: "Extract selected firmware",
|
||||||
|
input: {
|
||||||
|
path: "/tmp/firmware.bin",
|
||||||
|
output_directory: "/tmp/firmware-output",
|
||||||
|
range: { offset: 64, length: 4096 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const satisfies readonly ToolContract[];
|
||||||
@@ -25,6 +25,7 @@ export const TOOL_KINDS = [
|
|||||||
"artifact-provider",
|
"artifact-provider",
|
||||||
"managed-provider",
|
"managed-provider",
|
||||||
"android-provider",
|
"android-provider",
|
||||||
|
"firmware-provider",
|
||||||
"browser-provider",
|
"browser-provider",
|
||||||
"electron-provider",
|
"electron-provider",
|
||||||
"runtime-provider",
|
"runtime-provider",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
|
|||||||
import { TOOL_CONTRACTS } from "./toolContracts.js";
|
import { TOOL_CONTRACTS } from "./toolContracts.js";
|
||||||
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||||
import { MANAGED_TOOL_CONTRACTS } from "./managedToolContracts.js";
|
import { MANAGED_TOOL_CONTRACTS } from "./managedToolContracts.js";
|
||||||
|
import { FIRMWARE_TOOL_CONTRACTS } from "./firmwareToolContracts.js";
|
||||||
import { ANDROID_TOOL_CONTRACTS } from "./androidToolContracts.js";
|
import { ANDROID_TOOL_CONTRACTS } from "./androidToolContracts.js";
|
||||||
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managedWorkflowToolContracts.js";
|
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managedWorkflowToolContracts.js";
|
||||||
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
||||||
@@ -21,6 +22,7 @@ const GROUPS = {
|
|||||||
artifact: ARTIFACT_TOOL_CONTRACTS,
|
artifact: ARTIFACT_TOOL_CONTRACTS,
|
||||||
managed: MANAGED_TOOL_CONTRACTS,
|
managed: MANAGED_TOOL_CONTRACTS,
|
||||||
android: ANDROID_TOOL_CONTRACTS,
|
android: ANDROID_TOOL_CONTRACTS,
|
||||||
|
firmware: FIRMWARE_TOOL_CONTRACTS,
|
||||||
managed_workflow: MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
managed_workflow: MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||||
browser_provider: BROWSER_PROVIDER_TOOL_CONTRACTS,
|
browser_provider: BROWSER_PROVIDER_TOOL_CONTRACTS,
|
||||||
electron: ELECTRON_TOOL_CONTRACTS,
|
electron: ELECTRON_TOOL_CONTRACTS,
|
||||||
@@ -93,6 +95,8 @@ const PUBLISHED_TOOL_NAME_FLOOR = [
|
|||||||
"inspect_electron_page",
|
"inspect_electron_page",
|
||||||
"inspect_macho",
|
"inspect_macho",
|
||||||
"inspect_managed_artifact",
|
"inspect_managed_artifact",
|
||||||
|
"inspect_firmware_regions",
|
||||||
|
"extract_firmware",
|
||||||
"inspect_android_package",
|
"inspect_android_package",
|
||||||
"search_android_classes",
|
"search_android_classes",
|
||||||
"inspect_android_class",
|
"inspect_android_class",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
||||||
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||||
import { MANAGED_TOOL_CONTRACTS } from "./managedToolContracts.js";
|
import { MANAGED_TOOL_CONTRACTS } from "./managedToolContracts.js";
|
||||||
|
import { FIRMWARE_TOOL_CONTRACTS } from "./firmwareToolContracts.js";
|
||||||
import { ANDROID_TOOL_CONTRACTS } from "./androidToolContracts.js";
|
import { ANDROID_TOOL_CONTRACTS } from "./androidToolContracts.js";
|
||||||
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managedWorkflowToolContracts.js";
|
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managedWorkflowToolContracts.js";
|
||||||
import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "./browserProviderToolContracts.js";
|
import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "./browserProviderToolContracts.js";
|
||||||
@@ -21,6 +22,7 @@ export const TOOL_CONTRACTS = [
|
|||||||
...ARTIFACT_TOOL_CONTRACTS,
|
...ARTIFACT_TOOL_CONTRACTS,
|
||||||
...MANAGED_TOOL_CONTRACTS,
|
...MANAGED_TOOL_CONTRACTS,
|
||||||
...ANDROID_TOOL_CONTRACTS,
|
...ANDROID_TOOL_CONTRACTS,
|
||||||
|
...FIRMWARE_TOOL_CONTRACTS,
|
||||||
...MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
...MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||||
...BROWSER_PROVIDER_TOOL_CONTRACTS,
|
...BROWSER_PROVIDER_TOOL_CONTRACTS,
|
||||||
...ELECTRON_TOOL_CONTRACTS,
|
...ELECTRON_TOOL_CONTRACTS,
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ const sessionEvidence = effects({ mutatesSession: true });
|
|||||||
|
|
||||||
/** Explicit effect audit for every public tool. */
|
/** Explicit effect audit for every public tool. */
|
||||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||||
|
inspect_firmware_regions: effects({
|
||||||
|
mutatesSession: true,
|
||||||
|
launchesProcess: true,
|
||||||
|
writesFilesystem: true,
|
||||||
|
}),
|
||||||
|
extract_firmware: effects({
|
||||||
|
mutatesSession: true,
|
||||||
|
launchesProcess: true,
|
||||||
|
writesFilesystem: true,
|
||||||
|
idempotent: false,
|
||||||
|
}),
|
||||||
inspect_android_package: effects({
|
inspect_android_package: effects({
|
||||||
mutatesSession: true,
|
mutatesSession: true,
|
||||||
launchesProcess: true,
|
launchesProcess: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
AnalysisCapabilityUnavailableError,
|
AnalysisCapabilityUnavailableError,
|
||||||
AnalysisInputError,
|
AnalysisInputError,
|
||||||
|
AnalysisOutputError,
|
||||||
} from "./analysisErrorCore.js";
|
} from "./analysisErrorCore.js";
|
||||||
import { ArtifactOperationError } from "./artifactOperationError.js";
|
import { ArtifactOperationError } from "./artifactOperationError.js";
|
||||||
import { BinaryTargetError } from "./configurationErrors.js";
|
import { BinaryTargetError } from "./configurationErrors.js";
|
||||||
@@ -160,6 +161,23 @@ describe("analysis error projection: provider failures", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("analysis error projection: caller contract", () => {
|
describe("analysis error projection: caller contract", () => {
|
||||||
|
it("preserves the reported output constraint without exposing its internal cause", () => {
|
||||||
|
const projected = projectAnalysisError(
|
||||||
|
new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
"Report exceeded the declared byte budget",
|
||||||
|
{ cause: new Error("private internal cause") },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(projected).toMatchObject({
|
||||||
|
code: "unreadable_output",
|
||||||
|
details: {
|
||||||
|
operation: "extract_firmware",
|
||||||
|
reason: "Report exceeded the declared byte budget",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(projected)).not.toContain("private internal cause");
|
||||||
|
});
|
||||||
it("maps representative failures without exposing causes", () => {
|
it("maps representative failures without exposing causes", () => {
|
||||||
const secretCause = new Error("secret-token");
|
const secretCause = new Error("secret-token");
|
||||||
const projected = [
|
const projected = [
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
AnalysisCapabilityUnavailableError,
|
AnalysisCapabilityUnavailableError,
|
||||||
AnalysisInputError,
|
AnalysisInputError,
|
||||||
|
AnalysisOutputError,
|
||||||
AnalysisTimeoutError,
|
AnalysisTimeoutError,
|
||||||
} from "./analysisErrorCore.js";
|
} from "./analysisErrorCore.js";
|
||||||
import { ArtifactOperationError } from "./artifactOperationError.js";
|
import { ArtifactOperationError } from "./artifactOperationError.js";
|
||||||
@@ -176,6 +177,8 @@ const errorDetails = (
|
|||||||
const requestErrorDetails = (
|
const requestErrorDetails = (
|
||||||
error: AnalysisError,
|
error: AnalysisError,
|
||||||
): Readonly<Record<string, JsonValue>> | undefined => {
|
): Readonly<Record<string, JsonValue>> | undefined => {
|
||||||
|
if (error instanceof AnalysisOutputError)
|
||||||
|
return { operation: error.operation, reason: error.reason };
|
||||||
if (error instanceof AnalysisInputError && error.issues.length > 0)
|
if (error instanceof AnalysisInputError && error.issues.length > 0)
|
||||||
return {
|
return {
|
||||||
operation: error.operation,
|
operation: error.operation,
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { digestSchema } from "./digests.js";
|
||||||
|
import { jsonValueSchema } from "./jsonValue.js";
|
||||||
|
|
||||||
|
const bytes = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||||
|
const range = z.strictObject({ offset: bytes, length: bytes.positive() });
|
||||||
|
const target = {
|
||||||
|
path: z.string().min(1).describe("Local firmware file; never executed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Explicit firmware inspection and extraction intent, independent of engines. */
|
||||||
|
export const firmwareInputSchemas = {
|
||||||
|
inspect_firmware_regions: z.strictObject(target),
|
||||||
|
extract_firmware: z.strictObject({
|
||||||
|
...target,
|
||||||
|
output_directory: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("Absolute, absent output directory; parent must exist"),
|
||||||
|
range: range
|
||||||
|
.optional()
|
||||||
|
.describe("Explicit file-byte interval; omit to extract the whole file"),
|
||||||
|
max_depth: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(10)
|
||||||
|
.default(3)
|
||||||
|
.describe("Extraction depth; reaching it yields partial analysis"),
|
||||||
|
max_output_bytes: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(512 * 1024 * 1024)
|
||||||
|
.default(256 * 1024 * 1024)
|
||||||
|
.describe(
|
||||||
|
"Staging file-byte budget, checked during extraction; transient overshoot is possible",
|
||||||
|
),
|
||||||
|
max_output_files: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100_000)
|
||||||
|
.default(10_000)
|
||||||
|
.describe("Staging entry budget, including directories and links"),
|
||||||
|
}),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Supported firmware operations. */
|
||||||
|
export type FirmwareOperation = keyof typeof firmwareInputSchemas;
|
||||||
|
/** Correlated, validated firmware request. */
|
||||||
|
export type FirmwareRequest = {
|
||||||
|
[Name in FirmwareOperation]: {
|
||||||
|
operation: Name;
|
||||||
|
input: z.infer<(typeof firmwareInputSchemas)[Name]>;
|
||||||
|
};
|
||||||
|
}[FirmwareOperation];
|
||||||
|
/** Validate the operation with its exact input contract. */
|
||||||
|
export const firmwareRequestSchema = z.discriminatedUnion("operation", [
|
||||||
|
z.strictObject({
|
||||||
|
operation: z.literal("inspect_firmware_regions"),
|
||||||
|
input: firmwareInputSchemas.inspect_firmware_regions,
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
operation: z.literal("extract_firmware"),
|
||||||
|
input: firmwareInputSchemas.extract_firmware,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const engine = z.strictObject({
|
||||||
|
name: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
executable_path: z.string(),
|
||||||
|
executable_sha256: digestSchema,
|
||||||
|
source_revision: z.null(),
|
||||||
|
worker_count: z.literal(1),
|
||||||
|
});
|
||||||
|
const selection = z.strictObject({ ...range.shape, sha256: digestSchema });
|
||||||
|
const chunk = z.strictObject({
|
||||||
|
input_path: z.string(),
|
||||||
|
provider_id: z.string(),
|
||||||
|
range,
|
||||||
|
handler: z.string().nullable(),
|
||||||
|
encrypted: z.boolean().nullable(),
|
||||||
|
root_file_range: range.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Portable results retain reported boundaries, unknowns and extraction lineage. */
|
||||||
|
export const firmwareResultSchemas = {
|
||||||
|
inspect_firmware_regions: z.strictObject({
|
||||||
|
engine,
|
||||||
|
input_size: bytes,
|
||||||
|
regions: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
provider_id: z.string(),
|
||||||
|
offset: bytes,
|
||||||
|
reported_size: bytes,
|
||||||
|
signature: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
reported_confidence: z.number().int().min(0).max(255),
|
||||||
|
size_basis: z.literal("provider_reported_validation_unknown"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
coverage: z.literal("signature_scan"),
|
||||||
|
}),
|
||||||
|
extract_firmware: z.strictObject({
|
||||||
|
engine,
|
||||||
|
selection,
|
||||||
|
output_directory: z.string(),
|
||||||
|
coverage: z.enum(["complete", "partial"]),
|
||||||
|
files: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
relative_path: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
size: bytes,
|
||||||
|
sha256: digestSchema,
|
||||||
|
reported_sha256: digestSchema.nullable(),
|
||||||
|
mime_type: z.string().nullable(),
|
||||||
|
analysis_depth: bytes.nullable(),
|
||||||
|
runtime_address: z.null(),
|
||||||
|
original_file_range: z.null(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
unpublished_entries: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
relative_path: z.string(),
|
||||||
|
kind: z.enum(["symlink", "special"]),
|
||||||
|
link_target: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
chunks: z.array(chunk),
|
||||||
|
derivations: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
parent_path: z.string(),
|
||||||
|
child_path: z.string(),
|
||||||
|
provider_blob_id: z.string(),
|
||||||
|
handler: z.string().nullable(),
|
||||||
|
parent_file_range: range.nullable(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
depth_limited_paths: z.array(z.string()),
|
||||||
|
diagnostics: z.array(jsonValueSchema),
|
||||||
|
published_bytes: bytes,
|
||||||
|
}),
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { access, realpath } from "node:fs/promises";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { isAbsolute } from "node:path";
|
||||||
|
import {
|
||||||
|
AnalysisCapabilityUnavailableError,
|
||||||
|
AnalysisCancelledError,
|
||||||
|
AnalysisTimeoutError,
|
||||||
|
AnalysisOutputError,
|
||||||
|
} from "../domain/analysisErrorCore.js";
|
||||||
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||||
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||||
|
import {
|
||||||
|
spawnOwnedProviderProcess,
|
||||||
|
ProviderProcessSupervisor,
|
||||||
|
type OwnedProviderProcessSpawnOptions,
|
||||||
|
type SpawnedOwnedProviderProcess,
|
||||||
|
} from "../process/ProviderProcess.js";
|
||||||
|
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
||||||
|
import { FIRMWARE_LIMITS } from "./FirmwareRelease.js";
|
||||||
|
import {
|
||||||
|
hashFirmwareFile,
|
||||||
|
inventoryFirmwareOutput,
|
||||||
|
readFirmwareReport,
|
||||||
|
} from "./FirmwareFiles.js";
|
||||||
|
|
||||||
|
/** Injectable owned launcher for production protocol and lifecycle tests. */
|
||||||
|
export type FirmwareLauncher = (
|
||||||
|
options: OwnedProviderProcessSpawnOptions,
|
||||||
|
) => Promise<SpawnedOwnedProviderProcess>;
|
||||||
|
|
||||||
|
/** Resolve explicitly supplied executable paths; never install or search for engines. */
|
||||||
|
export const resolveFirmwareCommand = async (
|
||||||
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
|
engine: "binwalk" | "unblob",
|
||||||
|
operation: string,
|
||||||
|
) => {
|
||||||
|
const variable =
|
||||||
|
engine === "binwalk" ? "REA_BINWALK_COMMAND" : "REA_UNBLOB_COMMAND";
|
||||||
|
const configured = environment[variable];
|
||||||
|
if (
|
||||||
|
process.platform !== "linux" ||
|
||||||
|
configured === undefined ||
|
||||||
|
!isAbsolute(configured)
|
||||||
|
)
|
||||||
|
throw new AnalysisCapabilityUnavailableError(
|
||||||
|
engine,
|
||||||
|
operation,
|
||||||
|
`This integration requires Linux and an absolute ${variable} path to the caller-supplied tool`,
|
||||||
|
);
|
||||||
|
const command = await realpath(configured).catch((cause: unknown) => {
|
||||||
|
throw new AnalysisCapabilityUnavailableError(
|
||||||
|
engine,
|
||||||
|
operation,
|
||||||
|
`${variable} is unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await access(command, constants.X_OK);
|
||||||
|
const limiter =
|
||||||
|
environment.REA_FIRMWARE_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
|
||||||
|
if (!isAbsolute(limiter))
|
||||||
|
throw new AnalysisCapabilityUnavailableError(
|
||||||
|
engine,
|
||||||
|
operation,
|
||||||
|
"REA_FIRMWARE_PRLIMIT_COMMAND must be absolute",
|
||||||
|
);
|
||||||
|
await access(limiter, constants.X_OK).catch(() => {
|
||||||
|
throw new AnalysisCapabilityUnavailableError(
|
||||||
|
engine,
|
||||||
|
operation,
|
||||||
|
`Resource limiter unavailable: ${limiter}; provide util-linux prlimit`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { command, limiter, sha256: await hashFirmwareFile(command) };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Execute one bounded provider command and verify its owned descendants have stopped. */
|
||||||
|
export const runFirmwareCommand = async (context: {
|
||||||
|
engine: string;
|
||||||
|
operation: string;
|
||||||
|
command: string;
|
||||||
|
limiter: string;
|
||||||
|
args: readonly string[];
|
||||||
|
cwd: string;
|
||||||
|
environment: Readonly<Record<string, string | undefined>>;
|
||||||
|
signal: AbortSignal;
|
||||||
|
launcher?: FirmwareLauncher;
|
||||||
|
outputBudget?: { root: string; bytes: number; entries: number };
|
||||||
|
acceptReportedExtractionFailure?: boolean;
|
||||||
|
}) => {
|
||||||
|
const { engine, operation, signal } = context;
|
||||||
|
signal.throwIfAborted();
|
||||||
|
const runId = `rea-firmware-${randomUUID()}`;
|
||||||
|
const spawned = await (context.launcher ?? spawnOwnedProviderProcess)({
|
||||||
|
command: context.limiter,
|
||||||
|
arguments: [
|
||||||
|
`--as=${FIRMWARE_LIMITS.addressSpaceBytes}`,
|
||||||
|
`--fsize=${context.outputBudget?.bytes ?? FIRMWARE_LIMITS.reportBytes}`,
|
||||||
|
"--core=0",
|
||||||
|
"--cpu=120",
|
||||||
|
"--",
|
||||||
|
context.command,
|
||||||
|
...context.args,
|
||||||
|
],
|
||||||
|
expectedCommand: null,
|
||||||
|
runId,
|
||||||
|
cwd: context.cwd,
|
||||||
|
hostEnvironment: context.environment,
|
||||||
|
env: {
|
||||||
|
TMPDIR: context.cwd,
|
||||||
|
OMP_NUM_THREADS: "1",
|
||||||
|
OPENBLAS_NUM_THREADS: "1",
|
||||||
|
RAYON_NUM_THREADS: "1",
|
||||||
|
NO_COLOR: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const supervisor = new ProviderProcessSupervisor({
|
||||||
|
...spawned,
|
||||||
|
ownsProcessLifetime: true,
|
||||||
|
cleanup:
|
||||||
|
spawned.cleanup ?? (() => cleanupOwnedProcessGroup(spawned.ownership)),
|
||||||
|
});
|
||||||
|
let failure: unknown;
|
||||||
|
try {
|
||||||
|
const deadline = Date.now() + FIRMWARE_LIMITS.timeoutMs;
|
||||||
|
while (!(await supervisor.waitForExit(100))) {
|
||||||
|
if (signal.aborted) throw new AnalysisCancelledError(operation);
|
||||||
|
if (Date.now() >= deadline)
|
||||||
|
throw new AnalysisTimeoutError(operation, FIRMWARE_LIMITS.timeoutMs);
|
||||||
|
if (context.outputBudget !== undefined)
|
||||||
|
await inventoryFirmwareOutput(
|
||||||
|
context.outputBudget.root,
|
||||||
|
context.outputBudget.bytes,
|
||||||
|
context.outputBudget.entries,
|
||||||
|
operation,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (signal.aborted) throw new AnalysisCancelledError(operation);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
failure = cause;
|
||||||
|
}
|
||||||
|
const stopped = await supervisor.stop();
|
||||||
|
const snapshot = supervisor.snapshot();
|
||||||
|
supervisor.dispose();
|
||||||
|
if (stopped.status === "incomplete")
|
||||||
|
throw new ProviderCleanupError(engine, [runId, context.cwd], {
|
||||||
|
reason: stopped.reason,
|
||||||
|
leader_pid: spawned.ownership.leaderPid,
|
||||||
|
process_group_id: spawned.ownership.processGroupId,
|
||||||
|
exit_code: snapshot.exitCode ?? null,
|
||||||
|
signal: snapshot.signal ?? null,
|
||||||
|
stdout: snapshot.stdout.text,
|
||||||
|
stderr: snapshot.stderr.text,
|
||||||
|
stdout_bytes: snapshot.stdout.bytes,
|
||||||
|
stderr_bytes: snapshot.stderr.bytes,
|
||||||
|
previous_error: failure instanceof Error ? failure.message : null,
|
||||||
|
});
|
||||||
|
if (failure !== undefined) throw failure;
|
||||||
|
if (
|
||||||
|
snapshot.exitCode !== 0 &&
|
||||||
|
!(
|
||||||
|
context.acceptReportedExtractionFailure === true &&
|
||||||
|
snapshot.exitCode === 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new ProviderAdapterError(engine, operation, {
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
"Tool failed; inspect exit status and stderr for format, dependency, sandbox or resource-limit failures",
|
||||||
|
exit_code: snapshot.exitCode ?? null,
|
||||||
|
signal: snapshot.signal ?? null,
|
||||||
|
stdout: snapshot.stdout.text,
|
||||||
|
stderr: snapshot.stderr.text,
|
||||||
|
stdout_bytes: snapshot.stdout.bytes,
|
||||||
|
stderr_bytes: snapshot.stderr.bytes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
stdout: snapshot.stdout,
|
||||||
|
stderr: snapshot.stderr,
|
||||||
|
exit_code: snapshot.exitCode,
|
||||||
|
signal: snapshot.signal ?? null,
|
||||||
|
command: context.command,
|
||||||
|
args: [...context.args],
|
||||||
|
resource_limiter: context.limiter,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Preserve tool failure diagnostics when an accepted exit 1 has no usable report. */
|
||||||
|
export const readFirmwareCommandReport = async (context: {
|
||||||
|
path: string;
|
||||||
|
engine: string;
|
||||||
|
operation: string;
|
||||||
|
execution: Awaited<ReturnType<typeof runFirmwareCommand>>;
|
||||||
|
}): Promise<unknown> => {
|
||||||
|
try {
|
||||||
|
return await readFirmwareReport(context.path, context.operation);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (context.execution.exit_code === 1)
|
||||||
|
throw new ProviderAdapterError(context.engine, context.operation, {
|
||||||
|
cause,
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
"Tool exited 1 without a usable analysis report; inspect stderr for startup, sandbox or dependency failures",
|
||||||
|
report_failure:
|
||||||
|
cause instanceof Error ? cause.message : String(cause),
|
||||||
|
exit_code: 1,
|
||||||
|
signal: context.execution.signal,
|
||||||
|
stdout: context.execution.stdout.text,
|
||||||
|
stderr: context.execution.stderr.text,
|
||||||
|
stdout_bytes: context.execution.stdout.bytes,
|
||||||
|
stderr_bytes: context.execution.stderr.bytes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const diagnostics = `stdout (${context.execution.stdout.bytes} bytes): ${context.execution.stdout.text}; stderr (${context.execution.stderr.bytes} bytes): ${context.execution.stderr.text}`;
|
||||||
|
if (cause instanceof AnalysisOutputError)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
context.operation,
|
||||||
|
`${cause.reason}; ${diagnostics}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
context.operation,
|
||||||
|
`Tool did not produce the expected complete report: ${context.path}; ${diagnostics}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { constants, createReadStream, createWriteStream } from "node:fs";
|
||||||
|
import {
|
||||||
|
chmod,
|
||||||
|
lstat,
|
||||||
|
open,
|
||||||
|
readdir,
|
||||||
|
readlink,
|
||||||
|
realpath,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||||
|
import { Transform } from "node:stream";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import {
|
||||||
|
AnalysisCancelledError,
|
||||||
|
AnalysisInputError,
|
||||||
|
AnalysisOutputError,
|
||||||
|
} from "../domain/analysisErrorCore.js";
|
||||||
|
import type { FirmwareRequest } from "../domain/firmwareAnalysis.js";
|
||||||
|
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||||
|
import { FIRMWARE_LIMITS } from "./FirmwareRelease.js";
|
||||||
|
|
||||||
|
/** One regular file or unpublished filesystem entry in a private extraction. */
|
||||||
|
export interface FirmwareEntry {
|
||||||
|
readonly relativePath: string;
|
||||||
|
readonly path: string;
|
||||||
|
readonly size: number;
|
||||||
|
readonly kind: "file" | "symlink" | "special";
|
||||||
|
readonly linkTarget: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hash a regular tool or extracted file without retaining its bytes in memory. */
|
||||||
|
export const hashFirmwareFile = async (
|
||||||
|
path: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<string> => {
|
||||||
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
try {
|
||||||
|
if (!(await handle.stat()).isFile())
|
||||||
|
throw new Error(`Not a regular file: ${path}`);
|
||||||
|
for await (const chunk of handle.createReadStream({ autoClose: false })) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
hash.update(chunk);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
return hash.digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Copy and identify the exact bytes consumed by firmware providers. */
|
||||||
|
export const snapshotFirmware = async (
|
||||||
|
request: FirmwareRequest,
|
||||||
|
root: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => {
|
||||||
|
const sourcePath = resolve(request.input.path);
|
||||||
|
const source = await realpath(sourcePath);
|
||||||
|
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
|
const fullPath = join(root, "firmware.bin");
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
const stat = await handle.stat();
|
||||||
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.size === 0 ||
|
||||||
|
stat.size > FIRMWARE_LIMITS.inputBytes
|
||||||
|
)
|
||||||
|
throw new AnalysisInputError(request.operation, undefined, [
|
||||||
|
{
|
||||||
|
path: ["path"],
|
||||||
|
reason: "out_of_range",
|
||||||
|
message: `Firmware must be a nonempty regular file no larger than ${FIRMWARE_LIMITS.inputBytes} bytes`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const meter = new Transform({
|
||||||
|
transform(chunk: Buffer, _encoding, callback) {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > FIRMWARE_LIMITS.inputBytes) {
|
||||||
|
callback(new Error("Firmware grew beyond the input byte budget"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hash.update(chunk);
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await pipeline(
|
||||||
|
handle.createReadStream({ autoClose: false }),
|
||||||
|
meter,
|
||||||
|
createWriteStream(fullPath, { flags: "wx", mode: 0o600 }),
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
await chmod(fullPath, 0o400);
|
||||||
|
const sha256 = hash.digest("hex");
|
||||||
|
const selected =
|
||||||
|
request.operation === "extract_firmware" ? request.input.range : undefined;
|
||||||
|
const offset = selected?.offset ?? 0;
|
||||||
|
const length = selected?.length ?? size;
|
||||||
|
if (length === 0 || offset > size || length > size - offset)
|
||||||
|
throw new AnalysisInputError(request.operation, undefined, [
|
||||||
|
{
|
||||||
|
path: ["range"],
|
||||||
|
reason: "out_of_range",
|
||||||
|
message: `Selected range must be within the ${size}-byte firmware file`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let inputPath = fullPath;
|
||||||
|
if (selected !== undefined) {
|
||||||
|
inputPath = join(root, "selected.bin");
|
||||||
|
await pipeline(
|
||||||
|
createReadStream(fullPath, { start: offset, end: offset + length - 1 }),
|
||||||
|
createWriteStream(inputPath, { flags: "wx", mode: 0o600 }),
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
await chmod(inputPath, 0o400);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subject: { path: sourcePath, sha256, format: "file" as const },
|
||||||
|
size,
|
||||||
|
inputPath,
|
||||||
|
selection: {
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
sha256:
|
||||||
|
selected === undefined
|
||||||
|
? sha256
|
||||||
|
: await hashFirmwareFile(inputPath, signal),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read a complete bounded report; oversized data is never reported as complete. */
|
||||||
|
export const readFirmwareReport = async (
|
||||||
|
path: string,
|
||||||
|
operation: string,
|
||||||
|
): Promise<unknown> => {
|
||||||
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
|
try {
|
||||||
|
if (!(await handle.stat()).isFile())
|
||||||
|
throw new AnalysisOutputError(operation, "Report is not a regular file");
|
||||||
|
const bytes = await readBoundedFileBytes(
|
||||||
|
handle,
|
||||||
|
FIRMWARE_LIMITS.reportBytes,
|
||||||
|
);
|
||||||
|
if (bytes === undefined)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
operation,
|
||||||
|
`Report exceeded ${FIRMWARE_LIMITS.reportBytes} bytes`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(bytes.toString("utf8"));
|
||||||
|
return value;
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
operation,
|
||||||
|
"Tool report is not valid complete JSON",
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Inventory private output without following links, enforcing aggregate staging budgets. */
|
||||||
|
export const inventoryFirmwareOutput = async (
|
||||||
|
root: string,
|
||||||
|
maxBytes: number,
|
||||||
|
maxEntries: number,
|
||||||
|
operation: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<FirmwareEntry[]> => {
|
||||||
|
const entries: FirmwareEntry[] = [];
|
||||||
|
const pending = [root];
|
||||||
|
let bytes = 0;
|
||||||
|
let count = 0;
|
||||||
|
while (pending.length > 0) {
|
||||||
|
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
|
||||||
|
const directory = pending.pop();
|
||||||
|
if (directory === undefined) break;
|
||||||
|
const directoryStat = await lstat(directory).catch((cause: unknown) => {
|
||||||
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||||
|
return undefined;
|
||||||
|
throw cause;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
directoryStat === undefined ||
|
||||||
|
!directoryStat.isDirectory() ||
|
||||||
|
directoryStat.isSymbolicLink()
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const names = await readdir(directory).catch((cause: unknown) => {
|
||||||
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||||
|
return [];
|
||||||
|
throw cause;
|
||||||
|
});
|
||||||
|
for (const name of names.sort()) {
|
||||||
|
count++;
|
||||||
|
if (count > maxEntries)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
operation,
|
||||||
|
`Staging entry budget exceeded (${maxEntries})`,
|
||||||
|
);
|
||||||
|
const path = join(directory, name);
|
||||||
|
const stat = await lstat(path).catch((cause: unknown) => {
|
||||||
|
if (
|
||||||
|
cause instanceof Error &&
|
||||||
|
"code" in cause &&
|
||||||
|
cause.code === "ENOENT"
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
throw cause;
|
||||||
|
});
|
||||||
|
if (stat === undefined) continue;
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
pending.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stat.isFile()) bytes += stat.size;
|
||||||
|
if (bytes > maxBytes)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
operation,
|
||||||
|
`Staging byte budget exceeded (${maxBytes})`,
|
||||||
|
);
|
||||||
|
entries.push({
|
||||||
|
path,
|
||||||
|
relativePath: relative(root, path).split(sep).join("/"),
|
||||||
|
size: stat.size,
|
||||||
|
kind: stat.isSymbolicLink()
|
||||||
|
? "symlink"
|
||||||
|
: stat.isFile()
|
||||||
|
? "file"
|
||||||
|
: "special",
|
||||||
|
linkTarget: stat.isSymbolicLink() ? await readlink(path) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bind producer paths to the selected input or private extraction tree. */
|
||||||
|
export const firmwareLogicalPath = (
|
||||||
|
path: string,
|
||||||
|
inputPath: string,
|
||||||
|
outputRoot: string,
|
||||||
|
): string => {
|
||||||
|
if (path === inputPath) return "$input";
|
||||||
|
const rel = relative(outputRoot, path);
|
||||||
|
if (
|
||||||
|
!isAbsolute(path) ||
|
||||||
|
rel === ".." ||
|
||||||
|
rel.startsWith(`..${sep}`) ||
|
||||||
|
isAbsolute(rel)
|
||||||
|
)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
`Provider reported a path outside its owned output: ${path}`,
|
||||||
|
);
|
||||||
|
return rel === "" ? "$output" : `$output/${rel.split(sep).join("/")}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { join, isAbsolute } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { FirmwareAnalysisPort } from "../application/FirmwareAnalysisPort.js";
|
||||||
|
import {
|
||||||
|
createAnalysisExecution,
|
||||||
|
type ExecutionOptions,
|
||||||
|
} from "../application/AnalysisProvider.js";
|
||||||
|
import {
|
||||||
|
firmwareResultSchemas,
|
||||||
|
type FirmwareRequest,
|
||||||
|
} from "../domain/firmwareAnalysis.js";
|
||||||
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
|
import {
|
||||||
|
AnalysisCancelledError,
|
||||||
|
AnalysisCapabilityUnavailableError,
|
||||||
|
AnalysisInputError,
|
||||||
|
AnalysisOutputError,
|
||||||
|
AnalysisTimeoutError,
|
||||||
|
} from "../domain/analysisErrorCore.js";
|
||||||
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||||
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||||
|
import { err, ok } from "../domain/result.js";
|
||||||
|
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||||
|
import { publishFirmwareExtraction } from "./FirmwarePublication.js";
|
||||||
|
import {
|
||||||
|
FIRMWARE_LIMITS,
|
||||||
|
FIRMWARE_RELEASES,
|
||||||
|
BINWALK_PROVIDER_IDENTITY,
|
||||||
|
UNBLOB_PROVIDER_IDENTITY,
|
||||||
|
} from "./FirmwareRelease.js";
|
||||||
|
import {
|
||||||
|
resolveFirmwareCommand,
|
||||||
|
runFirmwareCommand,
|
||||||
|
readFirmwareCommandReport,
|
||||||
|
type FirmwareLauncher,
|
||||||
|
} from "./FirmwareCommand.js";
|
||||||
|
import { inventoryFirmwareOutput, snapshotFirmware } from "./FirmwareFiles.js";
|
||||||
|
import {
|
||||||
|
normalizeBinwalkReport,
|
||||||
|
normalizeUnblobReport,
|
||||||
|
} from "./FirmwareReports.js";
|
||||||
|
|
||||||
|
type Outcome = Awaited<ReturnType<FirmwareAnalysisPort["execute"]>>;
|
||||||
|
const waitForPredecessor = (
|
||||||
|
predecessor: Promise<void>,
|
||||||
|
operation: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const abort = () => reject(new AnalysisCancelledError(operation));
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
if (signal?.aborted === true) abort();
|
||||||
|
void predecessor.then(() => {
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const limitations = [
|
||||||
|
"Signature matches and reported sizes do not establish partition validity, encryption or runtime mappings. Binwalk may infer unknown lengths before emitting its report.",
|
||||||
|
"Only Linux is supported by this integration. One worker, inherited per-process address-space/file-size/CPU limits and a deadline constrain execution; staging byte and entry budgets are polled and may transiently overshoot. Limits are not aggregate process-memory or filesystem quotas.",
|
||||||
|
"Unblob's default sandbox and skip rules remain enabled. Missing external extractors, unknown chunks, encrypted chunks and depth limits yield partial coverage. Extracted code is never executed; only regular files are published, without executable permission bits.",
|
||||||
|
"The actual launcher executable is hashed. Interpreter, shared libraries and installed Python package dependencies are not independently attested; source revision remains unknown for caller-supplied builds.",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Queued bring-your-own firmware adapters with private snapshots and owned cleanup. */
|
||||||
|
export class FirmwareProvider implements FirmwareAnalysisPort {
|
||||||
|
#tail: Promise<void> = Promise.resolve();
|
||||||
|
#cleanupFailure: AnalysisError | undefined;
|
||||||
|
constructor(
|
||||||
|
readonly environment: Readonly<
|
||||||
|
Record<string, string | undefined>
|
||||||
|
> = process.env,
|
||||||
|
readonly launcher?: FirmwareLauncher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Inspect regions or explicitly extract selected bytes without changing the source. */
|
||||||
|
async execute(
|
||||||
|
request: FirmwareRequest,
|
||||||
|
options?: ExecutionOptions,
|
||||||
|
): Promise<Outcome> {
|
||||||
|
const predecessor = this.#tail;
|
||||||
|
let release = () => {};
|
||||||
|
this.#tail = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
// Cancel the caller promptly without letting a later request bypass its predecessor.
|
||||||
|
try {
|
||||||
|
await waitForPredecessor(predecessor, request.operation, options?.signal);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
void predecessor.then(release);
|
||||||
|
return err(
|
||||||
|
cause instanceof AnalysisError
|
||||||
|
? cause
|
||||||
|
: new AnalysisCancelledError(request.operation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.#execute(request, options);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #execute(
|
||||||
|
request: FirmwareRequest,
|
||||||
|
options?: ExecutionOptions,
|
||||||
|
): Promise<Outcome> {
|
||||||
|
let root: PrivateRuntimeRoot | undefined;
|
||||||
|
const engineName =
|
||||||
|
request.operation === "inspect_firmware_regions" ? "binwalk" : "unblob";
|
||||||
|
const deadline = AbortSignal.timeout(FIRMWARE_LIMITS.timeoutMs);
|
||||||
|
const signal =
|
||||||
|
options?.signal === undefined
|
||||||
|
? deadline
|
||||||
|
: AbortSignal.any([options.signal, deadline]);
|
||||||
|
let outcome: Outcome;
|
||||||
|
try {
|
||||||
|
if (this.#cleanupFailure !== undefined) return err(this.#cleanupFailure);
|
||||||
|
if (signal.aborted) throw new AnalysisCancelledError(request.operation);
|
||||||
|
const command = await resolveFirmwareCommand(
|
||||||
|
this.environment,
|
||||||
|
engineName,
|
||||||
|
request.operation,
|
||||||
|
);
|
||||||
|
root = await PrivateRuntimeRoot.create({ prefix: "rea-firmware-" });
|
||||||
|
const target = await snapshotFirmware(request, root.path, signal);
|
||||||
|
const run = (
|
||||||
|
args: readonly string[],
|
||||||
|
outputBudget?: { root: string; bytes: number; entries: number },
|
||||||
|
) =>
|
||||||
|
runFirmwareCommand({
|
||||||
|
engine: engineName,
|
||||||
|
operation: request.operation,
|
||||||
|
...command,
|
||||||
|
args,
|
||||||
|
cwd: root?.path ?? "",
|
||||||
|
environment: this.environment,
|
||||||
|
signal,
|
||||||
|
...(this.launcher === undefined ? {} : { launcher: this.launcher }),
|
||||||
|
...(outputBudget === undefined
|
||||||
|
? {}
|
||||||
|
: { outputBudget, acceptReportedExtractionFailure: true }),
|
||||||
|
});
|
||||||
|
const versionRun = await run(["--version"]);
|
||||||
|
const version = versionRun.stdout.text.trim();
|
||||||
|
const expected = FIRMWARE_RELEASES[engineName].version;
|
||||||
|
if (
|
||||||
|
versionRun.stdout.bytes > 1024 ||
|
||||||
|
(engineName === "binwalk"
|
||||||
|
? version !== `binwalk ${expected}`
|
||||||
|
: version !== expected)
|
||||||
|
)
|
||||||
|
throw new AnalysisCapabilityUnavailableError(
|
||||||
|
engineName,
|
||||||
|
request.operation,
|
||||||
|
`Unsupported tool version ${version}; this adapter is verified against ${expected}`,
|
||||||
|
);
|
||||||
|
const engine = {
|
||||||
|
name: engineName,
|
||||||
|
version: expected,
|
||||||
|
executable_path: command.command,
|
||||||
|
executable_sha256: command.sha256,
|
||||||
|
source_revision: null,
|
||||||
|
worker_count: 1 as const,
|
||||||
|
};
|
||||||
|
const reportPath = join(root.path, "report.json");
|
||||||
|
let result: unknown;
|
||||||
|
let raw: unknown;
|
||||||
|
if (request.operation === "inspect_firmware_regions") {
|
||||||
|
const processRun = await run([
|
||||||
|
"--quiet",
|
||||||
|
"--search-all",
|
||||||
|
"--threads",
|
||||||
|
"1",
|
||||||
|
"--log",
|
||||||
|
reportPath,
|
||||||
|
target.inputPath,
|
||||||
|
]);
|
||||||
|
const report = await readFirmwareCommandReport({
|
||||||
|
path: reportPath,
|
||||||
|
engine: engineName,
|
||||||
|
operation: request.operation,
|
||||||
|
execution: processRun,
|
||||||
|
});
|
||||||
|
result = firmwareResultSchemas.inspect_firmware_regions.parse({
|
||||||
|
engine,
|
||||||
|
input_size: target.size,
|
||||||
|
regions: normalizeBinwalkReport(
|
||||||
|
report,
|
||||||
|
target.inputPath,
|
||||||
|
target.size,
|
||||||
|
),
|
||||||
|
coverage: "signature_scan",
|
||||||
|
});
|
||||||
|
raw = { report, execution: processRun, version_execution: versionRun };
|
||||||
|
} else {
|
||||||
|
if (!isAbsolute(request.input.output_directory))
|
||||||
|
throw new AnalysisInputError(request.operation, undefined, [
|
||||||
|
{
|
||||||
|
path: ["output_directory"],
|
||||||
|
reason: "invalid_format",
|
||||||
|
message: "Extraction output directory must be absolute",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const outputRoot = join(root.path, "extracted");
|
||||||
|
const processRun = await run(
|
||||||
|
[
|
||||||
|
"--process-num",
|
||||||
|
"1",
|
||||||
|
"--depth",
|
||||||
|
String(request.input.max_depth),
|
||||||
|
"--randomness-depth",
|
||||||
|
"0",
|
||||||
|
"--report",
|
||||||
|
reportPath,
|
||||||
|
"--log",
|
||||||
|
join(root.path, "unblob.log"),
|
||||||
|
"--extract-dir",
|
||||||
|
outputRoot,
|
||||||
|
target.inputPath,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
root: outputRoot,
|
||||||
|
bytes: request.input.max_output_bytes,
|
||||||
|
entries: request.input.max_output_files,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const report = await readFirmwareCommandReport({
|
||||||
|
path: reportPath,
|
||||||
|
engine: engineName,
|
||||||
|
operation: request.operation,
|
||||||
|
execution: processRun,
|
||||||
|
});
|
||||||
|
const normalized = normalizeUnblobReport(report, {
|
||||||
|
inputPath: target.inputPath,
|
||||||
|
outputRoot,
|
||||||
|
...target.selection,
|
||||||
|
maxDepth: request.input.max_depth,
|
||||||
|
});
|
||||||
|
if (processRun.exit_code === 1 && normalized.diagnostics.length === 0)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
request.operation,
|
||||||
|
"Unblob failed without a reported extraction diagnostic",
|
||||||
|
);
|
||||||
|
const entries = await inventoryFirmwareOutput(
|
||||||
|
outputRoot,
|
||||||
|
request.input.max_output_bytes,
|
||||||
|
request.input.max_output_files,
|
||||||
|
request.operation,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
result = await publishFirmwareExtraction({
|
||||||
|
outputDirectory: request.input.output_directory,
|
||||||
|
entries,
|
||||||
|
normalized,
|
||||||
|
selection: target.selection,
|
||||||
|
engine,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
raw = { report, execution: processRun, version_execution: versionRun };
|
||||||
|
}
|
||||||
|
outcome = ok(
|
||||||
|
createAnalysisExecution(
|
||||||
|
result,
|
||||||
|
engineName === "binwalk"
|
||||||
|
? BINWALK_PROVIDER_IDENTITY
|
||||||
|
: UNBLOB_PROVIDER_IDENTITY,
|
||||||
|
{
|
||||||
|
subject: target.subject,
|
||||||
|
rawResult: raw,
|
||||||
|
limitations,
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
kind: "file-offset-range",
|
||||||
|
start: target.selection.offset,
|
||||||
|
end: target.selection.offset + target.selection.length,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
outcome = err(
|
||||||
|
cause instanceof ProviderCleanupError
|
||||||
|
? cause
|
||||||
|
: options?.signal?.aborted === true
|
||||||
|
? new AnalysisCancelledError(request.operation)
|
||||||
|
: deadline.aborted
|
||||||
|
? new AnalysisTimeoutError(
|
||||||
|
request.operation,
|
||||||
|
FIRMWARE_LIMITS.timeoutMs,
|
||||||
|
)
|
||||||
|
: cause instanceof AnalysisError
|
||||||
|
? cause
|
||||||
|
: cause instanceof z.ZodError
|
||||||
|
? new AnalysisOutputError(
|
||||||
|
request.operation,
|
||||||
|
"Tool report violated the pinned producer protocol",
|
||||||
|
{ cause },
|
||||||
|
)
|
||||||
|
: new ProviderAdapterError(engineName, request.operation, {
|
||||||
|
cause,
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: String(cause),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!outcome.ok && outcome.error.cleanupIncomplete) {
|
||||||
|
this.#cleanupFailure =
|
||||||
|
root === undefined ||
|
||||||
|
outcome.error.cleanupResources.includes(root.path)
|
||||||
|
? outcome.error
|
||||||
|
: new ProviderCleanupError(
|
||||||
|
engineName,
|
||||||
|
[...outcome.error.cleanupResources, root.path],
|
||||||
|
outcome.error instanceof ProviderAdapterError
|
||||||
|
? (outcome.error.diagnostics ?? {
|
||||||
|
reason: outcome.error.message,
|
||||||
|
})
|
||||||
|
: { reason: outcome.error.message },
|
||||||
|
{ cause: outcome.error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Never remove a workspace while process ownership remains uncertain.
|
||||||
|
if (this.#cleanupFailure !== undefined) return err(this.#cleanupFailure);
|
||||||
|
try {
|
||||||
|
await root?.close();
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
this.#cleanupFailure = new ProviderCleanupError(
|
||||||
|
engineName,
|
||||||
|
[
|
||||||
|
root?.path ?? "unknown",
|
||||||
|
...(outcome.ok && request.operation === "extract_firmware"
|
||||||
|
? [request.input.output_directory]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
reason: cause instanceof Error ? cause.message : String(cause),
|
||||||
|
previous_result: outcome.ok ? outcome.value.result : null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return err(this.#cleanupFailure);
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { SafeOutputTree } from "../artifacts/SafeOutputTree.js";
|
||||||
|
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||||
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||||
|
import { firmwareResultSchemas } from "../domain/firmwareAnalysis.js";
|
||||||
|
import { hashFirmwareFile, type FirmwareEntry } from "./FirmwareFiles.js";
|
||||||
|
import type { normalizeUnblobReport } from "./FirmwareReports.js";
|
||||||
|
|
||||||
|
/** Publish verified regular files, preserving links as metadata and rolling back failed writes. */
|
||||||
|
export const publishFirmwareExtraction = async (context: {
|
||||||
|
outputDirectory: string;
|
||||||
|
entries: readonly FirmwareEntry[];
|
||||||
|
normalized: ReturnType<typeof normalizeUnblobReport>;
|
||||||
|
selection: { offset: number; length: number; sha256: string };
|
||||||
|
engine: z.infer<typeof firmwareResultSchemas.extract_firmware>["engine"];
|
||||||
|
signal: AbortSignal;
|
||||||
|
}) => {
|
||||||
|
let tree: SafeOutputTree | undefined;
|
||||||
|
try {
|
||||||
|
const materialized = new Set(
|
||||||
|
context.entries
|
||||||
|
.filter((entry) => entry.kind === "file")
|
||||||
|
.map((entry) => `$output/${entry.relativePath}`),
|
||||||
|
);
|
||||||
|
for (const path of context.normalized.files.keys()) {
|
||||||
|
if (path !== "$input" && !materialized.has(path))
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
`Reported regular file is absent from materialized output: ${path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const files = [];
|
||||||
|
const unpublished = [];
|
||||||
|
tree = await SafeOutputTree.create(context.outputDirectory);
|
||||||
|
for (const entry of context.entries) {
|
||||||
|
if (entry.kind !== "file") {
|
||||||
|
unpublished.push({
|
||||||
|
relative_path: entry.relativePath,
|
||||||
|
kind: entry.kind,
|
||||||
|
link_target: entry.linkTarget,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const logicalPath = `$output/${entry.relativePath}`;
|
||||||
|
const reported = context.normalized.files.get(logicalPath);
|
||||||
|
const sha256 = await hashFirmwareFile(entry.path, context.signal);
|
||||||
|
if (
|
||||||
|
(reported?.sha256 !== null &&
|
||||||
|
reported?.sha256 !== undefined &&
|
||||||
|
reported.sha256 !== sha256) ||
|
||||||
|
(context.normalized.sizes.has(logicalPath) &&
|
||||||
|
context.normalized.sizes.get(logicalPath) !== entry.size)
|
||||||
|
)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
`Extracted bytes disagree with provider report: ${entry.relativePath}`,
|
||||||
|
);
|
||||||
|
await tree.write(
|
||||||
|
entry.relativePath,
|
||||||
|
createReadStream(entry.path),
|
||||||
|
sha256,
|
||||||
|
context.signal,
|
||||||
|
);
|
||||||
|
files.push({
|
||||||
|
relative_path: entry.relativePath,
|
||||||
|
path: join(tree.outputRoot, entry.relativePath),
|
||||||
|
size: entry.size,
|
||||||
|
sha256,
|
||||||
|
reported_sha256: reported?.sha256 ?? null,
|
||||||
|
mime_type: reported?.mimeType ?? null,
|
||||||
|
analysis_depth: reported?.depth ?? null,
|
||||||
|
runtime_address: null,
|
||||||
|
original_file_range: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = firmwareResultSchemas.extract_firmware.parse({
|
||||||
|
engine: context.engine,
|
||||||
|
selection: context.selection,
|
||||||
|
output_directory: tree.outputRoot,
|
||||||
|
coverage:
|
||||||
|
unpublished.length > 0 ? "partial" : context.normalized.coverage,
|
||||||
|
files,
|
||||||
|
unpublished_entries: unpublished,
|
||||||
|
chunks: context.normalized.chunks,
|
||||||
|
derivations: context.normalized.derivations,
|
||||||
|
depth_limited_paths: context.normalized.depth_limited_paths,
|
||||||
|
diagnostics: context.normalized.diagnostics,
|
||||||
|
published_bytes: files.reduce((sum, file) => sum + file.size, 0),
|
||||||
|
});
|
||||||
|
context.signal.throwIfAborted();
|
||||||
|
await tree.commit();
|
||||||
|
return result;
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
try {
|
||||||
|
await tree?.rollback();
|
||||||
|
} catch (cleanupCause: unknown) {
|
||||||
|
throw new ProviderCleanupError(
|
||||||
|
"unblob",
|
||||||
|
[context.outputDirectory],
|
||||||
|
{
|
||||||
|
reason:
|
||||||
|
cleanupCause instanceof Error
|
||||||
|
? cleanupCause.message
|
||||||
|
: String(cleanupCause),
|
||||||
|
previous_error:
|
||||||
|
cause instanceof Error ? cause.message : String(cause),
|
||||||
|
},
|
||||||
|
{ cause: cleanupCause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/** Audited upstream sources; installed tool bytes are fingerprinted separately. */
|
||||||
|
export const FIRMWARE_RELEASES = {
|
||||||
|
binwalk: {
|
||||||
|
version: "3.1.0",
|
||||||
|
revision: "4fdab3d464d97b68e0af9088df3f9e2e1545b21c",
|
||||||
|
repository: "https://github.com/ReFirmLabs/binwalk",
|
||||||
|
},
|
||||||
|
unblob: {
|
||||||
|
version: "26.6.4",
|
||||||
|
revision: "1fcc7a0a584a70a96c31f5a276c20944d199a089",
|
||||||
|
repository: "https://github.com/onekey-sec/unblob",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Catalog identities; Evidence uses the version observed from the executed tool. */
|
||||||
|
export const BINWALK_PROVIDER_IDENTITY = {
|
||||||
|
id: "binwalk",
|
||||||
|
name: "Binwalk",
|
||||||
|
version: FIRMWARE_RELEASES.binwalk.version,
|
||||||
|
} as const;
|
||||||
|
/** Catalog identity for explicit firmware extraction. */
|
||||||
|
export const UNBLOB_PROVIDER_IDENTITY = {
|
||||||
|
id: "unblob",
|
||||||
|
name: "Unblob",
|
||||||
|
version: FIRMWARE_RELEASES.unblob.version,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Resource policy for this Linux integration, not a claim of aggregate containment. */
|
||||||
|
export const FIRMWARE_LIMITS = {
|
||||||
|
inputBytes: 128 * 1024 * 1024,
|
||||||
|
reportBytes: 8 * 1024 * 1024,
|
||||||
|
addressSpaceBytes: 1024 * 1024 * 1024,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
normalizeBinwalkReport,
|
||||||
|
normalizeUnblobReport,
|
||||||
|
} from "./FirmwareReports.js";
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
inputPath: "/private/input.bin",
|
||||||
|
outputRoot: "/private/output",
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
length: 20,
|
||||||
|
offset: 64,
|
||||||
|
maxDepth: 3,
|
||||||
|
};
|
||||||
|
const root = () => ({
|
||||||
|
task: {
|
||||||
|
path: context.inputPath,
|
||||||
|
depth: 0,
|
||||||
|
blob_id: "",
|
||||||
|
is_multi_file: false,
|
||||||
|
},
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
__typename__: "StatReport",
|
||||||
|
path: context.inputPath,
|
||||||
|
size: 20,
|
||||||
|
is_file: true,
|
||||||
|
is_dir: false,
|
||||||
|
is_link: false,
|
||||||
|
link_target: null,
|
||||||
|
},
|
||||||
|
{ __typename__: "HashReport", sha256: context.sha256 },
|
||||||
|
{
|
||||||
|
__typename__: "UnknownChunkReport",
|
||||||
|
id: "unknown",
|
||||||
|
start_offset: 0,
|
||||||
|
end_offset: 20,
|
||||||
|
size: 20,
|
||||||
|
randomness: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subtasks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("firmware producer boundaries", () => {
|
||||||
|
it("retains unknown chunks and their selected original-file range", () => {
|
||||||
|
const normalized = normalizeUnblobReport([root()], context);
|
||||||
|
expect(normalized.coverage).toBe("partial");
|
||||||
|
expect(normalized.chunks[0]).toMatchObject({
|
||||||
|
handler: null,
|
||||||
|
range: { offset: 0, length: 20 },
|
||||||
|
root_file_range: { offset: 64, length: 20 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("preserves unfamiliar report types as explicit diagnostics", () => {
|
||||||
|
const record = root();
|
||||||
|
const normalized = normalizeUnblobReport(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...record,
|
||||||
|
reports: [
|
||||||
|
...record.reports,
|
||||||
|
{ __typename__: "FutureError", reason: "new producer facet" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
expect(normalized.diagnostics).toEqual([
|
||||||
|
{
|
||||||
|
input_path: "$input",
|
||||||
|
report: { __typename__: "FutureError", reason: "new producer facet" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(normalized.coverage).toBe("partial");
|
||||||
|
});
|
||||||
|
it("rejects unknown tasks outside the owned output and disconnected task graphs", () => {
|
||||||
|
const record = root();
|
||||||
|
for (const path of [
|
||||||
|
"/private/output/../escaped",
|
||||||
|
"/private/output/unconnected",
|
||||||
|
]) {
|
||||||
|
const child = { ...record, task: { ...record.task, path, depth: 1 } };
|
||||||
|
expect(() => normalizeUnblobReport([record, child], context)).toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("rejects absent root identity, inconsistent chunk bounds and duplicate tasks", () => {
|
||||||
|
const record = root();
|
||||||
|
expect(() => normalizeUnblobReport([], context)).toThrow("exactly one");
|
||||||
|
expect(() =>
|
||||||
|
normalizeUnblobReport(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...record,
|
||||||
|
reports: record.reports.filter(
|
||||||
|
(report) => report.__typename__ !== "HashReport",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).toThrow("hash");
|
||||||
|
expect(() =>
|
||||||
|
normalizeUnblobReport(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...record,
|
||||||
|
reports: [
|
||||||
|
...record.reports,
|
||||||
|
{
|
||||||
|
__typename__: "UnknownChunkReport",
|
||||||
|
id: "bad",
|
||||||
|
start_offset: 10,
|
||||||
|
end_offset: 21,
|
||||||
|
size: 11,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).toThrow("bounds");
|
||||||
|
expect(() => normalizeUnblobReport([record, record], context)).toThrow(
|
||||||
|
"exactly one",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("rejects empty, combined and extracting Binwalk reports", () => {
|
||||||
|
const record = {
|
||||||
|
Analysis: { file_path: context.inputPath, file_map: [], extractions: {} },
|
||||||
|
};
|
||||||
|
expect(normalizeBinwalkReport([record], context.inputPath, 20)).toEqual([]);
|
||||||
|
for (const report of [
|
||||||
|
[],
|
||||||
|
[record, record],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
Analysis: {
|
||||||
|
...record.Analysis,
|
||||||
|
extractions: { hit: { status: "success" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
expect(() =>
|
||||||
|
normalizeBinwalkReport(report, context.inputPath, 20),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||||
|
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||||
|
import { digestSchema } from "../domain/digests.js";
|
||||||
|
import { firmwareLogicalPath } from "./FirmwareFiles.js";
|
||||||
|
|
||||||
|
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||||
|
const signature = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
offset: integer,
|
||||||
|
size: integer,
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
confidence: z.number().int().min(0).max(255),
|
||||||
|
always_display: z.boolean(),
|
||||||
|
extraction_declined: z.boolean(),
|
||||||
|
});
|
||||||
|
const scan = z.array(
|
||||||
|
z.object({
|
||||||
|
Analysis: z.object({
|
||||||
|
file_path: z.string(),
|
||||||
|
file_map: z.array(signature),
|
||||||
|
extractions: z.record(z.string(), jsonValueSchema),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const report = z.object({ __typename__: z.string() }).catchall(jsonValueSchema);
|
||||||
|
const task = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
depth: integer,
|
||||||
|
blob_id: z.string(),
|
||||||
|
is_multi_file: z.boolean(),
|
||||||
|
});
|
||||||
|
const extraction = z.array(
|
||||||
|
z.object({ task, reports: z.array(report), subtasks: z.array(task) }),
|
||||||
|
);
|
||||||
|
const stat = z.object({
|
||||||
|
__typename__: z.literal("StatReport"),
|
||||||
|
path: z.string(),
|
||||||
|
size: integer,
|
||||||
|
is_dir: z.boolean(),
|
||||||
|
is_file: z.boolean(),
|
||||||
|
is_link: z.boolean(),
|
||||||
|
link_target: z.string().nullable(),
|
||||||
|
});
|
||||||
|
const hashes = z.object({ sha256: digestSchema });
|
||||||
|
const region = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
start_offset: integer,
|
||||||
|
end_offset: integer,
|
||||||
|
size: integer,
|
||||||
|
});
|
||||||
|
const known = new Set([
|
||||||
|
"StatReport",
|
||||||
|
"HashReport",
|
||||||
|
"FileMagicReport",
|
||||||
|
"RandomnessReport",
|
||||||
|
"ChunkReport",
|
||||||
|
"UnknownChunkReport",
|
||||||
|
"CarveDirectoryReport",
|
||||||
|
"MultiFileReport",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Interpret the pinned Binwalk report without inventing validated region lengths. */
|
||||||
|
export const normalizeBinwalkReport = (
|
||||||
|
raw: unknown,
|
||||||
|
inputPath: string,
|
||||||
|
size: number,
|
||||||
|
) => {
|
||||||
|
const records = scan.parse(raw);
|
||||||
|
const analysis = records[0]?.Analysis;
|
||||||
|
if (
|
||||||
|
records.length !== 1 ||
|
||||||
|
analysis?.file_path !== inputPath ||
|
||||||
|
Object.keys(analysis.extractions).length !== 0
|
||||||
|
)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"inspect_firmware_regions",
|
||||||
|
"Expected one inspection-only report for the selected immutable input",
|
||||||
|
);
|
||||||
|
return analysis.file_map.map((hit) => {
|
||||||
|
if (hit.offset >= size || hit.size > size - hit.offset)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"inspect_firmware_regions",
|
||||||
|
"Reported region lies outside the selected file",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
provider_id: hit.id,
|
||||||
|
offset: hit.offset,
|
||||||
|
reported_size: hit.size,
|
||||||
|
signature: hit.name,
|
||||||
|
description: hit.description,
|
||||||
|
reported_confidence: hit.confidence,
|
||||||
|
size_basis: "provider_reported_validation_unknown" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Normalize Unblob's unordered task graph, retaining unknown and failed analyses. */
|
||||||
|
export const normalizeUnblobReport = (
|
||||||
|
raw: unknown,
|
||||||
|
context: {
|
||||||
|
inputPath: string;
|
||||||
|
outputRoot: string;
|
||||||
|
sha256: string;
|
||||||
|
length: number;
|
||||||
|
offset: number;
|
||||||
|
maxDepth: number;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const records = extraction.parse(raw);
|
||||||
|
const logical = (path: string) =>
|
||||||
|
firmwareLogicalPath(path, context.inputPath, context.outputRoot);
|
||||||
|
const roots = records.filter(
|
||||||
|
({ task: item }) => item.path === context.inputPath && item.depth === 0,
|
||||||
|
);
|
||||||
|
if (roots.length !== 1)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
"Expected exactly one selected input task",
|
||||||
|
);
|
||||||
|
const rootHash = roots[0]?.reports.find(
|
||||||
|
(r) => r.__typename__ === "HashReport",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
rootHash === undefined ||
|
||||||
|
hashes.parse(rootHash).sha256 !== context.sha256
|
||||||
|
)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
"Unblob input hash disagrees with selected snapshot",
|
||||||
|
);
|
||||||
|
const files = new Map<
|
||||||
|
string,
|
||||||
|
{ sha256: string | null; mimeType: string | null; depth: number }
|
||||||
|
>();
|
||||||
|
const sizes = new Map<string, number>();
|
||||||
|
const depths: string[] = [];
|
||||||
|
const diagnostics: JsonValue[] = [];
|
||||||
|
const chunks: {
|
||||||
|
input_path: string;
|
||||||
|
provider_id: string;
|
||||||
|
range: { offset: number; length: number };
|
||||||
|
handler: string | null;
|
||||||
|
encrypted: boolean | null;
|
||||||
|
root_file_range: { offset: number; length: number } | null;
|
||||||
|
}[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const record of records) {
|
||||||
|
const path = logical(record.task.path);
|
||||||
|
if (seen.has(path))
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
`Duplicate analysis task: ${path}`,
|
||||||
|
);
|
||||||
|
seen.add(path);
|
||||||
|
if (record.task.depth >= context.maxDepth) depths.push(path);
|
||||||
|
const metadata = record.reports.find(
|
||||||
|
(r) => r.__typename__ === "StatReport",
|
||||||
|
);
|
||||||
|
if (metadata === undefined)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
`Missing StatReport: ${path}`,
|
||||||
|
);
|
||||||
|
const parsedStat = stat.parse(metadata);
|
||||||
|
if (parsedStat.path !== record.task.path)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
`StatReport is bound to another task: ${path}`,
|
||||||
|
);
|
||||||
|
if (path === "$input" && parsedStat.size !== context.length)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
"Unblob input size disagrees with selected snapshot",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
path === "$input" &&
|
||||||
|
(!parsedStat.is_file || parsedStat.is_link || parsedStat.is_dir)
|
||||||
|
)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
"Selected input was not reported as a regular file",
|
||||||
|
);
|
||||||
|
sizes.set(path, parsedStat.size);
|
||||||
|
const hash = record.reports.find((r) => r.__typename__ === "HashReport");
|
||||||
|
const magic = record.reports.find(
|
||||||
|
(r) => r.__typename__ === "FileMagicReport",
|
||||||
|
);
|
||||||
|
if (parsedStat.is_file && !parsedStat.is_link)
|
||||||
|
files.set(path, {
|
||||||
|
sha256: hash === undefined ? null : hashes.parse(hash).sha256,
|
||||||
|
mimeType:
|
||||||
|
magic === undefined
|
||||||
|
? null
|
||||||
|
: z.object({ mime_type: z.string() }).parse(magic).mime_type,
|
||||||
|
depth: record.task.depth,
|
||||||
|
});
|
||||||
|
for (const item of record.reports) {
|
||||||
|
if (!known.has(item.__typename__))
|
||||||
|
diagnostics.push(
|
||||||
|
jsonValueSchema.parse({ input_path: path, report: item }),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
item.__typename__ === "ChunkReport" ||
|
||||||
|
item.__typename__ === "UnknownChunkReport"
|
||||||
|
) {
|
||||||
|
const parsed = region.parse(item);
|
||||||
|
if (
|
||||||
|
parsed.end_offset <= parsed.start_offset ||
|
||||||
|
parsed.end_offset > parsedStat.size ||
|
||||||
|
parsed.size !== parsed.end_offset - parsed.start_offset
|
||||||
|
)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
`Invalid chunk bounds for ${path}`,
|
||||||
|
);
|
||||||
|
const detail =
|
||||||
|
item.__typename__ === "ChunkReport"
|
||||||
|
? z
|
||||||
|
.object({
|
||||||
|
handler_name: z.string(),
|
||||||
|
is_encrypted: z.boolean(),
|
||||||
|
extraction_reports: z.array(jsonValueSchema),
|
||||||
|
})
|
||||||
|
.parse(item)
|
||||||
|
: null;
|
||||||
|
if (detail !== null)
|
||||||
|
for (const problem of detail.extraction_reports)
|
||||||
|
diagnostics.push({ input_path: path, report: problem });
|
||||||
|
chunks.push({
|
||||||
|
input_path: path,
|
||||||
|
provider_id: parsed.id,
|
||||||
|
range: { offset: parsed.start_offset, length: parsed.size },
|
||||||
|
handler: detail?.handler_name ?? null,
|
||||||
|
encrypted: detail?.is_encrypted ?? null,
|
||||||
|
root_file_range:
|
||||||
|
path === "$input"
|
||||||
|
? {
|
||||||
|
offset: context.offset + parsed.start_offset,
|
||||||
|
length: parsed.size,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recordsByPath = new Map(
|
||||||
|
records.map((record) => [record.task.path, record.task]),
|
||||||
|
);
|
||||||
|
const chunksByOrigin = new Map<string, (typeof chunks)[number]>();
|
||||||
|
for (const item of chunks) {
|
||||||
|
const key = JSON.stringify([item.input_path, item.provider_id]);
|
||||||
|
if (chunksByOrigin.has(key))
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
`Duplicate chunk identity in ${item.input_path}`,
|
||||||
|
);
|
||||||
|
chunksByOrigin.set(key, item);
|
||||||
|
}
|
||||||
|
const derivations = records.flatMap((record) =>
|
||||||
|
record.subtasks.map((child) => {
|
||||||
|
const parentPath = logical(record.task.path);
|
||||||
|
const childPath = logical(child.path);
|
||||||
|
if (!seen.has(childPath))
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
`Missing child task: ${childPath}`,
|
||||||
|
);
|
||||||
|
const reportedChild = recordsByPath.get(child.path);
|
||||||
|
if (
|
||||||
|
reportedChild?.depth !== child.depth ||
|
||||||
|
reportedChild.blob_id !== child.blob_id ||
|
||||||
|
reportedChild.is_multi_file !== child.is_multi_file
|
||||||
|
)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
`Child task identity disagrees with its analysis: ${childPath}`,
|
||||||
|
);
|
||||||
|
const origin = chunksByOrigin.get(
|
||||||
|
JSON.stringify([parentPath, child.blob_id]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
parent_path: parentPath,
|
||||||
|
child_path: childPath,
|
||||||
|
provider_blob_id: child.blob_id,
|
||||||
|
handler: origin?.handler ?? null,
|
||||||
|
parent_file_range: origin?.range ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const reachable = new Set(["$input"]);
|
||||||
|
const pending = ["$input"];
|
||||||
|
const childrenByParent = new Map<string, string[]>();
|
||||||
|
for (const edge of derivations) {
|
||||||
|
const children = childrenByParent.get(edge.parent_path) ?? [];
|
||||||
|
children.push(edge.child_path);
|
||||||
|
childrenByParent.set(edge.parent_path, children);
|
||||||
|
}
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const parent = pending.pop();
|
||||||
|
for (const child of childrenByParent.get(parent ?? "") ?? []) {
|
||||||
|
if (reachable.has(child)) continue;
|
||||||
|
reachable.add(child);
|
||||||
|
pending.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reachable.size !== seen.size)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"extract_firmware",
|
||||||
|
"Report contains tasks disconnected from the selected input",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
sizes,
|
||||||
|
chunks,
|
||||||
|
derivations,
|
||||||
|
depth_limited_paths: depths.sort(),
|
||||||
|
diagnostics,
|
||||||
|
coverage:
|
||||||
|
depths.length > 0 ||
|
||||||
|
diagnostics.length > 0 ||
|
||||||
|
chunks.some((c) => c.handler === null || c.encrypted === true)
|
||||||
|
? ("partial" as const)
|
||||||
|
: ("complete" as const),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -30,7 +30,7 @@ interface GeneratedPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GENERATED_PAYLOAD_JSON =
|
const GENERATED_PAYLOAD_JSON =
|
||||||
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
|
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
|
||||||
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
||||||
if (!isGeneratedPayload(generatedPayload))
|
if (!isGeneratedPayload(generatedPayload))
|
||||||
throw new TypeError("Generated MCP catalog payload is invalid");
|
throw new TypeError("Generated MCP catalog payload is invalid");
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import { registerElectronTools } from "./registerElectronTools.js";
|
|||||||
import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
||||||
import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js";
|
import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js";
|
||||||
import { registerManagedTools } from "./registerManagedTools.js";
|
import { registerManagedTools } from "./registerManagedTools.js";
|
||||||
|
import { registerFirmwareTools } from "./registerFirmwareTools.js";
|
||||||
|
import { FirmwareAnalysisService } from "../application/FirmwareAnalysisService.js";
|
||||||
|
import type { FirmwareAnalysisPort } from "../application/FirmwareAnalysisPort.js";
|
||||||
|
import { FirmwareProvider } from "../firmware/FirmwareProvider.js";
|
||||||
import { registerAndroidTools } from "./registerAndroidTools.js";
|
import { registerAndroidTools } from "./registerAndroidTools.js";
|
||||||
import { AndroidAnalysisService } from "../application/AndroidAnalysisService.js";
|
import { AndroidAnalysisService } from "../application/AndroidAnalysisService.js";
|
||||||
import type { AndroidAnalysisPort } from "../application/AndroidAnalysisPort.js";
|
import type { AndroidAnalysisPort } from "../application/AndroidAnalysisPort.js";
|
||||||
@@ -38,6 +42,7 @@ const ACTIVE_TARGET_INSTRUCTIONS =
|
|||||||
|
|
||||||
export interface CreateServerOptions {
|
export interface CreateServerOptions {
|
||||||
readonly logger?: Logger;
|
readonly logger?: Logger;
|
||||||
|
readonly firmwareAnalysis?: FirmwareAnalysisPort;
|
||||||
readonly androidAnalysis?: AndroidAnalysisPort;
|
readonly androidAnalysis?: AndroidAnalysisPort;
|
||||||
readonly browserObservation?: BrowserObservationPort;
|
readonly browserObservation?: BrowserObservationPort;
|
||||||
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
|
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
|
||||||
@@ -55,6 +60,14 @@ const installSessionToolAvailability = (
|
|||||||
if (session === undefined) return undefined;
|
if (session === undefined) return undefined;
|
||||||
const policy = sessionAvailabilityPolicy(options.availabilityPolicy, {
|
const policy = sessionAvailabilityPolicy(options.availabilityPolicy, {
|
||||||
optionalFeatures: {
|
optionalFeatures: {
|
||||||
|
firmwareInspectionEnabled:
|
||||||
|
options.firmwareAnalysis !== undefined ||
|
||||||
|
(process.platform === "linux" &&
|
||||||
|
isAbsolute(process.env.REA_BINWALK_COMMAND ?? "")),
|
||||||
|
firmwareExtractionEnabled:
|
||||||
|
options.firmwareAnalysis !== undefined ||
|
||||||
|
(process.platform === "linux" &&
|
||||||
|
isAbsolute(process.env.REA_UNBLOB_COMMAND ?? "")),
|
||||||
androidAnalysisEnabled:
|
androidAnalysisEnabled:
|
||||||
options.androidAnalysis !== undefined ||
|
options.androidAnalysis !== undefined ||
|
||||||
((process.platform === "linux" || process.platform === "darwin") &&
|
((process.platform === "linux" || process.platform === "darwin") &&
|
||||||
@@ -127,6 +140,14 @@ export const createServer = (
|
|||||||
toolLogger,
|
toolLogger,
|
||||||
recordEvidence,
|
recordEvidence,
|
||||||
);
|
);
|
||||||
|
registerFirmwareTools(
|
||||||
|
server,
|
||||||
|
new FirmwareAnalysisService(
|
||||||
|
options.firmwareAnalysis ?? new FirmwareProvider(),
|
||||||
|
),
|
||||||
|
toolLogger,
|
||||||
|
recordEvidence,
|
||||||
|
);
|
||||||
registerObservationTools(toolContext);
|
registerObservationTools(toolContext);
|
||||||
registerGuidedPrompts(server, analysis, session);
|
registerGuidedPrompts(server, analysis, session);
|
||||||
if (session !== undefined) {
|
if (session !== undefined) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
|
||||||
|
import type { FirmwareAnalysisService } from "../application/FirmwareAnalysisService.js";
|
||||||
|
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||||
|
import { toolContract } from "../contracts/toolContracts.js";
|
||||||
|
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||||
|
import type { FirmwareOperation } from "../domain/firmwareAnalysis.js";
|
||||||
|
import type { Logger } from "../logger.js";
|
||||||
|
import { logToolExecution } from "./toolLogging.js";
|
||||||
|
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||||
|
import { toCallToolResult } from "./toolResult.js";
|
||||||
|
|
||||||
|
/** Bind firmware handlers to their exact named schemas. */
|
||||||
|
export const registerFirmwareTools = (
|
||||||
|
server: McpServer,
|
||||||
|
service: FirmwareAnalysisService,
|
||||||
|
logger: Logger,
|
||||||
|
recordEvidence?: BinarySessionPort["recordEvidence"],
|
||||||
|
): void => {
|
||||||
|
const handler =
|
||||||
|
(contract: ToolContract<FirmwareOperation>) =>
|
||||||
|
async (input: unknown, context: ServerContext) => {
|
||||||
|
const result = await logToolExecution(logger, contract.name, () =>
|
||||||
|
service.execute(contract.name, input, {
|
||||||
|
signal: context.mcpReq.signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!result.ok) return toCallToolResult(result, contract);
|
||||||
|
const recorded = recordEvidence?.(result.value);
|
||||||
|
return recorded !== undefined && !recorded.ok
|
||||||
|
? toCallToolResult(recorded, contract)
|
||||||
|
: toCallToolResult(result, contract);
|
||||||
|
};
|
||||||
|
const inspect = toolContract("inspect_firmware_regions");
|
||||||
|
server.registerTool(
|
||||||
|
inspect.name,
|
||||||
|
toolRegistrationOptions(inspect),
|
||||||
|
handler(inspect),
|
||||||
|
);
|
||||||
|
const extract = toolContract("extract_firmware");
|
||||||
|
server.registerTool(
|
||||||
|
extract.name,
|
||||||
|
toolRegistrationOptions(extract),
|
||||||
|
handler(extract),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,6 +12,8 @@ export interface SessionAvailabilityDefaults {
|
|||||||
| "electronAutomationEnabled"
|
| "electronAutomationEnabled"
|
||||||
| "v8InspectorObservationEnabled"
|
| "v8InspectorObservationEnabled"
|
||||||
| "androidAnalysisEnabled"
|
| "androidAnalysisEnabled"
|
||||||
|
| "firmwareInspectionEnabled"
|
||||||
|
| "firmwareExtractionEnabled"
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +25,10 @@ export const sessionAvailabilityPolicy = (
|
|||||||
configured ??
|
configured ??
|
||||||
(() => ({
|
(() => ({
|
||||||
processCaptureEnabled: platform !== "win32",
|
processCaptureEnabled: platform !== "win32",
|
||||||
|
firmwareInspectionEnabled:
|
||||||
|
defaults.optionalFeatures?.firmwareInspectionEnabled ?? false,
|
||||||
|
firmwareExtractionEnabled:
|
||||||
|
defaults.optionalFeatures?.firmwareExtractionEnabled ?? false,
|
||||||
androidAnalysisEnabled:
|
androidAnalysisEnabled:
|
||||||
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
|
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
|
||||||
browserObservationEnabled:
|
browserObservationEnabled:
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "../../../src/brow
|
|||||||
import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../../src/browser/V8InspectorProvider.js";
|
import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../../src/browser/V8InspectorProvider.js";
|
||||||
import { CLI_COMMAND_NAMES } from "../../../src/cliCommandNames.js";
|
import { CLI_COMMAND_NAMES } from "../../../src/cliCommandNames.js";
|
||||||
import { createCli } from "../../../src/cli.js";
|
import { createCli } from "../../../src/cli.js";
|
||||||
|
import {
|
||||||
|
BINWALK_PROVIDER_IDENTITY,
|
||||||
|
UNBLOB_PROVIDER_IDENTITY,
|
||||||
|
} from "../../../src/firmware/FirmwareRelease.js";
|
||||||
import { JADX_PROVIDER_IDENTITY } from "../../../src/android/JadxRelease.js";
|
import { JADX_PROVIDER_IDENTITY } from "../../../src/android/JadxRelease.js";
|
||||||
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
||||||
import {
|
import {
|
||||||
@@ -73,6 +77,8 @@ describe("canonical product catalog", () => {
|
|||||||
[
|
[
|
||||||
HOPPER_PROVIDER_IDENTITY,
|
HOPPER_PROVIDER_IDENTITY,
|
||||||
JADX_PROVIDER_IDENTITY,
|
JADX_PROVIDER_IDENTITY,
|
||||||
|
BINWALK_PROVIDER_IDENTITY,
|
||||||
|
UNBLOB_PROVIDER_IDENTITY,
|
||||||
GHIDRA_PROVIDER_IDENTITY,
|
GHIDRA_PROVIDER_IDENTITY,
|
||||||
NATIVE_MACOS_PROVIDER_IDENTITY,
|
NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||||
ARTIFACT_GRAPH_PROVIDER,
|
ARTIFACT_GRAPH_PROVIDER,
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { expect, it as test } from "vitest";
|
||||||
|
import {
|
||||||
|
firmwareFixture,
|
||||||
|
assertFirmwareCleanup,
|
||||||
|
} from "../../fixtures/firmware/provider.js";
|
||||||
|
import { firmwareResultSchemas } from "../../../src/domain/firmwareAnalysis.js";
|
||||||
|
import { FirmwareAnalysisService } from "../../../src/application/FirmwareAnalysisService.js";
|
||||||
|
import { FirmwareProvider } from "../../../src/firmware/FirmwareProvider.js";
|
||||||
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||||
|
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||||
|
import { toCallToolResult } from "../../../src/server/toolResult.js";
|
||||||
|
|
||||||
|
const it = test.skipIf(process.platform !== "linux");
|
||||||
|
|
||||||
|
it("binds region observations to exact bytes and preserves provider-reported uncertainty", async () => {
|
||||||
|
const fixture = await firmwareFixture();
|
||||||
|
const result = await fixture.service.execute("inspect_firmware_regions", {
|
||||||
|
path: fixture.path,
|
||||||
|
});
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
const normalized = firmwareResultSchemas.inspect_firmware_regions.parse(
|
||||||
|
result.value.normalized_result,
|
||||||
|
);
|
||||||
|
expect(normalized.regions[0]).toMatchObject({
|
||||||
|
offset: 2,
|
||||||
|
reported_size: 4,
|
||||||
|
size_basis: "provider_reported_validation_unknown",
|
||||||
|
});
|
||||||
|
expect(result.value.subject?.local_path).toBe(fixture.path);
|
||||||
|
expect(result.value.subject?.format).toBe("file");
|
||||||
|
expect(result.value.raw_result).toMatchObject({
|
||||||
|
report: [{ Analysis: { file_map: [{ id: "hit" }] } }],
|
||||||
|
});
|
||||||
|
expect(fixture.launches[1]?.args).toEqual(
|
||||||
|
expect.arrayContaining(["--as=1073741824", "--threads", "1"]),
|
||||||
|
);
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["wrong-input", "bounds"])(
|
||||||
|
"rejects a mismatched or out-of-bounds inspection: %s",
|
||||||
|
async (mode) => {
|
||||||
|
const fixture = await firmwareFixture(mode);
|
||||||
|
const result = await fixture.service.execute("inspect_firmware_regions", {
|
||||||
|
path: fixture.path,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "AnalysisOutputError" },
|
||||||
|
});
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("extracts a selected interval with verified file identity and parent-byte lineage", async () => {
|
||||||
|
const fixture = await firmwareFixture();
|
||||||
|
const result = await fixture.service.execute("extract_firmware", {
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
range: { offset: 2, length: 4 },
|
||||||
|
});
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
const normalized = firmwareResultSchemas.extract_firmware.parse(
|
||||||
|
result.value.normalized_result,
|
||||||
|
);
|
||||||
|
expect(normalized.selection).toMatchObject({ offset: 2, length: 4 });
|
||||||
|
expect(normalized.files[0]).toMatchObject({
|
||||||
|
relative_path: "rootfs/config",
|
||||||
|
size: 14,
|
||||||
|
original_file_range: null,
|
||||||
|
runtime_address: null,
|
||||||
|
});
|
||||||
|
expect(normalized.chunks[0]?.root_file_range).toEqual({
|
||||||
|
offset: 2,
|
||||||
|
length: 4,
|
||||||
|
});
|
||||||
|
expect(normalized.derivations[0]).toMatchObject({
|
||||||
|
parent_path: "$input",
|
||||||
|
child_path: "$output/rootfs/config",
|
||||||
|
handler: "gzip",
|
||||||
|
parent_file_range: { offset: 0, length: 4 },
|
||||||
|
});
|
||||||
|
expect(await readFile(normalized.files[0]?.path ?? "", "utf8")).toBe(
|
||||||
|
"firmware=true\n",
|
||||||
|
);
|
||||||
|
expect(fixture.launches[1]?.args).toEqual(
|
||||||
|
expect.arrayContaining(["--process-num", "1", "--randomness-depth", "0"]),
|
||||||
|
);
|
||||||
|
expect(fixture.launches[1]?.args).not.toContain("--no-sandbox");
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["depth", "dependency", "link"])(
|
||||||
|
"reports partial extraction for %s without losing inline results",
|
||||||
|
async (mode) => {
|
||||||
|
const fixture = await firmwareFixture(mode);
|
||||||
|
const result = await fixture.service.execute("extract_firmware", {
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
const normalized = firmwareResultSchemas.extract_firmware.parse(
|
||||||
|
result.value.normalized_result,
|
||||||
|
);
|
||||||
|
expect(normalized.coverage).toBe("partial");
|
||||||
|
expect(normalized.files).toHaveLength(1);
|
||||||
|
if (mode === "depth")
|
||||||
|
expect(normalized.depth_limited_paths).toEqual(["$output/rootfs/config"]);
|
||||||
|
if (mode === "dependency")
|
||||||
|
expect(normalized.diagnostics).toEqual([
|
||||||
|
{
|
||||||
|
input_path: "$input",
|
||||||
|
report: {
|
||||||
|
__typename__: "ExtractorDependencyNotFoundReport",
|
||||||
|
severity: "ERROR",
|
||||||
|
dependencies: ["sasquatch"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (mode === "link") {
|
||||||
|
expect(normalized.unpublished_entries).toEqual([
|
||||||
|
{
|
||||||
|
relative_path: "escape",
|
||||||
|
kind: "symlink",
|
||||||
|
link_target: "/etc/passwd",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await expect(access(`${fixture.output}/escape`)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("distinguishes a real output file named $input from the selected original", async () => {
|
||||||
|
const fixture = await firmwareFixture("reserved-name");
|
||||||
|
const outcome = await fixture.service.execute("extract_firmware", {
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (!outcome.ok) throw outcome.error;
|
||||||
|
const result = firmwareResultSchemas.extract_firmware.parse(
|
||||||
|
outcome.value.normalized_result,
|
||||||
|
);
|
||||||
|
expect(result.files[0]?.relative_path).toBe("$input");
|
||||||
|
expect(result.derivations[0]).toMatchObject({
|
||||||
|
parent_path: "$input",
|
||||||
|
child_path: "$output/$input",
|
||||||
|
});
|
||||||
|
expect(await readFile(`${fixture.output}/$input`, "utf8")).toBe(
|
||||||
|
"firmware=true\n",
|
||||||
|
);
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["hash", "malformed", "budget", "missing-file"])(
|
||||||
|
"rejects invalid or over-budget extraction and rolls back output: %s",
|
||||||
|
async (mode) => {
|
||||||
|
const fixture = await firmwareFixture(mode);
|
||||||
|
const result = await fixture.service.execute("extract_firmware", {
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
...(mode === "budget" ? { max_output_bytes: 1000 } : {}),
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
await expect(access(fixture.output)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("preserves existing output and rejects invalid intervals before invoking extraction", async () => {
|
||||||
|
const fixture = await firmwareFixture();
|
||||||
|
await mkdir(fixture.output);
|
||||||
|
await writeFile(`${fixture.output}/existing`, "keep");
|
||||||
|
const existing = await fixture.service.execute("extract_firmware", {
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
expect(existing.ok).toBe(false);
|
||||||
|
expect(await readFile(`${fixture.output}/existing`, "utf8")).toBe("keep");
|
||||||
|
const previous = fixture.launches.length;
|
||||||
|
const invalid = await fixture.service.execute("extract_firmware", {
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
range: { offset: 100, length: 1 },
|
||||||
|
});
|
||||||
|
expect(invalid).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "AnalysisInputError" },
|
||||||
|
});
|
||||||
|
expect(fixture.launches).toHaveLength(previous);
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains startup stderr when Unblob exits 1 without a report", async () => {
|
||||||
|
const fixture = await firmwareFixture("startup");
|
||||||
|
const result = await fixture.service.execute("extract_firmware", {
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (result.ok) throw new Error("Expected startup failure");
|
||||||
|
const details = {
|
||||||
|
diagnostics: {
|
||||||
|
stderr: expect.stringContaining("Landlock sandbox is not available"),
|
||||||
|
exit_code: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(projectAnalysisError(result.error)).toMatchObject({ details });
|
||||||
|
expect(
|
||||||
|
toCallToolResult(result, toolContract("extract_firmware"))
|
||||||
|
.structuredContent,
|
||||||
|
).toMatchObject({ error: { details } });
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
_tag: "ProviderAdapterError",
|
||||||
|
diagnostics: {
|
||||||
|
exit_code: 1,
|
||||||
|
stderr: expect.stringContaining("Landlock sandbox is not available"),
|
||||||
|
report_failure: expect.stringContaining("ENOENT"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(access(fixture.output)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes a successful process with missing output from startup failure", async () => {
|
||||||
|
const fixture = await firmwareFixture("missing-report");
|
||||||
|
const result = await fixture.service.execute("extract_firmware", {
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
_tag: "AnalysisOutputError",
|
||||||
|
reason: expect.stringContaining("expected complete report"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.ok) throw new Error("Expected missing-output failure");
|
||||||
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||||
|
details: { reason: expect.stringContaining("Producer forgot its report") },
|
||||||
|
});
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels an owned active process and permits the next queued operation", async () => {
|
||||||
|
const fixture = await firmwareFixture("stall");
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = fixture.service.execute(
|
||||||
|
"inspect_firmware_regions",
|
||||||
|
{ path: fixture.path },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
await expect.poll(() => fixture.launches.length).toBe(2);
|
||||||
|
controller.abort();
|
||||||
|
expect(await pending).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "AnalysisCancelledError" },
|
||||||
|
});
|
||||||
|
const cancelled = await fixture.service.execute(
|
||||||
|
"inspect_firmware_regions",
|
||||||
|
{ path: fixture.path },
|
||||||
|
{ signal: AbortSignal.abort() },
|
||||||
|
);
|
||||||
|
expect(cancelled).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "AnalysisCancelledError" },
|
||||||
|
});
|
||||||
|
expect(fixture.launches).toHaveLength(2);
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a waiting request promptly while keeping later launches behind the active operation", async () => {
|
||||||
|
const fixture = await firmwareFixture("stall");
|
||||||
|
const activeController = new AbortController();
|
||||||
|
const active = fixture.service.execute(
|
||||||
|
"inspect_firmware_regions",
|
||||||
|
{ path: fixture.path },
|
||||||
|
{ signal: activeController.signal },
|
||||||
|
);
|
||||||
|
await expect.poll(() => fixture.launches.length).toBe(2);
|
||||||
|
const queuedController = new AbortController();
|
||||||
|
const queued = fixture.service.execute(
|
||||||
|
"inspect_firmware_regions",
|
||||||
|
{ path: fixture.path },
|
||||||
|
{ signal: queuedController.signal },
|
||||||
|
);
|
||||||
|
queuedController.abort();
|
||||||
|
expect(await queued).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "AnalysisCancelledError" },
|
||||||
|
});
|
||||||
|
expect(fixture.launches).toHaveLength(2);
|
||||||
|
activeController.abort();
|
||||||
|
expect((await active).ok).toBe(false);
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains an uncertain workspace and blocks new launches after cleanup failure", async () => {
|
||||||
|
const fixture = await firmwareFixture("cleanup-failure");
|
||||||
|
const first = await fixture.service.execute("inspect_firmware_regions", {
|
||||||
|
path: fixture.path,
|
||||||
|
});
|
||||||
|
expect(first).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { cleanupIncomplete: true },
|
||||||
|
});
|
||||||
|
const workspace = fixture.launches[0]?.cwd;
|
||||||
|
expect(workspace).toBeDefined();
|
||||||
|
if (workspace !== undefined) await access(workspace);
|
||||||
|
const next = await fixture.service.execute("inspect_firmware_regions", {
|
||||||
|
path: fixture.path,
|
||||||
|
});
|
||||||
|
expect(next).toMatchObject({ ok: false, error: { cleanupIncomplete: true } });
|
||||||
|
expect(fixture.launches).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects absent or unsupported engines without installing anything", async () => {
|
||||||
|
const missing = await new FirmwareAnalysisService(
|
||||||
|
new FirmwareProvider({}),
|
||||||
|
).execute("inspect_firmware_regions", { path: "/tmp/unused" });
|
||||||
|
expect(missing).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||||
|
});
|
||||||
|
const fixture = await firmwareFixture("version");
|
||||||
|
const result = await fixture.service.execute("inspect_firmware_regions", {
|
||||||
|
path: fixture.path,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||||
|
});
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
});
|
||||||
@@ -94,6 +94,7 @@ describe("enhanced MCP tools", () => {
|
|||||||
error: {
|
error: {
|
||||||
code: "unreadable_output",
|
code: "unreadable_output",
|
||||||
category: "execution_failure",
|
category: "execution_failure",
|
||||||
|
details: { operation: "decompile", reason: "failed" },
|
||||||
message:
|
message:
|
||||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||||
retryable: false,
|
retryable: false,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||||
|
import { expect, it as test, onTestFinished } from "vitest";
|
||||||
|
import { FIRMWARE_TOOL_CONTRACTS } from "../../../src/contracts/firmwareToolContracts.js";
|
||||||
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||||
|
import { createServer } from "../../../src/server/createServer.js";
|
||||||
|
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||||
|
import {
|
||||||
|
firmwareFixture,
|
||||||
|
assertFirmwareCleanup,
|
||||||
|
} from "../../fixtures/firmware/provider.js";
|
||||||
|
|
||||||
|
const it = test.skipIf(process.platform !== "linux");
|
||||||
|
|
||||||
|
it("executes named firmware contracts with inline Evidence and no native binary admission", async () => {
|
||||||
|
const fixture = await firmwareFixture();
|
||||||
|
const session = createTestBinarySession(() => {
|
||||||
|
throw new Error("Firmware inspection must not acquire a native provider");
|
||||||
|
});
|
||||||
|
const server = createServer(session, session, {
|
||||||
|
firmwareAnalysis: fixture.provider,
|
||||||
|
});
|
||||||
|
const client = new Client({ name: "firmware-regression", version: "1" });
|
||||||
|
onTestFinished(async () => {
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
const [clientTransport, serverTransport] =
|
||||||
|
InMemoryTransport.createLinkedPair();
|
||||||
|
await server.connect(serverTransport);
|
||||||
|
await client.connect(clientTransport);
|
||||||
|
const advertised = await client.listTools();
|
||||||
|
for (const contract of FIRMWARE_TOOL_CONTRACTS) {
|
||||||
|
expect(
|
||||||
|
advertised.tools.find((tool) => tool.name === contract.name)?.annotations,
|
||||||
|
).toMatchObject(contract.annotations);
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: contract.name,
|
||||||
|
arguments: {
|
||||||
|
path: fixture.path,
|
||||||
|
...(contract.name === "extract_firmware"
|
||||||
|
? { output_directory: fixture.output }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||||
|
const result = contract.outputSchema.parse(response.structuredContent);
|
||||||
|
const evidence = parseEvidence(result.evidence);
|
||||||
|
expect(result.result).toEqual(evidence.normalized_result);
|
||||||
|
expect(result.evidence_id).toBe(evidence.evidence_id);
|
||||||
|
expect(evidence.operation).toBe(contract.name);
|
||||||
|
expect(evidence.raw_result).not.toBeNull();
|
||||||
|
}
|
||||||
|
await assertFirmwareCleanup(fixture.launches);
|
||||||
|
});
|
||||||
Vendored
+129
@@ -0,0 +1,129 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, readFile, writeFile, symlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const mode = process.env.REA_FIRMWARE_FIXTURE_MODE;
|
||||||
|
const engine = process.env.REA_FIRMWARE_FIXTURE_ENGINE;
|
||||||
|
if (args.includes("--version")) {
|
||||||
|
console.log(
|
||||||
|
mode === "version"
|
||||||
|
? "unsupported"
|
||||||
|
: engine === "binwalk"
|
||||||
|
? "binwalk 3.1.0"
|
||||||
|
: "26.6.4",
|
||||||
|
);
|
||||||
|
} else if (mode === "stall") {
|
||||||
|
setInterval(() => {}, 1000);
|
||||||
|
} else if (mode === "startup") {
|
||||||
|
console.error("Landlock sandbox is not available on this fixture host");
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else if (mode === "missing-report") {
|
||||||
|
console.error("Producer forgot its report");
|
||||||
|
} else {
|
||||||
|
const input = args.at(-1);
|
||||||
|
const bytes = await readFile(input);
|
||||||
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
const reportPath =
|
||||||
|
args[args.indexOf(engine === "binwalk" ? "--log" : "--report") + 1];
|
||||||
|
if (engine === "binwalk") {
|
||||||
|
await writeFile(
|
||||||
|
reportPath,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
Analysis: {
|
||||||
|
file_path: mode === "wrong-input" ? "/tmp/another.bin" : input,
|
||||||
|
file_map: [
|
||||||
|
{
|
||||||
|
id: "hit",
|
||||||
|
offset: 2,
|
||||||
|
size: mode === "bounds" ? bytes.length : 4,
|
||||||
|
name: "gzip",
|
||||||
|
confidence: 250,
|
||||||
|
description: "fixture signature",
|
||||||
|
always_display: false,
|
||||||
|
extraction_declined: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extractions: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const output = args[args.indexOf("--extract-dir") + 1];
|
||||||
|
await mkdir(output, { recursive: true });
|
||||||
|
const child =
|
||||||
|
mode === "reserved-name"
|
||||||
|
? join(output, "$input")
|
||||||
|
: join(output, "rootfs", "config");
|
||||||
|
await mkdir(join(output, "rootfs"));
|
||||||
|
const content = Buffer.from("firmware=true\n");
|
||||||
|
if (mode !== "missing-file")
|
||||||
|
await writeFile(child, mode === "budget" ? Buffer.alloc(10000) : content);
|
||||||
|
if (mode === "link") await symlink("/etc/passwd", join(output, "escape"));
|
||||||
|
const childHash = createHash("sha256").update(content).digest("hex");
|
||||||
|
const depth =
|
||||||
|
mode === "depth" ? Number(args[args.indexOf("--depth") + 1]) : 1;
|
||||||
|
const stat = (path, size) => ({
|
||||||
|
__typename__: "StatReport",
|
||||||
|
path,
|
||||||
|
size,
|
||||||
|
is_file: true,
|
||||||
|
is_dir: false,
|
||||||
|
is_link: false,
|
||||||
|
link_target: null,
|
||||||
|
});
|
||||||
|
const task = (path, level, id) => ({
|
||||||
|
path,
|
||||||
|
depth: level,
|
||||||
|
blob_id: id,
|
||||||
|
is_multi_file: false,
|
||||||
|
});
|
||||||
|
const hash = (value) => ({ __typename__: "HashReport", sha256: value });
|
||||||
|
const childTask = task(child, depth, "gzip-id");
|
||||||
|
const records = [
|
||||||
|
{
|
||||||
|
task: childTask,
|
||||||
|
reports: [
|
||||||
|
stat(child, content.length),
|
||||||
|
...(mode === "depth"
|
||||||
|
? []
|
||||||
|
: [hash(mode === "hash" ? "a".repeat(64) : childHash)]),
|
||||||
|
],
|
||||||
|
subtasks: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
task: task(input, 0, ""),
|
||||||
|
reports: [
|
||||||
|
stat(input, bytes.length),
|
||||||
|
hash(sha256),
|
||||||
|
{
|
||||||
|
__typename__: "ChunkReport",
|
||||||
|
id: "gzip-id",
|
||||||
|
start_offset: 0,
|
||||||
|
end_offset: bytes.length,
|
||||||
|
size: bytes.length,
|
||||||
|
handler_name: "gzip",
|
||||||
|
is_encrypted: false,
|
||||||
|
extraction_reports:
|
||||||
|
mode === "dependency"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
__typename__: "ExtractorDependencyNotFoundReport",
|
||||||
|
severity: "ERROR",
|
||||||
|
dependencies: ["sasquatch"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subtasks: [childTask],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await writeFile(
|
||||||
|
reportPath,
|
||||||
|
mode === "malformed" ? "[" : JSON.stringify(records),
|
||||||
|
);
|
||||||
|
if (mode === "dependency") process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { access, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { expect, onTestFinished } from "vitest";
|
||||||
|
import { FirmwareProvider } from "../../../src/firmware/FirmwareProvider.js";
|
||||||
|
import { FirmwareAnalysisService } from "../../../src/application/FirmwareAnalysisService.js";
|
||||||
|
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||||
|
import { createTestTempDirectory } from "../temporaryDirectory.js";
|
||||||
|
|
||||||
|
/** Exercise real subprocess ownership with synthetic pinned producer representations. */
|
||||||
|
export const firmwareFixture = async (mode = "normal") => {
|
||||||
|
const root = await createTestTempDirectory("rea-firmware-test-");
|
||||||
|
const path = join(root, "input.bin");
|
||||||
|
await writeFile(path, Buffer.from("fixture-input-data"));
|
||||||
|
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||||
|
const launches: {
|
||||||
|
cwd: string | undefined;
|
||||||
|
pid: number | undefined;
|
||||||
|
args: readonly string[];
|
||||||
|
}[] = [];
|
||||||
|
const provider = new FirmwareProvider(
|
||||||
|
{
|
||||||
|
...process.env,
|
||||||
|
REA_BINWALK_COMMAND: "/usr/bin/true",
|
||||||
|
REA_UNBLOB_COMMAND: process.execPath,
|
||||||
|
},
|
||||||
|
async (options) => {
|
||||||
|
const start = options.arguments.indexOf("--") + 2;
|
||||||
|
const args = options.arguments.slice(start);
|
||||||
|
const engine =
|
||||||
|
options.arguments[start - 1]?.endsWith("/true") === true
|
||||||
|
? "binwalk"
|
||||||
|
: "unblob";
|
||||||
|
const spawned = await spawnOwnedProviderProcess({
|
||||||
|
...options,
|
||||||
|
command: process.execPath,
|
||||||
|
expectedCommand: process.execPath,
|
||||||
|
arguments: [
|
||||||
|
fileURLToPath(new URL("./producer.mjs", import.meta.url)),
|
||||||
|
...args,
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
...options.env,
|
||||||
|
REA_FIRMWARE_FIXTURE_MODE: mode,
|
||||||
|
REA_FIRMWARE_FIXTURE_ENGINE: engine,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
launches.push({
|
||||||
|
cwd: options.cwd,
|
||||||
|
pid: spawned.process.pid,
|
||||||
|
args: options.arguments,
|
||||||
|
});
|
||||||
|
if (mode === "cleanup-failure") {
|
||||||
|
onTestFinished(async () => {
|
||||||
|
if (options.cwd !== undefined)
|
||||||
|
await rm(options.cwd, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...spawned,
|
||||||
|
cleanup: async () => ({
|
||||||
|
cleaned: false,
|
||||||
|
reason: "injected ownership failure",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return spawned;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
service: new FirmwareAnalysisService(provider),
|
||||||
|
path,
|
||||||
|
output: join(root, "output"),
|
||||||
|
root,
|
||||||
|
launches,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Verify only the acquired process groups and private runtime directories. */
|
||||||
|
export const assertFirmwareCleanup = async (
|
||||||
|
launches: Awaited<ReturnType<typeof firmwareFixture>>["launches"],
|
||||||
|
): Promise<void> => {
|
||||||
|
for (const launch of launches) {
|
||||||
|
const pid = launch.pid;
|
||||||
|
if (pid !== undefined)
|
||||||
|
expect(() => process.kill(-pid, 0)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "ESRCH" }),
|
||||||
|
);
|
||||||
|
if (launch.cwd !== undefined)
|
||||||
|
await expect(access(launch.cwd)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Vendored
+18
@@ -40,3 +40,21 @@ are checked against fixed SHA-256 values. Fixture provenance is recorded in
|
|||||||
`scripts/fixtures/android-apidemos.json`; the source uses Apache-2.0. This is a
|
`scripts/fixtures/android-apidemos.json`; the source uses Apache-2.0. This is a
|
||||||
static analysis fixture: no emulator, device, SDK installation or app execution
|
static analysis fixture: no emulator, device, SDK installation or app execution
|
||||||
is needed. APKs and JARs are neither tracked nor included in npm packages.
|
is needed. APKs and JARs are neither tracked nor included in npm packages.
|
||||||
|
|
||||||
|
## Firmware engines
|
||||||
|
|
||||||
|
| Engine | Source | Release | Pinned commit | License |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Binwalk | <https://github.com/ReFirmLabs/binwalk> | v3.1.0 | `4fdab3d464d97b68e0af9088df3f9e2e1545b21c` | MIT, upstream `LICENSE` |
|
||||||
|
| Unblob | <https://github.com/onekey-sec/unblob> | 26.6.4 | `1fcc7a0a584a70a96c31f5a276c20944d199a089` | MIT, upstream `LICENSE` |
|
||||||
|
|
||||||
|
Source remains unmodified in `third_party/binwalk` and `third_party/unblob`.
|
||||||
|
Release records live in `src/firmware/FirmwareRelease.ts`; REA adapters own the
|
||||||
|
CLI/report interpretation and lifecycle in `src/firmware/`. Caller-supplied
|
||||||
|
executables retain their actual launcher digest and unknown source revision;
|
||||||
|
version output alone does not establish source identity. REA does not bundle
|
||||||
|
extractors or download/install firmware engines during analysis.
|
||||||
|
|
||||||
|
On updates: inspect actual pinned CLI and report producers, preserve licenses,
|
||||||
|
update gitlinks/release records, run report regressions and the real firmware
|
||||||
|
lane. See [firmware setup and coverage](../docs/firmware-analysis.md).
|
||||||
+1
Submodule third_party/binwalk added at 4fdab3d464.
+1
Submodule third_party/unblob added at 1fcc7a0a58.
+1
-1
@@ -37,7 +37,7 @@ const TEST_PROJECTS = [
|
|||||||
name: "adapters",
|
name: "adapters",
|
||||||
include: [
|
include: [
|
||||||
"src/*.test.ts",
|
"src/*.test.ts",
|
||||||
"src/{artifacts,browser,dotnet,ghidra,hopper,native,process,reference,server,windows}/**/*.test.ts",
|
"src/{artifacts,browser,dotnet,firmware,ghidra,hopper,native,process,reference,server,windows}/**/*.test.ts",
|
||||||
],
|
],
|
||||||
pool: "forks" as const,
|
pool: "forks" as const,
|
||||||
maxWorkers: MAX_TEST_WORKERS,
|
maxWorkers: MAX_TEST_WORKERS,
|
||||||
|
|||||||
Reference in new issue
Block a user