aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md6
-rw-r--r--docs/RPC.md22
-rw-r--r--idatui/app.py81
-rw-r--r--idatui/codemode_client.py101
-rw-r--r--idatui/domain.py69
-rw-r--r--idatui/drive.py10
-rw-r--r--idatui/edit_ctl.py5
-rw-r--r--idatui/findings.py384
-rw-r--r--idatui/journal.py107
-rw-r--r--idatui/rpc.py22
-rw-r--r--tests/test_findings.py194
-rw-r--r--tests/test_scenarios.py82
12 files changed, 1077 insertions, 6 deletions
diff --git a/README.md b/README.md
index c52019b..1092219 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,12 @@ a 400 MB binary scrolls like a text file.
**Decompiler** — Hex-Rays pseudocode with syntax highlighting, per-line address
anchors, and rename/retype/comment that write back.
+**Findings export** (`ctrl+e`) — the session as a markdown writeup: your
+comments grouped by function, the names and prototypes you set, the types you
+declared. A `.i64` does not record *who* wrote a comment — IDA's own analyzer
+uses the same call — so idatui journals its edits into the database as it makes
+them, and the report is built from that. Also `python -m idatui.drive export`.
+
**Structs / types** (`ctrl+t`) — local types as plain C: the list on the left,
an editable, syntax-highlighted definition on the right. `Ctrl+S` declares it
back into the database and reformats to IDA's own layout, `Ctrl+N` starts a new
diff --git a/docs/RPC.md b/docs/RPC.md
index d13e8cd..4e736f6 100644
--- a/docs/RPC.md
+++ b/docs/RPC.md
@@ -102,6 +102,7 @@ predicate so the returned state is final.
| `xrefs` | — | `x`: open the xref picker. |
| `symbols` | `query?` | Ctrl+N palette, optionally pre-typed. |
| `structs` | — | Ctrl+T struct editor. |
+| `export` | `path?`, `types?=true` | write the session's findings as **markdown** and return `{path, comments, names, types, functions, bytes}`. Not typed through a prompt: the point of this verb is the file it leaves behind, so a driver gets the path back rather than a screenshot. Defaults to `<binary>.findings.md`. See *Findings export* below. |
| `search` | `term`, `direction?=1` | `/` (or `?`) incremental search in the active code view. |
| `select` | `index?` | in an open modal list (xrefs/symbols) choose the highlighted (or nth) item and activate it. |
| `save` | — | Ctrl+S: persist the `.i64`. |
@@ -131,6 +132,27 @@ symbol file: each one costs a navigation (listing page + decompile) plus two
prompt round-trips, i.e. tens of minutes for a few hundred symbols, where
`rename_many` is one call and a few seconds.
+### Findings export
+
+`export` writes what the session **worked out** -- comments, names, prototypes
+and the types you declared -- as one markdown document.
+
+The interesting part is provenance. A `.i64` does not record *who* wrote a
+comment or a name: IDA's analyzer sets `; switch 73 cases` and `; s1` with the
+same `set_cmt` a person uses, the loader sets `elf_gnu_hash_nbuckets` and
+`File class: 64-bit` the same way, and the flags, `get_cmt` and even the colour
+tag in `generate_disasm_line` are identical for all of them. So idatui keeps its
+own **journal** (`idatui/journal.py`) of every edit it makes, in a netnode
+inside the database, and the report is built from that -- exact, and still there
+next session. Ask it on a database nobody journalled (worked on in the IDA GUI,
+or before this existed) and it falls back to filtering by shape and says so in
+the document.
+
+```sh
+python -m idatui.drive export # -> <binary>.findings.md
+python -m idatui.drive export /tmp/writeup.md
+```
+
### Movement (fast — bare keypresses, pump-only settle)
| method | params | effect |
|--------|--------|--------|
diff --git a/idatui/app.py b/idatui/app.py
index 608cc4d..4a603f3 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -50,7 +50,9 @@ from . import kittygfx
from .edit_ctl import EditController
from .prompt import PromptBar
from .trace_ctl import TraceController
+from . import findings
from .highlight import CTextArea, highlight_c
+from .journal import Journal
from .errors import IDAToolError, IDAConnectionError
from .codemode_client import CodeModeClient, registered_database
@@ -3444,6 +3446,7 @@ _HELP = (
("B", "cycle the opcode-bytes column"),
("Ctrl+B", "show/hide the names pane"),
("Ctrl+T", "structs / types editor"),
+ ("Ctrl+E", "export findings as markdown"),
("Ctrl+P", "command palette"),
)),
("Move", (
@@ -4539,6 +4542,8 @@ class StructEditor(ModalScreen):
self._set_status(f"save failed — {msg}", error=True)
return
self._loaded = name
+ if name:
+ self.app.journal.record("type", None, name)
if formatted:
# Reformat the editor to IDA's canonical layout (keeps cursor at top).
ta = self.query_one("#se-edit", TextArea)
@@ -4590,6 +4595,7 @@ class StructEditor(ModalScreen):
return
if getattr(self.app, "_dirty", None) is not None:
self.app._dirty = True
+ self.app.journal.record("del_type", None, name)
self._refresh()
self._set_status(f"deleted {name}")
@@ -4664,6 +4670,8 @@ class IdaCommands(Provider):
app.action_strings),
("Switch binary…", "another binary in the project (Ctrl+O)",
app.action_switch_binary),
+ ("Export findings…", "your comments, names and types as markdown "
+ "(Ctrl+E)", app.action_export),
("Keyboard shortcuts", "the key cheatsheet (F1 or H)",
app.action_help),
("Follow symbol under cursor", "jump to the referenced symbol (Enter)",
@@ -4774,6 +4782,10 @@ class IdaTui(App):
height: 1; border: none; padding: 0 1;
background: $primary-darken-3; color: $text;
}
+ #export {
+ height: 1; border: none; padding: 0 1;
+ background: $success-darken-3; color: $text;
+ }
#status { height: 1; background: $panel; color: $text; padding: 0 1; }
.decomp-loading {
width: 100%; height: 100%; content-align: center middle;
@@ -4882,6 +4894,7 @@ class IdaTui(App):
Binding("q", "quit", "Quit"),
Binding("ctrl+n", "symbols", "Symbols"),
Binding("ctrl+t", "structs", "Structs"),
+ Binding("ctrl+e", "export", "Export", show=False),
Binding("backslash", "hex", "Hex"),
Binding("s", "toggle_split", "Split", show=False),
# IDA's own key for text/graph. Graph mode is opt-in and self-contained:
@@ -4993,9 +5006,12 @@ class IdaTui(App):
#: The one-line prompts above the footer. Each holds its own context
#: for exactly as long as it is on screen; see idatui/prompt.py.
self.prompts = PromptBar(self, "search", "rename", "comment",
- "retype", "makedata", "goto")
+ "retype", "makedata", "goto", "export")
#: Everything that writes to the database (idatui/edit_ctl.py).
self.edits = EditController(self)
+ #: What those writes were, so the findings export can say which
+ #: comments and names are YOURS -- the database cannot (journal.py).
+ self.journal = Journal()
self._xref_focus_name: str | None = None
self._dirty = False
@@ -5045,6 +5061,10 @@ class IdaTui(App):
gi.display = False
gi.can_focus = False
yield gi
+ xi = Input(id="export")
+ xi.display = False
+ xi.can_focus = False
+ yield xi
# markup=False: the status is plain text full of [listing]/[split]/[label]
# markers and symbol names that may contain brackets. With Textual markup
# on, a single-word marker parses as a style tag and is silently eaten —
@@ -6304,6 +6324,50 @@ class IdaTui(App):
inp.value = self._filter_term
inp.focus()
+ def action_export(self) -> None:
+ """Ctrl+E: write what you have worked out to a markdown report.
+
+ Prefilled with a path beside the binary, because the common case is
+ "just write it" and the rare case is worth one edit.
+ """
+ if self.program is None:
+ self._status("no database open", priority=True)
+ return
+ inp = self.query_one("#export", Input)
+ inp.placeholder = "export findings to… (markdown) — Enter"
+ inp.can_focus = True
+ inp.display = True
+ inp.value = findings.default_path(self._open_path or "findings")
+ self.query_one("#status", Static).display = False
+ inp.focus()
+
+ def _end_export(self) -> None:
+ inp = self.query_one("#export", Input)
+ inp.display = False
+ inp.can_focus = False
+ self.query_one("#status", Static).display = True
+
+ def export_findings(self, path: str | None = None) -> None:
+ """Gather + write the report off the UI thread (it walks the database)."""
+ self._status("exporting findings…", priority=True)
+ self._export_worker(path)
+
+ @work(thread=True, exclusive=True, group="export")
+ def _export_worker(self, path: str | None) -> None:
+ try:
+ self.journal.load(self.program)
+ self.journal.flush(self.program)
+ out, f = findings.export(self.program, self._open_path or "", path,
+ journal=self.journal)
+ except Exception as e: # noqa: BLE001 -- a bad path is a message, not a crash
+ self.call_from_thread(self._status, f"export failed: {e}", True)
+ return
+ n_named = len(findings._user_names(f))
+ self.call_from_thread(
+ self._status,
+ f"exported {len(f.comments)} comments, {n_named} names, "
+ f"{len(f.types)} types → {out}", True)
+
def action_goto(self) -> None:
inp = self.query_one("#goto", Input)
inp.placeholder = ("hex goto: 0xADDR or name — Enter" if self.is_hex
@@ -6892,6 +6956,7 @@ class IdaTui(App):
def _save(self) -> None:
assert self.program is not None
try:
+ self.journal.flush(self.program) # ride along into the .i64
self.program.client.save_database()
except Exception as e: # noqa: BLE001
self.app.call_from_thread(self._status, f"save failed: {e}")
@@ -7191,8 +7256,8 @@ class IdaTui(App):
event.prevent_default()
if prompt.id == "search":
self._end_search(cancel=True)
- elif prompt.id == "goto":
- self._end_goto()
+ elif prompt.id in ("goto", "export"):
+ self._end_goto() if prompt.id == "goto" else self._end_export()
(self.query_one(HexView) if self.is_hex
else (self._code_view() or self.query_one(ListingView))).focus()
else:
@@ -7240,6 +7305,13 @@ class IdaTui(App):
if value:
self._goto(value)
return
+ if inp.id == "export":
+ self._end_export()
+ (self.query_one(HexView) if self.is_hex
+ else (self._code_view() or self.query_one(ListingView))).focus()
+ if value:
+ self.export_findings(value)
+ return
# filter mode: already applied incrementally; Enter just confirms + closes.
self._apply_filter(value)
self.query_one("#func-table", DataTable).focus()
@@ -7461,7 +7533,8 @@ class IdaTui(App):
# Prompt overlays that own the keyboard while visible; a background
# navigation must not yank focus out from under them (else typed keys leak
# into a code view as destructive verbs — e.g. 'u' = undefine).
- _PROMPT_IDS = ("search", "rename", "comment", "retype", "goto", "func-filter")
+ _PROMPT_IDS = ("search", "rename", "comment", "retype", "goto", "export",
+ "func-filter")
def _prompt_active(self) -> bool:
for iid in self._PROMPT_IDS:
diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py
index 88d7b31..4c3ec99 100644
--- a/idatui/codemode_client.py
+++ b/idatui/codemode_client.py
@@ -501,6 +501,107 @@ for item in page:
result = {"strings": rows, "total": len(items), "next_offset": offset + len(rows)}
result
''',
+ # Everything a person ADDED to the database: comments, non-dummy names, and
+ # the prototypes they set.
+ #
+ # Names come from IDA's name list, which is already an index -- no scan at
+ # all. Comments have no index, so they need a walk, and the walk is over
+ # HEADS: `next_that`'s predicate is a *Python* callback (SWIG calls it with
+ # one argument, so `f_has_cmt` does not even fit), which would be one call
+ # per BYTE -- 400 million of them on a big image. `max_scan` bounds it and
+ # reports `truncated` rather than sitting there.
+ "list_annotations": r'''
+import ida_bytes, ida_funcs, ida_lines, ida_nalt, ida_name
+import ida_segment, ida_typeinf, idautils
+limit = max(1, int(a.get("limit", 4000)))
+max_scan = max(1000, int(a.get("max_scan", 2000000)))
+comments, names = [], []
+scanned = 0
+
+def _line(ea):
+ try:
+ txt = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS)
+ except Exception:
+ txt = ""
+ return " ".join((txt or "").split())
+
+for ea, nm in idautils.Names():
+ if len(names) >= limit:
+ break
+ if not nm or not ida_bytes.has_user_name(ida_bytes.get_flags(ea)):
+ continue
+ fn = ida_funcs.get_func(ea)
+ is_fn = fn is not None and int(fn.start_ea) == int(ea)
+ proto = None
+ if is_fn:
+ try:
+ ti = ida_typeinf.tinfo_t()
+ if ida_nalt.get_tinfo(ti, ea):
+ proto = str(ti)
+ except Exception:
+ proto = None
+ seg = ida_segment.getseg(ea)
+ names.append({"addr": hex(int(ea)), "name": nm, "func": is_fn,
+ "size": (int(fn.end_ea - fn.start_ea) if is_fn else 0),
+ "proto": proto,
+ "seg": (ida_segment.get_segm_name(seg) if seg else "")})
+
+for i in range(ida_segment.get_segm_qty()):
+ seg = ida_segment.getnseg(i)
+ if seg is None or len(comments) >= limit or scanned >= max_scan:
+ continue
+ for ea in idautils.Heads(seg.start_ea, seg.end_ea):
+ scanned += 1
+ if len(comments) >= limit or scanned >= max_scan:
+ break
+ if not ida_bytes.has_cmt(ida_bytes.get_flags(ea)):
+ continue
+ for rep in (False, True):
+ text = ida_bytes.get_cmt(ea, rep)
+ if text:
+ fn = ida_funcs.get_func(ea)
+ comments.append({
+ "addr": hex(int(ea)), "text": text, "repeatable": rep,
+ "line": _line(ea), "seg": ida_segment.get_segm_name(seg),
+ "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None),
+ "func_addr": (hex(int(fn.start_ea)) if fn else None)})
+
+# Whole-function comments are not on the byte flags, so the scan cannot see them.
+for fn_ea in idautils.Functions():
+ fn = ida_funcs.get_func(fn_ea)
+ if fn is None or len(comments) >= limit:
+ continue
+ for rep in (False, True):
+ text = ida_funcs.get_func_cmt(fn, rep)
+ if text:
+ seg = ida_segment.getseg(fn_ea)
+ comments.append({"addr": hex(int(fn_ea)), "text": text,
+ "repeatable": rep, "line": "", "whole_func": True,
+ "seg": (ida_segment.get_segm_name(seg) if seg else ""),
+ "func": ida_funcs.get_func_name(fn_ea),
+ "func_addr": hex(int(fn_ea))})
+result = {"comments": comments, "names": names, "scanned": scanned,
+ "truncated": (len(comments) >= limit or len(names) >= limit
+ or scanned >= max_scan)}
+result
+''',
+ # The findings journal (idatui/journal.py). A netnode blob rides along in
+ # the .i64, so "what did I work out here" survives closing the database.
+ "journal_get": r'''
+import ida_netnode
+n = ida_netnode.netnode(a.get("node", "$ idatui.journal"))
+blob = n.getblob(0, "I") if ida_netnode.exist(n) else None
+result = {"data": blob.decode("utf-8", "replace") if blob else ""}
+result
+''',
+ "journal_put": r'''
+import ida_netnode
+n = ida_netnode.netnode(a.get("node", "$ idatui.journal"), 0, True)
+payload = (a.get("data") or "").encode("utf-8")
+n.setblob(payload, 0, "I")
+result = {"ok": True, "bytes": len(payload)}
+result
+''',
"list_linkage": r'''
imports = [{"addr": hex(int(item.address)), "name": item.name, "module": item.module_name}
for item in db.imports.get_all_imports() if item.name]
diff --git a/idatui/domain.py b/idatui/domain.py
index 6fe73c2..15f4ef4 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -252,6 +252,37 @@ def link_name(raw: str) -> str:
@dataclass(frozen=True)
+class Comment:
+ """One comment somebody wrote into the database.
+
+ ``line`` is the disassembly the comment is attached to, carried along so a
+ report can show what was being commented ON without a second round trip.
+ ``whole_func`` marks a function comment rather than an instruction one.
+ """
+ addr: int
+ text: str
+ repeatable: bool = False
+ whole_func: bool = False
+ line: str = ""
+ seg: str = ""
+ func: str | None = None
+ func_addr: int | None = None
+
+
+@dataclass(frozen=True)
+class NamedItem:
+ """An address carrying a real name -- one you typed, or one the file's own
+ symbols supplied. IDA records both as "user" names and does not remember
+ which was which, so a report must say so rather than claim authorship."""
+ addr: int
+ name: str
+ is_func: bool = False
+ size: int = 0
+ proto: str | None = None
+ seg: str = ""
+
+
+@dataclass(frozen=True)
class Linkage:
"""One import or export: a name this binary takes from, or offers to, other
modules. ``module`` is set for imports (the library IDA attributes it to),
@@ -1903,6 +1934,44 @@ class Program:
self._linkage = out
return out
+ def annotations(self, limit: int = 4000) -> tuple[list["Comment"], list["NamedItem"]]:
+ """``(comments, names)`` -- everything a person added to this database.
+
+ Not cached: it is the *current* state of your work, and the one caller
+ (the findings export) asks for it once. ``([], [])`` if the backend has
+ no such operation, so an alternate client degrades instead of breaking.
+ """
+ try:
+ payload = self.client.invoke("list_annotations", limit=int(limit))
+ except IDAToolError:
+ return ([], [])
+ if not isinstance(payload, dict):
+ return ([], [])
+ comments = [
+ Comment(addr=_as_int(r.get("addr", 0)), text=str(r.get("text", "")),
+ repeatable=bool(r.get("repeatable")),
+ whole_func=bool(r.get("whole_func")),
+ line=str(r.get("line", "") or ""),
+ seg=str(r.get("seg", "") or ""),
+ func=(r.get("func") or None),
+ func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") else None))
+ for r in payload.get("comments", []) if isinstance(r, dict) and r.get("text")]
+ names = [
+ NamedItem(addr=_as_int(r.get("addr", 0)), name=str(r.get("name", "")),
+ is_func=bool(r.get("func")), size=int(r.get("size", 0) or 0),
+ proto=(r.get("proto") or None), seg=str(r.get("seg", "") or ""))
+ for r in payload.get("names", []) if isinstance(r, dict) and r.get("name")]
+ return (comments, names)
+
+
+ def journal_get(self) -> str:
+ """The findings journal blob stored in this database ('' if none)."""
+ payload = self.client.invoke("journal_get")
+ return str(payload.get("data", "")) if isinstance(payload, dict) else ""
+
+ def journal_put(self, data: str) -> None:
+ self.client.invoke("journal_put", data=str(data))
+
def decomp_map(self, ea: int) -> list[list[int]]:
"""Per-pseudocode-line instruction coverage for the split-view region
highlight: a list aligned to the decompiled lines, each the EAs the
diff --git a/idatui/drive.py b/idatui/drive.py
index 825111c..6ba13c6 100644
--- a/idatui/drive.py
+++ b/idatui/drive.py
@@ -296,6 +296,14 @@ def cmd_save(c, args):
return " saved"
+def cmd_export(c, args):
+ """export [path] -- write the session's findings as markdown."""
+ r = c.call("export", **({"path": args[0]} if args else {}))
+ return (f" {r.get('path')} ({r.get('bytes', 0)} bytes: "
+ f"{r.get('comments', 0)} comments, {r.get('names', 0)} names, "
+ f"{r.get('types', 0)} types)")
+
+
def cmd_screen(c, args):
return c.call("screen").get("text", "")
@@ -315,7 +323,7 @@ COMMANDS = {
"callees": cmd_callees, "callers": cmd_callers, "names": cmd_names,
"rename": cmd_rename, "mv": cmd_mv, "note": cmd_note, "retype": cmd_retype,
"save": cmd_save, "screen": cmd_screen, "raw": cmd_raw, "define": cmd_define,
- "syms": cmd_syms, "fmt": cmd_fmt,
+ "syms": cmd_syms, "fmt": cmd_fmt, "export": cmd_export,
"binaries": cmd_binaries, "switch": cmd_switch,
}
diff --git a/idatui/edit_ctl.py b/idatui/edit_ctl.py
index 80e1109..52566ca 100644
--- a/idatui/edit_ctl.py
+++ b/idatui/edit_ctl.py
@@ -266,6 +266,7 @@ class EditController:
if kind == "func" and addr is not None:
self._rename_index(addr, new)
app._dirty = True
+ app.journal.record("rename", addr, f"{old} → {new}", {"kind": kind})
app._status(f"renamed {old} → {new} (Ctrl+S to save)")
def do_name_addr(self, addr: int, name: str) -> None: # worker context
@@ -317,6 +318,7 @@ class EditController:
self._rename_index(addr, name)
app._open_at(addr, label, idx, False, -1, 0, True)
app._dirty = True
+ app.journal.record("rename", addr, f"→ {name}")
app._status(f"named {addr:#x} → {name} (Ctrl+S to save)")
# -- comments (IDA ';') ------------------------------------------------- #
@@ -382,6 +384,7 @@ class EditController:
app.program.bump_names()
self.reload_active_code()
app._dirty = True
+ app.journal.record("comment" if text else "uncomment", ea, text)
verb = "cleared comment" if not text else "commented"
app._status(f"{verb} @ {ea:#x} (Ctrl+S to save)")
@@ -470,6 +473,8 @@ class EditController:
app.program.bump_names()
self.reload_active_code()
app._dirty = True
+ app.journal.record("retype", getattr(app._cur, "ea", None), word,
+ {"kind": kind})
what = "prototype" if kind == "func" else f"'{word}'"
app._status(f"retyped {what} (Ctrl+S to save)")
diff --git a/idatui/findings.py b/idatui/findings.py
new file mode 100644
index 0000000..58a69f6
--- /dev/null
+++ b/idatui/findings.py
@@ -0,0 +1,384 @@
+"""Export a reverse-engineering session as a markdown report.
+
+The output of an RE session is not the database, it is what you *learned* --
+and that lives scattered across comments, names and types inside a `.i64` that
+only IDA can read. This turns it into one document you can paste into an
+advisory, a writeup or a ticket.
+
+Two halves, deliberately separated:
+
+* :func:`gather` talks to a :class:`~idatui.domain.Program` (the only part that
+ needs IDA) and returns a plain :class:`Findings`.
+* :func:`render` turns a :class:`Findings` into markdown and knows nothing about
+ IDA, so the formatting -- grouping, sorting, escaping, the empty cases -- is
+ tested offline in ``tests/test_findings.py``.
+
+**On authorship.** IDA records "this address has a real name" but not *who*
+named it, so a stripped binary's report is exactly your renames while a binary
+with symbols also lists the ones it shipped with. The report says which case it
+is rather than claiming credit; comments and types have no such ambiguity.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import time
+from dataclasses import dataclass, field
+
+
+@dataclass
+class Findings:
+ """Everything the report can show, already fetched. Plain data on purpose."""
+
+ binary: str = ""
+ path: str = ""
+ #: (start, end, name) segments, for the overview
+ sections: list[tuple[int, int, str]] = field(default_factory=list)
+ n_functions: int = 0
+ #: idatui.domain.Comment
+ comments: list = field(default_factory=list)
+ #: idatui.domain.NamedItem
+ names: list = field(default_factory=list)
+ #: (idatui.domain.Struct, source or "")
+ types: list[tuple[object, str]] = field(default_factory=list)
+ #: names IDA supplied from imports/exports -- excluded from "named", since
+ #: they are the linker's work, not anyone's finding
+ linked: set[str] = field(default_factory=set)
+ stripped: bool = True
+ truncated: bool = False
+ #: annotations dropped as the loader's own work, reported as a count
+ skipped_loader: int = 0
+ #: Addresses idatui recorded itself editing (idatui/journal.py). When this
+ #: is non-empty the report is EXACT -- it is what you did, not what the
+ #: database happens to contain. Empty means nobody journalled this database
+ #: (worked on in the IDA GUI, or before this feature), and the report falls
+ #: back to filtering by shape, which it says out loud.
+ recorded: set[int] = field(default_factory=set)
+ #: type names the journal saw declared, for the same reason
+ recorded_types: set[str] = field(default_factory=set)
+ n_recorded: int = 0
+ generated_at: float = field(default_factory=time.time)
+
+
+#: Segments the *loader* owns rather than the program: the ELF/PE header and
+#: friends. IDA annotates those itself -- "File format: \x7FELF", "File class:
+#: 64-bit", `elf_gnu_hash_nbuckets` -- through the very same set_cmt/set_name
+#: calls a person uses, and the database does not record who called them. So a
+#: report that trusted `has_user_name` alone opened with forty lines of ELF
+#: header trivia. Anything here is the file describing itself; it is reported as
+#: a count, never as a finding.
+_LOADER_SEGS = frozenset({"LOAD", "HEADER", "MEMORY", "UNDEF", "abs", "extern"})
+
+#: Same idea for names the loader derives from format structures.
+_LOADER_NAME = re.compile(r"^(?:elf|pe|macho|coff|dos)_", re.I)
+
+
+def from_loader(seg: str, name: str = "") -> bool:
+ """True if this annotation is the file format describing itself."""
+ return (seg or "") in _LOADER_SEGS or bool(_LOADER_NAME.match(name or ""))
+
+
+#: IDA's *analyzer* also writes comments, with `set_cmt`, and the database keeps
+#: no record that they are its own -- verified: `get_cmt` at a switch returns
+#: "switch jump" with exactly the flags a hand-written comment has. These are
+#: its stereotyped shapes, which no one types by accident.
+_ANALYZER = re.compile(
+ r"^(?:switch \d+ cases?|switch jump|jumptable [0-9A-Fa-f]+\b.*|"
+ r"indirect table for switch.*|jump table for switch.*)$", re.I)
+
+#: The other family is argument hints (`s1`, `locale`, `domainname`), which IDA
+#: copies from the callee's prototype onto each argument-setup instruction. They
+#: have no distinguishing shape -- but they REPEAT, once per call site, while a
+#: note you wrote is yours alone. Three occurrences of one whitespace-free text
+#: is the threshold; anything filtered is counted in the report, never dropped
+#: in silence.
+_HINT_REPEATS = 3
+
+
+def analyzer_texts(comments) -> set[str]:
+ """The comment texts in ``comments`` that look like IDA's own work."""
+ counts: dict[str, int] = {}
+ for c in comments:
+ text = (c.text or "").strip()
+ if text and not text.split()[1:]: # a single whitespace-free token
+ counts[text] = counts.get(text, 0) + 1
+ out = {t for t, n in counts.items() if n >= _HINT_REPEATS}
+ out |= {(c.text or "").strip() for c in comments
+ if _ANALYZER.match((c.text or "").strip())}
+ return out
+
+
+#: Names IDA invents when nobody has said otherwise. An address carrying one of
+#: these has not been understood by anybody, so it is not a finding.
+_DUMMY = re.compile(
+ r"^(?:(?:sub|loc|locret|off|seg|asc|byte|word|dword|qword|xmmword|ymmword|"
+ r"flt|dbl|tbyte|stru|algn|unk|nullsub|def|jpt|jsub)_[0-9A-Fa-f]+"
+ # j_strlen: a thunk name IDA derives from its target, not from a person.
+ r"|j_\w+)$")
+
+
+def is_dummy(name: str) -> bool:
+ """True for an IDA-generated placeholder name (``sub_1234``, ``loc_A0``…)."""
+ return bool(_DUMMY.match(name or ""))
+
+
+def gather(program, path: str = "", *, limit: int = 4000,
+ types: bool = True, journal=None) -> Findings:
+ """Collect a :class:`Findings` from a live :class:`Program`.
+
+ ``path`` is the binary the app opened -- ``Program`` speaks to a database
+ and does not know the file name the user would recognise.
+
+ Every step is individually guarded: a report that is missing its types
+ section is worth far more than an exception at the end of a long session.
+ """
+ out = Findings()
+ out.path = path or ""
+ out.binary = os.path.basename(out.path) if out.path else ""
+ if journal is not None:
+ try:
+ out.recorded = journal.addresses()
+ out.recorded_types = {e.get("d", "") for e in journal.entries
+ if e.get("k") == "type" and e.get("d")}
+ out.n_recorded = len(journal)
+ except Exception: # noqa: BLE001
+ out.recorded, out.recorded_types, out.n_recorded = set(), set(), 0
+ try:
+ out.sections = list(program.sections())
+ except Exception: # noqa: BLE001
+ out.sections = []
+ try:
+ comments, names = program.annotations(limit=limit)
+ except Exception: # noqa: BLE001
+ comments, names = [], []
+ out.comments, out.names = list(comments), list(names)
+ try:
+ imports, exports = program.linkage()
+ out.linked = {i.name for i in imports} | {e.name for e in exports}
+ except Exception: # noqa: BLE001
+ out.linked = set()
+ try:
+ idx = program.functions()
+ idx.load_all()
+ out.n_functions = len(idx)
+ # "Stripped" is a judgement about the report, not about the ELF: if
+ # almost every function is still sub_XXXX, a real name IS a finding.
+ named = sum(1 for f in idx.all_loaded() if not is_dummy(f.name))
+ out.stripped = named <= max(4, out.n_functions // 20)
+ except Exception: # noqa: BLE001
+ pass
+ if types:
+ try:
+ for st in program.list_structs():
+ try:
+ src = program.struct_source(st.name)
+ except Exception: # noqa: BLE001
+ src = ""
+ out.types.append((st, src))
+ except Exception: # noqa: BLE001
+ out.types = []
+ return out
+
+
+def _esc(text: str) -> str:
+ """Make one line safe inside a markdown TABLE cell."""
+ return (text or "").replace("|", "\\|").replace("\n", " ").strip()
+
+
+def _fence(text: str) -> str:
+ """Fence body text so a comment containing backticks cannot break out."""
+ ticks = "`" * max(3, max((len(m) for m in re.findall(r"`+", text or "")),
+ default=0) + 1)
+ return f"{ticks}\n{(text or '').rstrip()}\n{ticks}"
+
+
+def _user_names(f: Findings) -> list:
+ """The names worth reporting.
+
+ With a journal, that is exactly the addresses we recorded renaming. Without
+ one, it is a judgement: a real name, not the linker's, not the loader's.
+ """
+ names = [n for n in f.names
+ if not is_dummy(n.name) and n.name not in f.linked
+ and not from_loader(n.seg, n.name)]
+ if f.recorded:
+ return [n for n in names if n.addr in f.recorded]
+ return names
+
+
+def _user_types(f: Findings) -> list:
+ """The types worth reporting. A database is seeded with the type libraries
+ IDA loaded, so with a journal we show only the ones declared here; without
+ one, all of them, newest ordinal first (yours are the newest)."""
+ if f.recorded or f.recorded_types:
+ return [t for t in f.types
+ if getattr(t[0], "name", "") in f.recorded_types]
+ return list(f.types)
+
+
+def _user_comments(f: Findings) -> list:
+ """The comments a person wrote: not the loader's, not the analyzer's, and
+ not the same comment reported twice."""
+ auto = analyzer_texts(f.comments)
+ out, seen = [], set()
+ for c in f.comments:
+ text = (c.text or "").strip()
+ if not text or from_loader(c.seg) or text in auto:
+ continue
+ if f.recorded and c.addr not in f.recorded:
+ continue
+ # A comment on a function's first instruction comes back BOTH as an
+ # instruction comment and as the function comment; report it once.
+ key = (c.addr, text)
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(c)
+ return out
+
+
+def render(f: Findings) -> str:
+ """Render a :class:`Findings` as a markdown document."""
+ when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(f.generated_at))
+ names = sorted(_user_names(f), key=lambda n: n.addr)
+ funcs = [n for n in names if n.is_func]
+ data = [n for n in names if not n.is_func]
+ comments = sorted(_user_comments(f), key=lambda c: (c.func_addr or c.addr,
+ c.addr))
+ dropped = (len(f.comments) - len(comments)) + (len(f.names) - len(names))
+ types = _user_types(f)
+
+ L: list[str] = []
+ title = f.binary or "database"
+ L.append(f"# Findings — {title}")
+ L.append("")
+ L.append(f"*{len(funcs)} named functions · {len(data)} named data · "
+ f"{len(comments)} comments · {len(types)} local types — "
+ f"exported {when} by idatui*")
+ L.append("")
+ if f.path:
+ L.append(f"- **binary**: `{f.path}`")
+ if f.n_functions:
+ L.append(f"- **functions**: {f.n_functions}")
+ if f.sections:
+ segs = ", ".join(f"`{nm}` {s:#x}–{e:#x}" for s, e, nm in f.sections[:8])
+ more = f" (+{len(f.sections) - 8} more)" if len(f.sections) > 8 else ""
+ L.append(f"- **segments**: {segs}{more}")
+ if f.recorded or f.recorded_types:
+ n_at = len(f.recorded)
+ L.append(f"- **source**: idatui's edit journal — {f.n_recorded} recorded "
+ f"edits across {n_at} address{'' if n_at == 1 else 'es'}. "
+ "Everything below is work done here, not the analyzer's.")
+ else:
+ L.append("- **source**: a scan of the database. Nothing in a `.i64` "
+ "records *who* wrote a comment or a name — IDA's own analyzer "
+ "uses the same calls — so this is filtered by shape and may "
+ "include its work as well as yours.")
+ if not f.stripped:
+ L.append("- **note**: this binary has its own symbols, so the names "
+ "below include ones it shipped with.")
+ if dropped and (f.recorded or f.recorded_types):
+ L.append(f"- **note**: {dropped} other annotations in this database "
+ "were not made here (the analyzer's, the loader's, the "
+ "linker's) and are left out.")
+ elif dropped:
+ L.append(f"- **note**: {dropped} annotations left out as the loader's "
+ "own (file headers, dummy names, imports).")
+ if f.truncated:
+ L.append("- **note**: the scan hit its limit; this report is partial.")
+ L.append("")
+
+ # -- comments: the actual reasoning, so they lead ----------------------- #
+ L.append("## Comments")
+ L.append("")
+ if not comments:
+ L.append("*None. (Comments are the part of a database nobody else can "
+ "reconstruct — they are worth writing.)*")
+ L.append("")
+ else:
+ by_func: dict[str, list] = {}
+ for c in comments:
+ by_func.setdefault(c.func or "", []).append(c)
+ for fn in sorted(by_func, key=lambda k: (k == "", k)):
+ rows = by_func[fn]
+ head = f"### `{fn}`" if fn else "### outside any function"
+ if fn and rows[0].func_addr is not None:
+ head += f" ({rows[0].func_addr:#x})"
+ L.append(head)
+ L.append("")
+ for c in rows:
+ if c.whole_func:
+ L.append(f"- **{c.addr:#x}** — *whole function*: "
+ f"{_esc(c.text)}")
+ elif c.line:
+ L.append(f"- **{c.addr:#x}** `{_esc(c.line)}` \n"
+ f" {_esc(c.text)}")
+ else:
+ L.append(f"- **{c.addr:#x}** — {_esc(c.text)}")
+ L.append("")
+
+ # -- names -------------------------------------------------------------- #
+ L.append("## Named functions")
+ L.append("")
+ if not funcs:
+ L.append("*None.*")
+ L.append("")
+ else:
+ L.append("| address | name | size | prototype |")
+ L.append("|---|---|---|---|")
+ for n in funcs:
+ proto = f"`{_esc(n.proto)}`" if n.proto else ""
+ L.append(f"| `{n.addr:#x}` | `{_esc(n.name)}` | "
+ f"{n.size:#x} | {proto} |")
+ L.append("")
+ if data:
+ L.append("## Named data")
+ L.append("")
+ L.append("| address | name | segment |")
+ L.append("|---|---|---|")
+ for n in data:
+ L.append(f"| `{n.addr:#x}` | `{_esc(n.name)}` | {_esc(n.seg)} |")
+ L.append("")
+
+ # -- types -------------------------------------------------------------- #
+ if types:
+ L.append("## Local types")
+ L.append("")
+ if not (f.recorded or f.recorded_types):
+ L.append("*Newest first. A database is seeded with types from the "
+ "libraries IDA loaded, so the ones you defined are the "
+ "ones with the highest ordinals — at the top of this "
+ "list.*")
+ L.append("")
+ ordered = sorted(types, key=lambda t: -getattr(t[0], "ordinal", 0))
+ for st, src in ordered:
+ kw = "union" if getattr(st, "is_union", False) else "struct"
+ L.append(f"### `{kw} {st.name}` "
+ f"({getattr(st, 'size', 0):#x} bytes, "
+ f"{getattr(st, 'members', 0)} fields)")
+ L.append("")
+ if src:
+ L.append("```c")
+ L.append(src.rstrip())
+ L.append("```")
+ L.append("")
+ return "\n".join(L).rstrip() + "\n"
+
+
+def default_path(program_path: str) -> str:
+ """Where a report lands if nobody says otherwise: beside the binary."""
+ base = program_path or "findings"
+ return f"{base}.findings.md"
+
+
+def export(program, binary_path: str = "", out_path: str | None = None, *,
+ limit: int = 4000, types: bool = True,
+ journal=None) -> tuple[str, Findings]:
+ """Gather, render and WRITE the report. Returns ``(path, findings)``."""
+ f = gather(program, binary_path, limit=limit, types=types, journal=journal)
+ out = out_path or default_path(f.path)
+ out = os.path.abspath(os.path.expanduser(out))
+ with open(out, "w", encoding="utf-8") as fh:
+ fh.write(render(f))
+ return out, f
diff --git a/idatui/journal.py b/idatui/journal.py
new file mode 100644
index 0000000..a5a07de
--- /dev/null
+++ b/idatui/journal.py
@@ -0,0 +1,107 @@
+"""A record of the edits idatui makes, kept inside the database.
+
+**Why this has to exist.** A findings report wants to say "here is what *you*
+worked out", and the database cannot answer that. IDA's own analyzer writes
+comments with the same `set_cmt` a person uses (`; s1` on an argument setup,
+`; switch 73 cases` on a jump table), and the loader writes both comments and
+names for the file header. Four separate probes agree that nothing tells them
+apart: the `FF_COMM` flag is identical, `get_cmt` returns them all, the colour
+tag in `generate_disasm_line` is `COLOR_REGCMT` for every one of them, and they
+survive with auto-comments switched off. So authorship is not recoverable after
+the fact -- it has to be recorded as it happens, which is what this does.
+
+It lives in an IDA **netnode**, so it is saved into the `.i64` with everything
+else and is still there next session. The entries are small and additive; the
+journal is metadata *about* edits that themselves live in the database, so
+losing it degrades the report to a heuristic rather than losing work.
+"""
+
+from __future__ import annotations
+
+import json
+import threading
+import time
+
+#: Where the blob lives inside the database.
+NODE = "$ idatui.journal"
+
+#: Cap: a long session is hundreds of edits, not hundreds of thousands, and the
+#: blob is rewritten whole. Oldest entries fall off first.
+MAX_ENTRIES = 20000
+
+
+class Journal:
+ """Append-only log of what was edited, with lazy load and explicit flush.
+
+ Writing through to the database on every keystroke-sized edit would put a
+ round trip in the way of the user; the in-memory list is authoritative
+ during a session and :meth:`flush` persists it at the points that already
+ mean "keep this": saving, exporting, and quitting.
+ """
+
+ def __init__(self) -> None:
+ self.entries: list[dict] = []
+ self._dirty = False
+ self._loaded = False
+ self._lock = threading.Lock()
+
+ # -- recording ---------------------------------------------------------- #
+ def record(self, kind: str, ea: int | None = None, detail: str = "",
+ extra: dict | None = None) -> None:
+ """Note one edit: ``kind`` is 'rename' / 'comment' / 'retype' / …"""
+ entry = {"k": str(kind), "t": int(time.time())}
+ if ea is not None:
+ entry["ea"] = int(ea)
+ if detail:
+ entry["d"] = str(detail)[:400]
+ if extra:
+ entry.update(extra)
+ with self._lock:
+ self.entries.append(entry)
+ if len(self.entries) > MAX_ENTRIES:
+ del self.entries[:len(self.entries) - MAX_ENTRIES]
+ self._dirty = True
+
+ def addresses(self, kinds: tuple[str, ...] | None = None) -> set[int]:
+ """Every address touched (optionally only by certain kinds of edit)."""
+ with self._lock:
+ return {e["ea"] for e in self.entries
+ if "ea" in e and (kinds is None or e.get("k") in kinds)}
+
+ def __len__(self) -> int:
+ return len(self.entries)
+
+ # -- persistence -------------------------------------------------------- #
+ def load(self, program) -> None:
+ """Read the journal out of the database, once. Never raises."""
+ if self._loaded:
+ return
+ self._loaded = True
+ try:
+ raw = program.journal_get()
+ except Exception: # noqa: BLE001 -- an old database simply has none
+ return
+ if not raw:
+ return
+ try:
+ data = json.loads(raw)
+ except Exception: # noqa: BLE001
+ return
+ if isinstance(data, list):
+ with self._lock:
+ # Prepend: what is already in memory happened later.
+ self.entries = [e for e in data if isinstance(e, dict)] + self.entries
+
+ def flush(self, program) -> bool:
+ """Write the journal back if it changed. Returns whether it wrote."""
+ with self._lock:
+ if not self._dirty:
+ return False
+ payload = json.dumps(self.entries, separators=(",", ":"))
+ try:
+ program.journal_put(payload)
+ except Exception: # noqa: BLE001 -- never let bookkeeping break an edit
+ return False
+ with self._lock:
+ self._dirty = False
+ return True
diff --git a/idatui/rpc.py b/idatui/rpc.py
index 2a63812..98ff90f 100644
--- a/idatui/rpc.py
+++ b/idatui/rpc.py
@@ -39,7 +39,7 @@ _PROGRAM_METHODS = {
"goto", "open", "rename", "comment", "retype", "follow", "xrefs", "symbols",
"structs", "search", "select", "save", "hex", "toggle_view",
"pseudocode", "disassembly", "xrefs_to", "xrefs_from", "resolve",
- "define", "rename_many", "opfmt", "graph",
+ "define", "rename_many", "opfmt", "graph", "export",
}
# Self-documenting method table (returned by the 'methods' verb).
@@ -75,6 +75,8 @@ METHODS = {
"xrefs": "open the xref picker",
"symbols": "{query?} open the symbol palette",
"structs": "open the struct editor",
+ "export": "{path?,types?=true} write the session's comments/names/types as "
+ "a markdown report -> {path,comments,names,types}",
"search": "{term,direction?=1} incremental search in the code view",
"select": "{index?} choose the highlighted/nth item in the open modal",
"save": "persist the .i64 (Ctrl+S)",
@@ -1173,6 +1175,24 @@ class RpcServer:
return await self._press(
["ctrl+t"], lambda: type(app.screen).__name__ == "StructEditor",
timeout, "structs")
+ if method == "export":
+ # Deliberately NOT driven through the prompt: this is the one verb
+ # whose whole point is the file it leaves behind, and a driver needs
+ # the path back, not a screenshot of a prompt closing.
+ from . import findings
+ path = params.get("path")
+ app.journal.load(app.program)
+ app.journal.flush(app.program)
+ out, f = await asyncio.to_thread(
+ findings.export, app.program, app._open_path or "",
+ str(path) if path else None,
+ types=bool(params.get("types", True)), journal=app.journal)
+ app._status(f"exported findings → {out}", priority=True)
+ await drain(app)
+ return {"path": out, "comments": len(f.comments),
+ "names": len(findings._user_names(f)),
+ "types": len(f.types), "functions": f.n_functions,
+ "bytes": os.path.getsize(out) if os.path.exists(out) else 0}
if method == "close":
return await self._press(["escape"], timeout=timeout)
if method == "save":
diff --git a/tests/test_findings.py b/tests/test_findings.py
new file mode 100644
index 0000000..cf813c2
--- /dev/null
+++ b/tests/test_findings.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""The findings export: gathering and, mostly, RENDERING.
+
+`idatui.findings.render` takes plain data and returns markdown, so all of the
+interesting behaviour -- grouping, sorting, what an empty section says, and
+whether hostile text can break out of a table cell or a code fence -- is
+testable with no IDA, no worker and no binary. `gather` is covered against a
+fake Program that answers like the real one, including by raising.
+"""
+
+#: pure: stdlib only.
+#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
+NEEDS_IDA = False
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from idatui.domain import Comment, NamedItem, Struct # noqa: E402
+from idatui.findings import ( # noqa: E402
+ Findings, default_path, from_loader, gather, is_dummy, render,
+)
+
+PASS = FAIL = 0
+
+
+def check(name, cond, detail=""):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+def sample() -> Findings:
+ return Findings(
+ binary="echo", path="/tmp/echo",
+ sections=[(0x1000, 0x2000, ".text"), (0x2000, 0x2100, ".data")],
+ n_functions=128,
+ comments=[
+ Comment(addr=0x1100, text="length is attacker controlled",
+ line="mov edi, [rbp+len]", func="parse", func_addr=0x1000),
+ Comment(addr=0x1010, text="entry", line="push rbp",
+ func="parse", func_addr=0x1000),
+ Comment(addr=0x1000, text="parses the header", whole_func=True,
+ func="parse", func_addr=0x1000),
+ Comment(addr=0x2004, text="magic", line="dd 0DEADBEEFh"),
+ # What the ELF loader writes into every database, through the very
+ # same set_cmt a person uses.
+ Comment(addr=0x4, text="File class: 64-bit", line="db 2",
+ seg="LOAD"),
+ ],
+ names=[
+ NamedItem(addr=0x1000, name="parse", is_func=True, size=0x120,
+ proto="int __fastcall parse(char *)"),
+ NamedItem(addr=0x1200, name="sub_1200", is_func=True, size=0x30),
+ NamedItem(addr=0x2004, name="hdr_magic", seg=".data"),
+ NamedItem(addr=0x1400, name="memcpy", is_func=True, size=0x40),
+ NamedItem(addr=0x390, name="elf_gnu_hash_nbuckets", seg="LOAD"),
+ ],
+ types=[(Struct(name="hdr", size=0x10, is_union=False, members=3,
+ ordinal=42), "struct hdr\n{\n int magic;\n};\n"),
+ (Struct(name="Elf64_Dyn", size=0x10, is_union=False, members=2,
+ ordinal=3), "struct Elf64_Dyn\n{\n int d_tag;\n};\n")],
+ linked={"memcpy"},
+ stripped=True,
+ )
+
+
+def main() -> int:
+ doc = render(sample())
+
+ check("the report names the binary", doc.startswith("# Findings — echo"), doc[:40])
+ # 1 function, not 3: sub_1200 is IDA's invention and memcpy is the linker's.
+ check("the summary counts only what a person contributed",
+ "1 named functions · 1 named data · 4 comments · 2 local types" in doc,
+ doc.splitlines()[2] if len(doc.splitlines()) > 2 else "")
+
+ # A name IDA invented is not a finding, and neither is one the linker gave.
+ check("dummy names are excluded", "sub_1200" not in doc)
+ check("imported names are excluded", "`memcpy`" not in doc)
+ check("real names survive", "`parse`" in doc and "`hdr_magic`" in doc)
+
+ # The loader annotates every database it makes; none of it is a finding.
+ check("the loader's own comments are left out", "File class" not in doc)
+ check("the loader's own names are left out", "elf_gnu_hash" not in doc)
+ check("but the report says how many it dropped",
+ "4 annotations left out as the loader's own" in doc, # 1 comment + 3 names
+ [l for l in doc.splitlines() if "left out" in l])
+ check("from_loader knows both shapes",
+ from_loader("LOAD") and from_loader("", "elf_gnu_hash_x")
+ and not from_loader(".text", "parse"))
+ check("is_dummy knows the shapes IDA invents",
+ all(is_dummy(n) for n in ("sub_1234", "loc_A0", "unk_4000", "j_free"))
+ and not any(is_dummy(n) for n in ("parse", "sub_parse", "main", "")),
+ "")
+
+ # Comments lead, grouped by function, address-ordered within a group.
+ check("comments come before the name tables",
+ doc.index("## Comments") < doc.index("## Named functions"))
+ body = doc[doc.index("## Comments"):doc.index("## Named functions")]
+ check("comments are grouped under their function", "### `parse`" in body)
+ check("a commentless region is grouped separately",
+ "### outside any function" in body)
+ check("comments are ordered by address inside a group",
+ body.index("0x1010") < body.index("0x1100"))
+ check("a function comment says that is what it is",
+ "*whole function*: parses the header" in body)
+ check("an instruction comment carries the line it annotates",
+ "`mov edi, [rbp+len]`" in body)
+
+ # Types: newest ordinal first, because that is the one you just wrote.
+ types = doc[doc.index("## Local types"):]
+ check("your newest type is first",
+ types.index("hdr") < types.index("Elf64_Dyn"))
+ check("type source is fenced as C", "```c\nstruct hdr" in types)
+
+ # Escaping.
+ hostile = Findings(binary="x", comments=[
+ Comment(addr=1, text="a | b", line="mov | rax"),
+ ], names=[NamedItem(addr=2, name="a|b")])
+ hdoc = render(hostile)
+ check("a pipe cannot break a table row", "a\\|b" in hdoc, hdoc)
+ check("a pipe in a comment is escaped too", "a \\| b" in hdoc)
+
+ # The empty database must still produce a document that says something.
+ empty = render(Findings(binary="nothing"))
+ check("an empty report is still a document",
+ empty.startswith("# Findings — nothing") and "## Comments" in empty)
+ check("and it says why it is empty", "Comments are the part" in empty)
+ check("an empty report has no dangling type section",
+ "## Local types" not in empty)
+
+ # Provenance must be stated, not implied. Without a journal the report is a
+ # scan and says so; with one it is exactly what idatui recorded doing.
+ scanned = render(Findings(binary="x", stripped=False,
+ names=[NamedItem(addr=1, name="main", is_func=True)]))
+ check("a scanned report admits it cannot know who wrote what",
+ "**source**: a scan of the database" in scanned
+ and "include its work as well as yours" in scanned)
+ check("and warns when the binary brought its own symbols",
+ "include ones it shipped with" in scanned)
+
+ j = sample()
+ j.recorded = {0x1100, 0x1000}
+ j.n_recorded = 7
+ jdoc = render(j)
+ check("a journalled report says so", "idatui's edit journal" in jdoc
+ and "7 recorded edits" in jdoc, "")
+ jbody = jdoc[jdoc.index("## Comments"):jdoc.index("## Named functions")]
+ check("and lists only the comments it recorded",
+ "length is attacker controlled" in jbody and "0x2004" not in jbody,
+ jbody)
+ check("a journalled report drops names it did not record",
+ "`parse`" in jdoc and "hdr_magic" not in jdoc)
+
+ # -- gather ------------------------------------------------------------- #
+ class FakeProgram:
+ def sections(self):
+ return [(0x1000, 0x2000, ".text")]
+
+ def annotations(self, limit=4000):
+ return ([Comment(addr=1, text="hi")],
+ [NamedItem(addr=1, name="parse", is_func=True)])
+
+ def linkage(self):
+ return ([], [])
+
+ def functions(self):
+ raise RuntimeError("index unavailable")
+
+ def list_structs(self):
+ raise RuntimeError("no types")
+
+ def struct_source(self, name):
+ return ""
+
+ f = gather(FakeProgram(), "/tmp/echo")
+ check("gather reads the annotations", len(f.comments) == 1 and len(f.names) == 1)
+ check("gather takes the binary name from the path", f.binary == "echo", f.binary)
+ check("a failing backend degrades the report instead of raising",
+ f.n_functions == 0 and f.types == [] and "# Findings" in render(f))
+
+ check("the default path sits beside the binary",
+ default_path("/tmp/echo") == "/tmp/echo.findings.md")
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 6c3177a..0b004b3 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -1093,6 +1093,88 @@ async def s_structs(c: Ctx):
c.check("Esc closes the struct editor", not isinstance(app.screen, StructEditor))
+@scenario("export_findings")
+async def s_export_findings(c: Ctx):
+ """Ctrl+E writes a markdown report of what this session worked out.
+
+ Deliberately end-to-end: the interesting failure is not the formatting (that
+ is covered offline in test_findings.py) but whether a comment and a rename
+ made through the UI come back out of the database and into the file.
+ """
+ import tempfile
+
+ from idatui.findings import default_path
+
+ app = c.app
+ fn = c.biggest()
+ tag = os.getpid()
+ newname, note = f"exp_{tag}", f"found_it_{tag}"
+ old = fn.name
+ await c.open(fn.addr, "listing")
+
+ # Make something to find: a rename and a comment, through the real paths.
+ app.program.client.invoke(
+ "rename", batch={"func": {"addr": hex(fn.addr), "name": newname}})
+ app.program.bump_names()
+ app.program.set_comment(fn.addr, note)
+ app.program.invalidate(fn.addr)
+ # ...and tell the journal, exactly as the edit controller would. The
+ # database cannot say who wrote a comment (IDA's own analyzer uses the same
+ # call), so the journal is what makes this MY finding rather than noise.
+ app.journal.record("rename", fn.addr, f"{old} → {newname}")
+ app.journal.record("comment", fn.addr, note)
+
+ out = os.path.join(tempfile.gettempdir(), f"idatui-findings-{tag}.md")
+ try:
+ await c.press("ctrl+e")
+ inp = app.query_one("#export", Input)
+ opened = await c.wait(lambda: inp.display, 5)
+ c.check("Ctrl+E opens the export prompt", opened, f"display={inp.display}")
+ c.check("the prompt is prefilled with a path beside the binary",
+ inp.value == default_path(app._open_path), f"value={inp.value!r}")
+ inp.value = out
+ await c.press("enter")
+ written = await c.wait(lambda: os.path.exists(out), 30)
+ c.check("Enter writes the report", written, f"no {out}")
+ if not written:
+ return
+ doc = open(out, encoding="utf-8").read()
+ c.check("the report is markdown with the expected sections",
+ doc.startswith("# Findings") and "## Comments" in doc
+ and "## Named functions" in doc, doc[:60])
+ c.check("a comment written this session is in it", note in doc,
+ doc[:200])
+ c.check("and the function it belongs to is named", newname in doc,
+ doc[:200])
+ c.check("the report is sourced from the journal, not a scan",
+ "idatui's edit journal" in doc,
+ [l for l in doc.splitlines() if "**source**" in l])
+ c.check("the analyzer's own comments stay out of it",
+ "switch jump" not in doc and "jumptable" not in doc,
+ [l for l in doc.splitlines() if "switch" in l][:2])
+ c.check("the status line says where it went",
+ out in c.status(), c.status())
+ # The journal has to survive the database, or a report is only ever
+ # about the session that happened to be open.
+ from idatui.journal import Journal
+
+ app.journal.flush(app.program)
+ reloaded = Journal()
+ reloaded.load(app.program)
+ c.check("the journal round-trips through the .i64",
+ fn.addr in reloaded.addresses(),
+ f"{len(reloaded)} entries, {sorted(reloaded.addresses())[:3]}")
+ finally:
+ # Idempotent: hand the database back exactly as we found it.
+ app.program.set_comment(fn.addr, "")
+ app.program.client.invoke(
+ "rename", batch={"func": {"addr": hex(fn.addr), "name": old}})
+ app.program.bump_names()
+ app.program.invalidate(fn.addr)
+ if os.path.exists(out):
+ os.remove(out)
+
+
@scenario("struct_filter")
async def s_struct_filter(c: Ctx):
app = c.app