Skip to content

Commit ea3ee20

Browse files
branoverclaude
andauthored
fix: detach Ghidra enrichment on reveal — target_reveal_dir hung 2+ hours (#275)
* fix: detach Ghidra enrichment on reveal — target_reveal_dir hung 2+ hours Revealing a firmware child materializes its recon nodes AND (if Ghidra headless enrichment is enabled) runs Ghidra's full-inventory analysis on it — previously synchronous, inline, inside the reveal call. reveal_dir loops over every hidden child under a directory prefix and calls this per child with no aggregate bound. A real incident: revealing an Erlang erts/bin directory (a dozen-plus binaries — erl, beam.smp, dialyzer, run_erl, ...) ran a cold headless Ghidra full-analysis on each one sequentially, hanging the MCP call for 2+ hours. Same architectural bug class as promote_file (fixed in #274 earlier tonight) — a bulk operation fanning out N synchronous heavy per-item calls inside one blocking request, no detachment, no bound. Split _materialize_on_reveal: node materialization from already-stored recon facts stays synchronous (fast, no re-run). Ghidra enrichment now runs DETACHED via a new `ghidra_enrich` Task type, reusing the create_task/spawn_detached_task infrastructure built for the promote_file fix. Idempotent + self-healing, same pattern: a target already marked enriched or with a queued/running enrichment task is skipped; a task that ended without marking success (died, or a soft failure) gets retried on the next reveal call rather than silently staying unenriched forever. set_visible/reveal_dir now report enrichment_queued so callers (MCP, REST, the FilesystemBrowser UI) know background work is still in flight rather than assuming a fast return means nothing happened. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: batch reveal_dir's Ghidra enrichment into one detached process Review of #275 flagged a real gap: reveal_dir spawned one INDEPENDENT detached process per revealed binary needing enrichment. A directory with a dozen-plus binaries (the incident's own erts/bin case) would launch that many CONCURRENT cold headless Ghidra containers (--memory 2g --cpus 2.0 each) — genuine new resource contention the original blocking code never had (it ran them one at a time, just synchronously). reveal_dir now collects every target that needs enrichment across the whole call and queues ONE `ghidra_enrich_batch` task that processes them sequentially in a single detached process — still off the request path, but no concurrency blowup. set_visible's single-target path is unaffected (spawning one process for one target was never the problem). This also incidentally restores reveal_dir's prior single-commit-point atomicity (the per-child commit the review flagged as a second, smaller issue) as a side effect of consolidating the spawn to one place. Also: mark the Task failed (not stuck "queued" forever) if spawn_detached_task itself raises, in both the single and batch paths — a permanently-queued task would wrongly satisfy the already-running self-heal check and block every future retry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d4904fd commit ea3ee20

8 files changed

Lines changed: 383 additions & 25 deletions

File tree

frontend/src/api.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,11 @@ export const api = {
294294
promoteFile: (pid: string, fwId: string, rel: string) => postJSON<any>(`/api/projects/${pid}/targets/${fwId}/promote-file`, { rel }),
295295
// Reveal/re-hide a target in the curated graph. Firmware ELF children are hidden by default
296296
// (unpack registers them all); revealing materializes the target's recon nodes from stored facts.
297-
setTargetVisible: (pid: string, tid: string, visible: boolean) => postJSON<{ target_id: string; name: string; visible: boolean; materialized: boolean }>(`/api/projects/${pid}/targets/${tid}/visible`, { visible }),
297+
setTargetVisible: (pid: string, tid: string, visible: boolean) => postJSON<{ target_id: string; name: string; visible: boolean; materialized: boolean; enrichment_queued: boolean }>(`/api/projects/${pid}/targets/${tid}/visible`, { visible }),
298298
// Bulk-reveal every hidden firmware child whose rootfs path is under `prefix` (e.g. "usr/sbin").
299-
revealDir: (pid: string, fwId: string, prefix: string) => postJSON<{ firmware_target_id: string; prefix: string; revealed: number; target_ids: string[] }>(`/api/projects/${pid}/targets/${fwId}/reveal-dir`, { prefix }),
299+
// `enrichment_queued` = how many revealed targets got a background Ghidra enrichment task
300+
// (revealing/materializing itself is synchronous and fast; enrichment is not).
301+
revealDir: (pid: string, fwId: string, prefix: string) => postJSON<{ firmware_target_id: string; prefix: string; revealed: number; target_ids: string[]; enrichment_queued: number }>(`/api/projects/${pid}/targets/${fwId}/reveal-dir`, { prefix }),
300302
// Target-level follow-up suggestions from enriched recon metadata (e.g. risky-sink → static-analyze).
301303
targetSuggestions: (tid: string) => getJSON<any[]>(`/api/targets/${tid}/suggestions`),
302304
// Source trees (Phase 1 — read-only IDE browse)

frontend/src/components/FilesystemBrowser.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ export default function FilesystemBrowser({ projectId, targetId, onChanged }: {
7474
try {
7575
const r = await api.revealDir(projectId, targetId, prefix);
7676
if (r.revealed === 0) alert("No hidden binaries to reveal under " + (prefix || "/"));
77+
// Ghidra enrichment per binary (if enabled) runs in the background — a directory with
78+
// many binaries would otherwise block this call for a long time (each is a cold headless
79+
// Ghidra pass). The binaries are already revealed/searchable; enrichment just deepens them.
80+
else if (r.enrichment_queued > 0)
81+
alert(`Revealed ${r.revealed} binaries. Ghidra enrichment for ${r.enrichment_queued} of `
82+
+ `them is running in the background and may take a while.`);
7783
await load(); onChanged?.();
7884
} catch (e: any) { alert(String(e.message || e)); }
7985
finally { setBusy(""); }

src/hexgraph/agent/mcp_catalog.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,9 @@
125125
{"type": "object", "properties": {"project_id": {"type": "string"}, "target_id": {"type": "string"}}, "required": ["project_id", "target_id"]}),
126126
("write", "target_restore", _t.restore_target, "Un-archive a previously soft-removed target subtree (its nodes/findings reappear).",
127127
{"type": "object", "properties": {"project_id": {"type": "string"}, "target_id": {"type": "string"}}, "required": ["project_id", "target_id"]}),
128-
("write", "target_set_visible", _t.set_visible, "REVEAL (visible=true) or re-HIDE (visible=false) ONE target in the curated graph. Firmware ELF children are hidden by default — unpack registers each (searchable via target_list(include_hidden=true), addressable, recon-enriched) but they'd flood the graph, so they contribute NOTHING to it until revealed. Revealing materializes the target's recon symbol/string nodes from the already-stored facts (no re-run), so it joins the graph + Targets pane. Use after target_list(include_hidden=true)/fs_list surfaces a child worth analyzing. Returns {target_id, name, visible, materialized}.",
128+
("write", "target_set_visible", _t.set_visible, "REVEAL (visible=true) or re-HIDE (visible=false) ONE target in the curated graph. Firmware ELF children are hidden by default — unpack registers each (searchable via target_list(include_hidden=true), addressable, recon-enriched) but they'd flood the graph, so they contribute NOTHING to it until revealed. Revealing materializes the target's recon symbol/string nodes from the already-stored facts (no re-run) so it joins the graph + Targets pane immediately; optional Ghidra enrichment (if enabled) runs DETACHED in the background — a cold headless pass can take minutes, this call does not wait for it. Use after target_list(include_hidden=true)/fs_list surfaces a child worth analyzing. Returns {target_id, name, visible, materialized, enrichment_queued}.",
129129
{"type": "object", "properties": {"project_id": {"type": "string"}, "target_id": {"type": "string"}, "visible": {"type": "boolean", "description": "true to reveal (default), false to re-hide"}}, "required": ["project_id", "target_id"]}),
130-
("write", "target_reveal_dir", _t.reveal_dir, "REVEAL every HIDDEN child of a firmware whose rootfs path is under `prefix` (e.g. prefix='usr/sbin' reveals all ELFs in /usr/sbin) — the bulk counterpart to target_set_visible for bringing a whole directory of binaries into the curated graph at once. An empty prefix reveals ALL hidden children. Materializes each revealed child's recon nodes from stored facts (no re-run). `target_id` is the firmware. Returns {firmware_target_id, prefix, revealed, target_ids}.",
130+
("write", "target_reveal_dir", _t.reveal_dir, "REVEAL every HIDDEN child of a firmware whose rootfs path is under `prefix` (e.g. prefix='usr/sbin' reveals all ELFs in /usr/sbin) — the bulk counterpart to target_set_visible for bringing a whole directory of binaries into the curated graph at once. An empty prefix reveals ALL hidden children. Materializes each revealed child's recon nodes from stored facts (no re-run) — fast, even for a directory with many binaries; optional per-binary Ghidra enrichment (if enabled) runs DETACHED in the background instead of blocking this call (a dozen+ binaries analyzed sequentially can take hours). `target_id` is the firmware. Returns {firmware_target_id, prefix, revealed, target_ids, enrichment_queued}.",
131131
{"type": "object", "properties": {"project_id": {"type": "string"}, "target_id": {"type": "string", "description": "the firmware target whose children to reveal"}, "prefix": {"type": "string", "description": "rootfs directory prefix (e.g. 'usr/sbin'); empty reveals all hidden children"}}, "required": ["project_id", "target_id"]}),
132132

133133
# ---- re: static reverse engineering ---------------------------------------------

src/hexgraph/agent/mcp_tools.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1548,8 +1548,10 @@ def set_visible(project_id: str, target_id: str, visible: bool = True) -> dict:
15481548
Firmware ELF children are HIDDEN by default (unpack registers each so it's searchable
15491549
and addressable, but a 765-ELF firmware would otherwise flood the graph/Targets pane);
15501550
recon already enriched them. Revealing materializes the target's recon nodes from the
1551-
already-stored facts (no re-run) so it joins the graph. Returns
1552-
{target_id, name, visible, materialized}."""
1551+
already-stored facts (no re-run) so it joins the graph immediately; optional Ghidra
1552+
enrichment (if enabled) runs DETACHED in the background — `enrichment_queued` says
1553+
whether a task was kicked off (a cold headless Ghidra pass can take minutes; this call
1554+
does not wait for it). Returns {target_id, name, visible, materialized, enrichment_queued}."""
15531555
from hexgraph.engine.targets.reveal import set_visible as _set
15541556

15551557
with session_scope() as s:
@@ -1566,8 +1568,13 @@ def reveal_dir(project_id: str, target_id: str, prefix: str = "") -> dict:
15661568
(e.g. prefix='usr/sbin' reveals all ELFs in /usr/sbin) — the bulk counterpart to
15671569
target_set_visible for bringing a whole directory of binaries into the curated graph
15681570
at once. An empty prefix reveals ALL hidden children. Materializes each revealed
1569-
child's recon nodes from stored facts (no re-run). `target_id` is the firmware.
1570-
Returns {firmware_target_id, prefix, revealed, target_ids}."""
1571+
child's recon nodes from stored facts (no re-run) — fast, even for a directory with many
1572+
binaries. Optional Ghidra enrichment per binary (if enabled) runs DETACHED in the
1573+
background rather than blocking this call (a cold headless Ghidra pass on a dozen+
1574+
binaries sequentially can take hours; this call returns as soon as they're revealed).
1575+
`target_id` is the firmware. Returns {firmware_target_id, prefix, revealed, target_ids,
1576+
enrichment_queued} (enrichment_queued = how many revealed targets got a background
1577+
enrichment task)."""
15711578
# NB: the catalog advertises this arg as `target_id` (the firmware), and the MCP
15721579
# server dispatches by KEYWORD (`fn(**arguments)`), so this param name MUST match
15731580
# the catalog schema — otherwise every MCP call raises TypeError.

src/hexgraph/agent/vr_skill.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,10 @@
417417
`fs_list` entry that's `added` but not `revealed` already IS a target — reveal it (recon
418418
already enriched it; reveal materializes its nodes, no re-run) rather than re-promoting.
419419
Reveal the binaries worth analyzing, then decompile / `task_run` / fuzz them like any other.
420+
If Ghidra enrichment is enabled, it runs DETACHED per revealed binary (`enrichment_queued`
421+
in the response) — a directory can have a dozen-plus binaries, each a cold headless Ghidra
422+
pass; don't expect it done by the time this call returns, and don't loop calling
423+
`target_reveal_dir` back-to-back on overlapping prefixes waiting for it to "finish".
420424
- **target_promote_file(target_id, path)** — promote a NON-ELF file or one unpack didn't register
421425
(a CGI script, a helper) into its OWN child target (created visible), then analyze it. Use
422426
`target_set_visible`/`target_reveal_dir` for the ELF children that already exist hidden, and

src/hexgraph/engine/targets/reveal.py

Lines changed: 122 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,47 +45,147 @@ def _recon_facts(session: Session, project: Project, target: Target) -> dict:
4545
return target.metadata_json or {}
4646

4747

48-
def _materialize_on_reveal(session: Session, project: Project, target: Target) -> None:
49-
"""Bring a just-revealed target's enrichment into the curated graph: its recon
50-
symbol/string nodes (from stored facts) plus the optional Ghidra enrichment that
51-
was deferred while it was hidden. Idempotent (materialize_* dedups)."""
48+
def _materialize_recon_only(session: Session, project: Project, target: Target) -> dict:
49+
"""The fast, synchronous half of revealing: materialize `target`'s recon symbol/string
50+
nodes from already-stored facts (no re-run). Returns the facts dict so the caller can
51+
decide whether Ghidra enrichment applies (see `_needs_ghidra_enrichment`)."""
5252
from hexgraph.engine.re.recon import materialize_recon_nodes
5353

5454
facts = _recon_facts(session, project, target)
5555
materialize_recon_nodes(session, project.id, target, facts)
56-
# Mirror analyze_target's optional Ghidra enrich pass (skipped while hidden).
57-
try:
58-
from hexgraph.engine.re.ghidra import enrich_enabled, enrich_target
56+
return facts
57+
58+
59+
def _needs_ghidra_enrichment(target: Target) -> bool:
60+
"""Whether `target` still wants Ghidra enrichment: the feature is on and it hasn't
61+
already been recorded (see the `ghidra_enrich`/`ghidra_enrich_batch` task dispatch,
62+
which marks `ghidra_enriched` on success only — a soft failure or a killed task leaves
63+
it unmarked so a later reveal retries instead of silently giving up forever)."""
64+
from hexgraph.engine.re.ghidra import enrich_enabled
65+
66+
return enrich_enabled() and not (target.metadata_json or {}).get("ghidra_enriched")
5967

60-
if enrich_enabled() and (facts.get("kind") in ("executable", "shared_library")):
61-
enrich_target(session, project, target)
68+
69+
def _materialize_on_reveal(session: Session, project: Project, target: Target) -> bool:
70+
"""Bring a just-revealed target's enrichment into the curated graph: recon nodes (fast,
71+
synchronous) plus optional Ghidra enrichment, kicked off DETACHED (see
72+
`_ensure_ghidra_enrichment`) rather than run inline — a cold headless Ghidra full-analysis
73+
can take many minutes. Used by `set_visible` (ONE target — see `reveal_dir` for the bulk
74+
path, which batches enrichment instead of spawning one detached process per target).
75+
Idempotent (materialize_* dedups). Returns True if Ghidra enrichment was (newly) queued."""
76+
facts = _materialize_recon_only(session, project, target)
77+
if facts.get("kind") not in ("executable", "shared_library"):
78+
return False
79+
try:
80+
return _ensure_ghidra_enrichment(session, project, target) if _needs_ghidra_enrichment(target) else False
6281
except Exception: # noqa: BLE001 — enrichment is an optional bonus pass
63-
pass
82+
return False
83+
84+
85+
def _ensure_ghidra_enrichment(session: Session, project: Project, target: Target) -> bool:
86+
"""Kick off `target`'s optional Ghidra enrichment (`engine.re.ghidra.enrich_target`) in a
87+
DETACHED background OS process if it isn't already in flight — same pattern as
88+
`engine.targets.filesystem._ensure_analysis`. Caller must have already checked
89+
`_needs_ghidra_enrichment`. Safe to call repeatedly: no-ops while a `ghidra_enrich` Task
90+
for this target is still queued/running. Returns True if a task was (newly) queued."""
91+
from hexgraph.db.models import Task, TaskStatus
92+
93+
already_running = (
94+
session.query(Task)
95+
.filter(Task.target_id == target.id, Task.type == "ghidra_enrich",
96+
Task.status.in_((TaskStatus.queued, TaskStatus.running)))
97+
.first()
98+
)
99+
if already_running is not None:
100+
return False
101+
102+
from hexgraph.engine.tasks import create_task
103+
from hexgraph.engine.worker import spawn_detached_task
104+
from sqlalchemy.orm.attributes import flag_modified
105+
106+
task = create_task(session, project=project, target_id=target.id, type="ghidra_enrich")
107+
meta = dict(target.metadata_json or {})
108+
meta["ghidra_enrich_task_id"] = task.id
109+
target.metadata_json = meta
110+
flag_modified(target, "metadata_json")
111+
session.commit()
112+
try:
113+
spawn_detached_task(task.id)
114+
except Exception: # noqa: BLE001 — if the spawn itself fails (fork/exec resource
115+
# exhaustion), mark the task failed rather than leaving it stuck "queued" forever —
116+
# a permanently-queued task would wrongly satisfy the already_running check above
117+
# and block every future reveal from ever retrying.
118+
from hexgraph.engine.tasks import mark_failed
119+
120+
mark_failed(task, "failed to spawn detached enrichment process")
121+
session.commit()
122+
return False
123+
return True
124+
125+
126+
def _ensure_batch_ghidra_enrichment(session: Session, project: Project, firmware: Target,
127+
target_ids: list[str]) -> int:
128+
"""Kick off Ghidra enrichment for MANY targets in ONE detached process that runs them
129+
SEQUENTIALLY, not one detached process per target. `reveal_dir` can reveal a dozen+
130+
binaries in one call — each is its own cold headless Ghidra analysis (`--memory 2g
131+
--cpus 2.0` per the sandbox spec), so spawning one INDEPENDENT process per target would
132+
launch that many CONCURRENT containers and genuinely contend for host resources, unlike
133+
`promote_file`'s single detached analysis. The Task is anchored on `firmware` (there's no
134+
single natural target for a batch); `target_ids` travel in `params_json`. Returns how
135+
many targets were queued (0 if none needed it or the feature is off)."""
136+
if not target_ids:
137+
return 0
138+
139+
from hexgraph.engine.tasks import create_task
140+
from hexgraph.engine.worker import spawn_detached_task
141+
142+
task = create_task(session, project=project, target_id=firmware.id, type="ghidra_enrich_batch",
143+
params={"target_ids": target_ids})
144+
session.commit()
145+
try:
146+
spawn_detached_task(task.id)
147+
except Exception: # noqa: BLE001 — enrichment is optional, must never break reveal_dir;
148+
# same self-heal reasoning as _ensure_ghidra_enrichment above.
149+
from hexgraph.engine.tasks import mark_failed
150+
151+
mark_failed(task, "failed to spawn detached enrichment process")
152+
session.commit()
153+
return 0
154+
return len(target_ids)
64155

65156

66157
def set_visible(session: Session, project_id: str, target_id: str, visible: bool) -> dict:
67158
"""Reveal (visible=True) or re-hide (visible=False) one target. Revealing
68-
materializes its recon nodes from the already-stored facts (no re-run). Returns
69-
{target_id, visible, materialized} (materialized = nodes were (re)materialized)."""
159+
materializes its recon nodes from the already-stored facts (no re-run); optional Ghidra
160+
enrichment runs detached (see `_materialize_on_reveal`). Returns {target_id, visible,
161+
materialized} (materialized = nodes were (re)materialized)."""
70162
t = session.get(Target, target_id)
71163
if t is None or t.project_id != project_id:
72164
raise ValueError("target not found in project")
73165
was_visible = t.visible
74166
t.visible = visible
75167
materialized = False
168+
enrichment_queued = False
76169
if visible and not was_visible:
77170
project = session.get(Project, project_id)
78-
_materialize_on_reveal(session, project, t)
171+
enrichment_queued = _materialize_on_reveal(session, project, t)
79172
materialized = True
80173
session.flush()
81-
return {"target_id": t.id, "name": t.name, "visible": t.visible, "materialized": materialized}
174+
return {"target_id": t.id, "name": t.name, "visible": t.visible, "materialized": materialized,
175+
"enrichment_queued": enrichment_queued}
82176

83177

84178
def reveal_dir(session: Session, project_id: str, firmware_target_id: str, prefix: str) -> dict:
85179
"""Reveal every HIDDEN child of a firmware whose rootfs-relative name (path) is
86180
under `prefix` (a directory prefix like "usr/sbin" or "/usr/sbin"). Materializes
87-
each revealed child's recon nodes. Returns {firmware_target_id, prefix, revealed,
88-
target_ids}. Already-visible children are left untouched."""
181+
each revealed child's recon nodes (fast); optional Ghidra enrichment for the whole batch
182+
runs as ONE detached background process working through them sequentially — not one
183+
process per target (see `_ensure_batch_ghidra_enrichment`: a directory can have a dozen+
184+
binaries, and spawning that many CONCURRENT cold headless Ghidra containers would
185+
contend hard for host resources, unlike a single target's `set_visible`).
186+
Returns {firmware_target_id, prefix, revealed, target_ids, enrichment_queued}
187+
(enrichment_queued = how many revealed targets got queued into that background batch).
188+
Already-visible children are left untouched."""
89189
fw = session.get(Target, firmware_target_id)
90190
if fw is None or fw.project_id != project_id:
91191
raise ValueError("firmware target not found in project")
@@ -115,17 +215,22 @@ def _under(rel: str) -> bool:
115215
.all()
116216
)
117217
revealed_ids: list[str] = []
218+
to_enrich: list[str] = []
118219
for c in children:
119220
if c.visible:
120221
continue
121222
if c.id in ids_under_prefix or _under(c.name): # any manifest path under prefix, or its own name
122223
c.visible = True
123-
_materialize_on_reveal(session, project, c)
224+
facts = _materialize_recon_only(session, project, c)
225+
if facts.get("kind") in ("executable", "shared_library") and _needs_ghidra_enrichment(c):
226+
to_enrich.append(c.id)
124227
revealed_ids.append(c.id)
125228
session.flush()
229+
enrichment_queued = _ensure_batch_ghidra_enrichment(session, project, fw, to_enrich)
126230
return {
127231
"firmware_target_id": firmware_target_id,
128232
"prefix": prefix,
129233
"revealed": len(revealed_ids),
130234
"target_ids": revealed_ids,
235+
"enrichment_queued": enrichment_queued,
131236
}

0 commit comments

Comments
 (0)