Skip to content

Commit b0755f8

Browse files
branoverclaude
andauthored
fix: detach analyze_target's per-child recon loop for large firmware (#278)
* fix: detach analyze_target's per-child recon loop for large firmware Found while designing directory-import: promote_file and reveal_dir got detached earlier tonight (#274, #275), but the loop they both actually detach — analyze_target's sequential per-child sandbox recon — was never fixed at its OWN primary call site. `ingest_and_analyze` (hexgraph ingest / target_ingest / the web UI's upload endpoint) calls analyze_target synchronously, inline, with no aggregate bound — a large firmware still blocks the initial ingest call for however long sequential per-child recon takes, the exact same bug class, just one call site earlier in the chain. Above CHILD_RECON_DETACH_THRESHOLD (25 — comfortably above every existing test fixture's few-file unpacks, comfortably below what a real large firmware produces) unpacked children, recon now runs as ONE detached process working through them sequentially, reusing the exact create_task/spawn_detached_task infrastructure built for promote_file and reveal_dir tonight. Child target rows still exist immediately (unpack's own registration is a cheap host-side copy+hash, not the bottleneck) and are returned in the summary; only their recon facts land later. A small firmware (every existing fixture) is completely unaffected — same synchronous behavior, same summary shape, recon_status="done". Threaded through every entry point that calls analyze_target/ ingest_and_analyze directly: CLI (`hexgraph ingest`), MCP (`target_ingest`), and the web UI's upload endpoint (`api_add_target`) all now surface `recon_status` and handle the "queued" case without breaking their existing synchronous-case output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: mention recon_status in target_ingest's MCP catalog description Flagged in #278's review: docs/mcp.md, vr_skill.py, and ingest()'s own docstring were updated to explain the new detached-recon behavior, but the advertised MCP catalog description (the one an agent actually sees under deferred/name-only tool loading) was missed. 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 29d6979 commit b0755f8

9 files changed

Lines changed: 266 additions & 20 deletions

File tree

docs/mcp.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ agent's context small:
7979
its own target), `task_run`, `finding_verify_poc`, `fuzz_verify_artifact`,
8080
`fuzz_start`/`fuzz_resume`, `src_build`, and more. `target_ingest` returns a bounded summary
8181
(the child count plus a preview of the first ~20 children, since firmware can unpack into
82-
hundreds); call `target_list(project_id)` for the full target tree. `target_promote_file`
82+
hundreds); call `target_list(project_id)` for the full target tree. Above a couple dozen
83+
unpacked children, per-child recon runs detached in the background instead of blocking the
84+
call — `recon_status` in the response is "done" or "queued" (children exist, their recon
85+
facts land later; `target_facts` on one to check). `target_promote_file`
8386
returns as soon as the child target exists — analysis runs detached, since promoting a large,
8487
deeply-nested container can mean thousands of sequential sandbox runs; call it again with the
8588
same arguments to poll `analysis_status` (queued/running/succeeded/failed) rather than assuming

