やってみた 2026年10月6日

REAに自作フロントのビルド成果物を読ませたら、URLの書き方ひとつで機能の追跡が空振りした

XECIN TypeScriptリバースエンジニアリングフロントエンドビルド

GitHubのトレンドで morluto/rea を見かけました。READMEの見出しは「Reverse Engineer Anything」で、気に入った機能がどう動いているかを、ネイティブバイナリの水準まで理解する、という触れ込みです。

自分はフロントエンドが主戦場なので、ネイティブより先に目が止まったのは「JavaScript and Electron」の項目でした。モジュールやimport、source map、ルート、ストレージなどを、アプリを動かさずに地図にすると書いてあります。本番にsource mapを置くかどうかって、毎回ちょっと揉めるんですよね。置いたら何が読めてしまうのかを、自分が作ったもので確かめられるなら、それだけで試す価値があります。

なので今回読ませたのは、自分で書いた小さなメモアプリのビルド成果物だけです。他人のアプリには当てていません。

試した環境と、doctorに「非対応」と言われた話

# 使い捨てのDockerコンテナ(node:22-bookworm)の中で実行
$ npm install -g rea-agents@4.0.1
added 138 packages in 5s          # 実測 5.25秒
$ rea --version
4.0.1

$ rea doctor
healthy: false
environment_healthy: false
# (中略)
  - name: node
    ok: true
    classification: healthy
    detail: 22.23.1
  - name: host
    ok: false
    classification: unsupported_host
    detail: debian 12
    remediation: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and the experimental Windows x64 Ghidra P0 boundary."
# (中略)
  - name: ghidra
    ok: false
    classification: missing_analysis_engine
    detail: GHIDRA_INSTALL_DIR is not set
    remediation: Set GHIDRA_INSTALL_DIR to an extracted Ghidra 12.1.4 release directory.
# 終了コード 1

環境はDebian 12.14、Node.js 22.23.1、npmで入れた rea-agents は4.0.1(GitHubのリリースは10月5日・UTC)です。インストールは5.25秒で138パッケージ、入ったディレクトリは約72MBでした。

公式のREADMEには、動作環境としてmacOS 12以上、Ubuntu 24.04以上、Fedora 41以上、64-bitのArch Linuxが並んでいます。doctorもDebian 12をはっきり unsupported_host と言ってきますし、HopperもGhidraも入れていないので healthy: false、終了コードは1です。

正直なところ、ここでUbuntuのコンテナに作り直すか迷いました。ただ、実際やってみると、JavaScriptアプリの解析はDebian 12のままでも普通に通ったんですよね。ネイティブバイナリの解析はHopperかGhidraが前提なので、今回はJavaScript側だけに絞っています。ネイティブ側は試していません。

自作のメモアプリを4通りにビルドして読ませた

用意したのは main.js・api.js・storage.js の3ファイルだけのメモアプリです。#/notes と #/settings をハッシュで切り替え、APIの土台は API_BASE = "/api/v1" という定数に置いて、一覧取得と保存を fetch で叩きます。下書きは localStorage の memo-draft-v1 に置きます。v2は api.js に exportNotes を1つ足して、#/export の画面を増やしただけです。

これをesbuild 0.25.12で --bundle --minify --format=iife にかけ、--sourcemap のあり・なしで作り分けました。v1のsource mapなしは、こうなります。