src/hexgraph/agent/mcp_catalog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
{"type": "object", "properties": {"project_id": {"type": "string"}, "include_hidden": {"type": "boolean", "description": "also list hidden firmware children (default false)"}}, "required": ["project_id"]}),
110110
("read", "target_facts", _t.target_facts, "The cached recon SUMMARY for a target — its detected architecture and file format, the key imported/exported symbols, and the security mitigations recon already computed at ingest. The cheapest first orientation (it reads stored recon, no sandbox run). Note its `dangerous_imports` to find the risky libc calls fast. For the AUTHORITATIVE, uncapped symbol table + relocations + full mitigations, run re_binutils_facts instead.",
111111
{"type": "object", "properties": {"target_id": {"type": "string"}}, "required": ["target_id"]}),
112-
("run", "target_ingest", _t.ingest, "INGEST a binary/firmware from a local path as a new target (the bytes are processed in the sandbox; firmware unpacks into child targets). Use this to bring a binary/firmware artifact into HexGraph. Creates a project if none is given. Returns a BOUNDED summary — {root_target_id, children_count, a preview of the first ~20 children, format}; call target_list(project_id) for the FULL child set (firmware can unpack into hundreds).",
112+
("run", "target_ingest", _t.ingest, "INGEST a binary/firmware from a local path as a new target (the bytes are processed in the sandbox; firmware unpacks into child targets). Use this to bring a binary/firmware artifact into HexGraph. Creates a project if none is given. Above a couple dozen unpacked children, per-child recon runs DETACHED in the background instead of blocking this call — `recon_status` is \"done\" or \"queued\" (child targets exist either way; their recon facts land later for a large firmware). Returns a BOUNDED summary — {root_target_id, children_count, a preview of the first ~20 children, format, recon_status}; call target_list(project_id) for the FULL child set (firmware can unpack into hundreds).",
113113
{"type": "object", "properties": {"path": {"type": "string", "description": "local filesystem path to the binary/firmware"}, "name": {"type": "string", "description": "optional target name (defaults from the path)"}, "project_id": {"type": "string", "description": "optional; a NEW project is created if omitted"}}, "required": ["path"]}),
114114
("run", "target_promote_file", _t.promote_file, "PROMOTE one file from a firmware target's unpacked filesystem into its OWN child target — the bridge from browsing the rootfs (fs_list/fs_read_file) to ANALYZING a binary in it (re_decompile_function/re_list_functions/task_run/fuzz_start). `path` is relative to the extracted root; an entry's `is_elf` flags a binary worth promoting, `added` means it's already a target. Idempotent per path. Returns as soon as the child target exists (seconds) — analysis (recon; for a CONTAINER, unpack + recon of every nested file) runs DETACHED in the sandbox when Docker is up, since a large deeply-nested firmware package can be thousands of sequential sandbox runs, minutes to hours. `analysis_status` reports queued/running/succeeded/failed; CALL AGAIN with the same target_id/path to poll — returns the same child, no duplicate work. Promoting a CONTAINER (.pkg/squashfs/cpio — e.g. a large vendor firmware package) registers its inner binaries as HIDDEN child targets once analysis succeeds; the result then reports `registered_children` (+ a note: target_list(include_hidden=true) to see them, target_set_visible to analyze one) and `packed_containers` for any still-nested containers. Returns {id, name, kind, parent_id, arch, analysis_status?, registered_children?, packed_containers?, note?}.",
115115
{"type": "object", "properties": {"target_id": {"type": "string", "description": "the firmware target whose filesystem holds the file"}, "path": {"type": "string", "description": "file path relative to the extracted root (see fs_list)"}}, "required": ["target_id", "path"]}),

src/hexgraph/agent/mcp_tools.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2464,7 +2464,13 @@ def ingest(path: str, name: str | None = None, project_id: str | None = None) ->
24642464
"""Ingest a binary/firmware from a local path as a target (firmware unpacks into
24652465
children), running recon in the sandbox. Creates a project if none is given. Returns a
24662466
bounded summary (children_count + a preview of the first ~20 children); call
2467-
target_list(project_id) for the full set."""
2467+
target_list(project_id) for the full set.
2468+
2469+
For a large firmware (more than a couple dozen unpacked children), per-child recon runs
2470+
DETACHED in the background instead of blocking this call — `recon_status` is "done"
2471+
(small — recon already ran) or "queued" (large — the child target rows already exist and
2472+
are in `children`/`children_count`, but their recon facts land later; poll via
2473+
`target_facts` on a child, or just re-list with target_list once you expect it's done)."""
24682474
import os
24692475

24702476
from hexgraph.engine.targets.ingest import create_project, ingest_file
@@ -2490,9 +2496,12 @@ def ingest(path: str, name: str | None = None, project_id: str | None = None) ->
24902496
"root_target_id": summary["root_target_id"],
24912497
"children_count": summary.get("children_count", len(children)),
24922498
"children": children[:_INGEST_CHILD_PREVIEW],
2499+
"recon_status": summary.get("recon_status", "done"),
24932500
}
24942501
if summary.get("format"):
24952502
result["format"] = summary["format"]
2503+
if summary.get("recon_status") == "failed":
2504+
result["warning"] = "background recon could not be started for this firmware's children"
24962505
# G01: a large blob whose container format the unpacker didn't recognize, and the carve
24972506
# attempt extracted nothing — surface it LOUDLY (a top-level warning + the magic bytes) so
24982507
# the operator isn't left with a silent 0-child result and no idea why.
@@ -2509,7 +2518,12 @@ def ingest(path: str, name: str | None = None, project_id: str | None = None) ->
25092518
"(squashfs/cpio/.pkg) are still in the tree and were NOT recursed — the deeper "
25102519
"service surface (e.g. the service runtime: web UI/SSH/SNMP) may be inside. "
25112520
"target_promote_file one to extract + register its inner binaries.")
2512-
if len(children) > _INGEST_CHILD_PREVIEW:
2521+
if summary.get("recon_status") == "queued":
2522+
result["note"] = (f"{len(children)} children found; recon is running in the "
2523+
f"background (large firmware — sequential per-child sandbox "
2524+
f"analysis can take a while). Check target_facts on a child, "
2525+
f"or re-list later, to see recon facts land.")
2526+
elif len(children) > _INGEST_CHILD_PREVIEW:
25132527
result["note"] = (f"{len(children)} children unpacked; showing the first "
25142528
f"{_INGEST_CHILD_PREVIEW}. Use target_list(project_id) for the "
25152529
f"full set.")

src/hexgraph/agent/vr_skill.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@
8383
**Firmware unpacks into child targets**: the extracted binaries become their own targets and
8484
the rootfs becomes browsable. Those children are registered **hidden** (a firmware unpacks
8585
into hundreds of ELFs; a visible child each would bury the graph), but each is still
86-
recon-enriched, searchable, and addressable. `target_list(project_id)` shows the firmware
86+
recon-enriched, searchable, and addressable. Above a couple dozen children, per-child recon
87+
runs DETACHED in the background instead of blocking this call (`recon_status` in the
88+
response: "done" or "queued" — the child targets exist either way, their recon facts just
89+
land later for a large firmware; `target_facts` on one to check, or just come back to it).
90+
`target_list(project_id)` shows the firmware
8791
plus the **revealed** children; `target_list(project_id, include_hidden=true)` (or `fs_list`,
8892
whose entries carry `added`/`revealed`) shows the full set. Pick the binaries worth analyzing
8993
(httpd, cgi-bin handlers, daemons, the libraries they link) and **reveal** them —

src/hexgraph/api/routers/targets.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ def api_add_target(
7171
summary = analyze_target(s, project, target, executor.get_executor())
7272
build_links_against(s, project)
7373
result["children"] = summary.get("children", [])
74+
# A large firmware's per-child recon runs detached (engine.pipeline.
75+
# CHILD_RECON_DETACH_THRESHOLD) — "queued" means the children above are
76+
# registered but their recon facts land later, not that this call failed.
77+
result["recon_status"] = summary.get("recon_status", "done")
7478
return result
7579
finally:
7680
os.unlink(tmp)

src/hexgraph/cli.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,18 @@ def _cmd_ingest(args: argparse.Namespace) -> int:
8080
print(f"target {summary['root_target_id']} {summary['name']}")
8181
for child in summary["children"]:
8282
print(f" child {child['target_id']} {child['name']}")
83-
print(
84-
f"recon complete: {1 + len(summary['children'])} target(s), "
85-
f"{summary['links_against_edges']} links_against edge(s)"
86-
)
83+
if summary.get("recon_status") == "queued":
84+
print(f"{1 + len(summary['children'])} target(s) registered; recon for "
85+
f"{len(summary['children'])} child(ren) is running in the background "
86+
f"(large firmware) — check `hexgraph targets {project_id}` later.")
87+
elif summary.get("recon_status") == "failed":
88+
print(f"{1 + len(summary['children'])} target(s) registered, but background recon "
89+
f"could not be started for the children — re-ingest to retry.", file=sys.stderr)
90+
else:
91+
print(
92+
f"recon complete: {1 + len(summary['children'])} target(s), "
93+
f"{summary['links_against_edges']} links_against edge(s)"
94+
)
8795
return 0
8896

8997

src/hexgraph/engine/pipeline.py

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@
2626
_FIRMWARE_FORMATS = {"squashfs", "cpio", "disk_image"}
2727
_ENRICHABLE_KINDS = {"executable", "shared_library"}
2828

29+
# How many unpacked children to recon INLINE before switching to a detached background task
30+
# instead. Existing test fixtures unpack into single digits (unaffected); a real large
31+
# firmware can unpack into thousands — reconning each is its own sandboxed container
32+
# spin-up, so a small firmware still analyzes fully synchronously (unchanged behavior, exact
33+
# same summary shape as before) while a large one returns fast with the child TARGET ROWS
34+
# already created (unpack_firmware's own registration is a cheap host-side copy+hash, not
35+
# the bottleneck — see _ensure_children_recon_detached) and a task id to poll instead of
36+
# blocking the ingest call for what can be hours. Same bug class already fixed tonight for
37+
# promote_file/reveal_dir, just at the INITIAL-ingest call site (`ingest_and_analyze` /
38+
# `hexgraph ingest` / `target_ingest` never routed through the detached-task system at all).
39+
CHILD_RECON_DETACH_THRESHOLD = 25
40+
2941

3042
def _record_progress(session: Session, target: Target, stage: str, **extra) -> None:
3143
"""Emit a coarse ingest-progress signal so the multi-minute unpack+recon isn't a silent
@@ -69,6 +81,37 @@ def _maybe_enrich_ghidra(session: Session, project: Project, target: Target, fac
6981
pass
7082

7183

84+
def _ensure_children_recon_detached(session: Session, project: Project, target: Target,
85+
children: list[Target]) -> str:
86+
"""Kick off recon for `children` as ONE detached process working through them
87+
SEQUENTIALLY — same "one process for the whole batch, not one per item" reasoning as
88+
`engine.targets.reveal._ensure_batch_ghidra_enrichment` (a directory/firmware can have
89+
thousands of children; spawning one INDEPENDENT process per child would launch that many
90+
CONCURRENT sandbox containers and contend hard for host resources). The Task is anchored
91+
on `target` (the firmware/parent being analyzed); child ids travel in `params_json`.
92+
Returns "queued", or "failed" if the spawn itself raised (the Task ends up marked
93+
failed rather than stuck queued forever — see engine.targets.reveal for the same
94+
self-heal reasoning)."""
95+
from hexgraph.engine.tasks import create_task, mark_failed
96+
from hexgraph.engine.worker import spawn_detached_task
97+
98+
task = create_task(session, project=project, target_id=target.id, type="recon_children_batch",
99+
params={"target_ids": [c.id for c in children]})
100+
session.commit()
101+
try:
102+
spawn_detached_task(task.id)
103+
except Exception: # noqa: BLE001 — never let a spawn failure break the ingest call
104+
mark_failed(task, "failed to spawn detached recon-children process")
105+
session.commit()
106+
return "failed"
107+
meta = dict(target.metadata_json or {})
108+
meta["recon_children_task_id"] = task.id
109+
target.metadata_json = meta
110+
flag_modified(target, "metadata_json")
111+
session.commit()
112+
return "queued"
113+
114+
72115
def analyze_target(
73116
session: Session,
74117
project: Project,
@@ -79,10 +122,16 @@ def analyze_target(
79122
80123
Emits coarse per-stage progress on the root target's metadata (recon → unpacking →
81124
recon i/N children → done) so the multi-minute firmware path isn't a silent black box
82-
(F05). The signal is advisory — a poller watches `metadata_json["ingest_progress"]`."""
125+
(F05). The signal is advisory — a poller watches `metadata_json["ingest_progress"]`.
126+
127+
Above `CHILD_RECON_DETACH_THRESHOLD` children, per-child recon runs DETACHED (see
128+
`_ensure_children_recon_detached`) instead of inline. `summary["recon_status"]` is
129+
"done" (small — recon already ran, exactly like before), "queued" (large — child TARGET
130+
ROWS exist and are already in `summary["children"]`, but their recon facts land later —
131+
poll via `target_facts`/re-ingesting), or "failed" (the detach itself couldn't start)."""
83132
_record_progress(session, target, "recon")
84133
facts = run_recon(session, project, target, runner)
85-
summary = {"target_id": target.id, "name": target.name, "children": []}
134+
summary = {"target_id": target.id, "name": target.name, "children": [], "recon_status": "done"}
86135

87136
# G01: a recognized firmware format OR a large blob whose format we couldn't recognize —
88137
# in the latter case ATTEMPT a binwalk carve anyway (it often recognizes vendor wrappers our
@@ -100,14 +149,18 @@ def analyze_target(
100149
# Materialize the child list first so we can report "recon i/N children" with a known N.
101150
children = list(unpack_firmware(session, project, target, runner))
102151
total = len(children)
103-
for i, child in enumerate(children, start=1):
104-
_record_progress(session, target, "recon_children", done=i - 1, total=total)
105-
# Children are registered HIDDEN by unpack_firmware: recon ENRICHES each
106-
# (metadata + a recon Observation) but materializes no graph nodes — a
107-
# hidden child contributes nothing to the graph until revealed.
108-
child_facts = run_recon(session, project, child, runner)
109-
_maybe_enrich_ghidra(session, project, child, child_facts)
110-
summary["children"].append({"target_id": child.id, "name": child.name})
152+
if total > CHILD_RECON_DETACH_THRESHOLD:
153+
summary["children"] = [{"target_id": c.id, "name": c.name} for c in children]
154+
summary["recon_status"] = _ensure_children_recon_detached(session, project, target, children)
155+
else:
156+
for i, child in enumerate(children, start=1):
157+
_record_progress(session, target, "recon_children", done=i - 1, total=total)
158+
# Children are registered HIDDEN by unpack_firmware: recon ENRICHES each
159+
# (metadata + a recon Observation) but materializes no graph nodes — a
160+
# hidden child contributes nothing to the graph until revealed.
161+
child_facts = run_recon(session, project, child, runner)
162+
_maybe_enrich_ghidra(session, project, child, child_facts)
163+
summary["children"].append({"target_id": child.id, "name": child.name})
111164
# F07: flag packed containers the unpack left in the tree (a large vendor firmware image leaves
112165
# the real web UI/SSH/SNMP runtime in nested .pkg/squashfs that aren't auto-recursed).
113166
# Without this, "N children unpacked" reads as "fully unpacked" and a researcher hunts the
@@ -143,7 +196,8 @@ def analyze_target(
143196
else:
144197
_maybe_enrich_ghidra(session, project, target, facts)
145198
summary["children_count"] = len(summary["children"])
146-
_record_progress(session, target, "done", children=summary["children_count"])
199+
final_stage = "recon_children_queued" if summary["recon_status"] == "queued" else "done"
200+
_record_progress(session, target, final_stage, children=summary["children_count"])
147201
return summary
148202

149203

src/hexgraph/engine/worker.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,31 @@ def _dispatch(session: Session, project: Project, target: Target, task: Task) ->
7070
analyze_target(session, project, target, get_executor())
7171
build_links_against(session, project)
7272
return
73+
if task.type == "recon_children_batch":
74+
# analyze_target's large-firmware path (engine.pipeline.CHILD_RECON_DETACH_THRESHOLD):
75+
# ONE detached process recons every child SEQUENTIALLY — same "one process for the
76+
# whole batch, not one per item" reasoning as ghidra_enrich_batch below. `target`
77+
# here is the PARENT (firmware) being analyzed, not any one of the actual children —
78+
# those travel in params_json.
79+
from hexgraph.engine.pipeline import _maybe_enrich_ghidra, _record_progress
80+
from hexgraph.engine.re.recon import run_recon
81+
82+
child_ids = (task.params_json or {}).get("target_ids", [])
83+
total = len(child_ids)
84+
for i, cid in enumerate(child_ids, start=1):
85+
# _record_progress commits — this doubles as the per-child checkpoint, same as
86+
# the inline (small-firmware) loop already relied on.
87+
_record_progress(session, target, "recon_children", done=i - 1, total=total)
88+
child = session.get(Target, cid)
89+
if child is None:
90+
continue
91+
try:
92+
child_facts = run_recon(session, project, child, get_executor())
93+
_maybe_enrich_ghidra(session, project, child, child_facts)
94+
except Exception: # noqa: BLE001 — one bad child must not abort the rest
95+
pass
96+
_record_progress(session, target, "done", children=total)
97+
return
7398
if task.type == "ghidra_enrich":
7499
from hexgraph.engine.re.ghidra import enrich_target
75100

0 commit comments

Comments
 (0)