// v1(source map なし)の app.js。732バイト
(()=>{var o="/api/v1";async function n(){return(await fetch(`${o}/notes`)).json()}async function s(t){let e=await fetch(o+"/notes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)});return{ok:e.ok,status:e.status}}var a="memo-draft-v1";function r(t){localStorage.setItem(a,t)}function c(){return localStorage.getItem(a)??""}var l={"/notes":i,"/settings":u};async function i(){let t=await n();document.querySelector("#app").textContent=`notes: ${t.length}`}function u(){document.querySelector("#app").textContent="settings"}window.addEventListener("hashchange",()=>{(l[location.hash.slice(1)]??i)()});document.querySelector("#save")?.addEventListener("click",()=>{let t=c();s({text:t}),r("")});})();

各ビルドを rea analyze-javascript-application <ディレクトリ> に1ケース1プロセスで読ませた結果です。

ビルドapp.jssource map解析時間出力JSON
v1 / mapなし732Bなし1.03秒624,293B
v1 / mapあり764B2,486B0.96秒645,150B
v2 / mapあり1,013B3,226B1.02秒846,701B
v2 / mapなし981Bなし0.99秒825,844B

どれも1秒前後で、終了コードは0でした。出力の制限事項には「JavaScript and HTML were parsed as inert text; bundle bootstrap code was never executed.」とあって、コードはあくまで文字列として構文解析しているだけです。

v1のmapなしでは、構造グラフのノードが10個できました。localStorage.setItem と getItem の呼び出しがstorageノードとして2つ、package.json がpackageノードとして1つ、といった具合です。mapありにすると、source mapのノードが2つと、元ファイルのノードが3つ増えて15個になります。../src-v1/api.js のような元ファイルのパスが、ここで見えるようになるわけです。

一方で、出力JSONを DRAFT_KEY・API_BASE・saveDraft・showSettings で検索すると、mapのあり・なしどちらでも0件でした。minifyで消えた元の変数名や関数名は、少なくとも解析結果には出てきません。/api/v1 や memo-draft-v1 は、バンドルに文字列として残っているので出てきます。

ひとつ意外だったのは、Electronでもない素のブラウザ向けアプリなのに、package.json に main があるだけで electron-main と判定されたことです。根拠は package.json:main、状態は inferred、確からしさは high。index.html のscript参照からは electron-renderer も立ちます。推定だとちゃんと書いてあるので嘘ではないんですが、high が付くのはちょっと驚きました。

# 732バイトのバンドルの解析結果を、そのままエージェントに渡すとしたら…
$ rea analyze-javascript-application /work/v1-nomap --token-count
101097
$ rea analyze-javascript-application /work/v1-map --token-count
104483

# 統計の部分だけに絞ると
$ rea analyze-javascript-application /work/v1-nomap --filter-output normalized_result.statistics --token-count
84

ここは使い方次第だと思っています。入力が732バイトでも、出力は丸ごとだと10万トークンを超えます。MCPでエージェントに全部渡す使い方だと、かなりコンテキストを持っていかれそうです(今回はエージェントから使ってはいないので、そこは未確認です)。CLIに --filter-output や --token-limit が最初から付いているので、欲しいキーを決めて絞るのが現実的な使い方なんだと思います。

v1とv2の差分で、詰まったのは自分のビルド手順だった

次に rea compare-application-versions で版の差分を取りました。入力は左右の解析結果を丸ごと入れたJSONです。ドキュメントには「Evidenceをそのまま入れる」とあるものの、読んだ範囲ではキー名の例が見当たらなかったので、left と right で投げてみたら通りました。機能追跡のほうは evidence で投げたら "path": ["application"] が missing_argument と返ってきて、それでキー名が分かりました。エラーがどの引数の何が悪いかを返してくれるので、手探りでも迷子にはなりません。

# 比較結果の items を並べた自作スクリプトの出力(状態・種類・照合の根拠・確からしさ・名前。変化した観点の列は省略)

# (1) v1 を src-v1/、v2 を src-v2/ という別ディレクトリからビルドして比較
summary: {"unchanged":3,"added":6,"removed":6,"changed":6,"unknown":0}
removed   source-module   none   unknown   ../src-v1/storage.js
added     source-module   none   unknown   ../src-v2/storage.js
# api.js と main.js も同じく removed と added の組になる

# (2) 同じ解析結果を左右に渡すと受け付けない
"message": "Application version Evidence must be distinct"

# (3) 同じ src/ を書き換えてビルドし直した v1 と、その完全コピー(対照)
summary: {"unchanged":15,"added":0,"removed":0,"changed":0,"unknown":0}

# (4) 同じ src/ から作った v1 → v2
summary: {"unchanged":4,"added":3,"removed":3,"changed":8,"unknown":0}
changed     source-module   source-map-identity   high   ../src/api.js
changed     source-module   source-map-identity   high   ../src/main.js
unchanged   source-module   source-map-identity   high   ../src/storage.js

最初の比較で removed が6件出て、何も消していないのに、と思ったら原因は自分でした。v1とv2を別々のディレクトリから作っていたので、source mapに書かれた元ファイルのパスがそもそも違っていたんです。そのせいで、中身が1バイトも変わっていない storage.js まで「消えて、別のものが増えた」扱いになっていました(照合の根拠は none)。

今思えば、実際のプロジェクトでは同じ src/ を書き換えて次の版を作るので、不自然だったのは自分の比べ方のほうなんですよね。同じ src/ から作り直すと、storage.js は unchanged、api.js と main.js だけが changed になって、手を入れたファイルをきっちり言い当てました。完全コピーとの比較で15件すべて unchanged になるのも確認できたので、比較そのものは素直です。

残った removed と added の3件ずつは、成果物ディレクトリ全体(artifact root)と、ハッシュを名前に含むモジュールのノード、source mapのノード1つです。ドキュメントにも、モジュールの並び順やminify後の名前は版をまたいだ識別子にしない、と書いてあるので、そこは設計どおりなんだと思います。

ただ、差分の中に「/export が増えた」という形の項目は出てきません。出てくるのは、ファイルとストレージ呼び出しの単位までです。

「/exportを辿って」が空振りした理由はURLの書き方だった

じゃあ機能のほうから辿ろうと、rea trace-application-feature に文字列 /export を種として渡しました。

# v2(URL は API_BASE 定数+テンプレートで組み立て)。種は {"kind":"string","value":"/export"}
"summary": {"matched_seeds":0,"traced_nodes":0,"traced_edges":0,"terminal_paths":0, ...}
"coverage": {"status":"no-match","source_graph_status":"complete","total_seed_matches":0}
"limitations": ["No graph entity matched the literal seed; this is not evidence that the feature is absent."]
# request ノード3本の endpoint
[null,null,null]

# v3(機能は v2 と同じで、URL だけ文字列リテラルの直書きにした対照)
# request ノード3本の endpoint
["/api/v1/notes","/api/v1/notes","/api/v1/export?format=csv"]
"summary": {"matched_seeds":1,"traced_nodes":17,"traced_edges":20,"terminal_paths":3, ...}
endpoint(/api/v1/export?format=csv) -> javascript-module(app.js) -> storage(call:localStorage.setItem)
endpoint(/api/v1/export?format=csv) -> javascript-module(app.js) -> storage(call:localStorage.getItem)
endpoint(/api/v1/export?format=csv) -> javascript-module(app.js) -> endpoint(/api/v1/notes)

v2では一致が0件でした。fetch の呼び出し自体は3本ともrequestノードとして見つかっていて、method は fetch なのに、endpoint が3本とも null です。API_BASE を定数に置いて、テンプレートリテラルや + で組み立てているからですね。

そこで、機能は同じままURLだけを fetch("/api/v1/export?format=csv") のような直書きにしたv3を作りました。こちらは endpoint が3本とも埋まり、グラフにendpointノードが2つ増えて、/export の追跡も一致しました。

一致しなかったときに「機能が無い証拠ではない」とわざわざ書いてくるのは、好感が持てます。見つからないことを「無い」と言い切らないのは、調べる道具として大事なところだと思います。

ただ、辿れた経路にはクセがあります。/export のendpointから、app.js を経由して localStorage に着いているんですが、エクスポート処理は localStorage を触っていません。v2で元ファイルを種にしても同じで、api.js・storage.js・main.js のどれから辿っても、同じ15ノード・17エッジ・2本の経路で localStorage に着きました。バンドル1本の単位でつながっているので、どのファイルがストレージを触っているかまでは、この追跡では切り分けられないということです。

もっと細かい意味関係のグラフを辿る rea trace-javascript-semantics にも、キーの文字列 memo-draft-v1 から前向きに追わせてみました。関数のノード2つまでは届いたものの、状態は ambiguous で、setItem の呼び出し地点までは届きませんでした。

試してみての所感

自分のビルド成果物が外からどう見えるかの点検には、普通に使えそうです。source mapを置けば元ファイルのパスまで見える、URLを直書きすればendpointとして拾われる、ということを1件1秒で確かめられました。PRごとに同じ src/ から前後の版を作って compare-application-versions に流せば、「どのファイルを変えたか」の機械的な確認には使えると思います。

逆に、「URLを組み立てていればREAには読まれない」と受け取るのは筋が悪いです。null は静的に確定できなかったという意味で、隠せているわけではありません。上のminify後のコードを見れば、/api/v1 も /notes も文字列のまま残っています。

出力の大きさとバンドル単位の粗さは、エージェントにそのまま渡す前提だとまだ様子見です。ネイティブ側はGhidraを入れて試していないので、そっちは別の機会に。

次は、自分が普段触っているプロジェクトの本番ビルドで、前後の版の差分を取るところからやってみるつもりです。もっと良い方法があったら教えてください。