From 2eb2a0a8cff586fffecfcb068c53b65e8f6f9839 Mon Sep 17 00:00:00 2001 From: blasty Date: Fri, 7 Aug 2026 22:55:27 +0200 Subject: Export findings as markdown (Ctrl+E), and the journal that makes it true The output of an RE session is what you worked out, and it was locked in a .i64 that only IDA can read. Ctrl+E (or `drive export`, or the `export` RPC verb) writes it out: your comments grouped by function with the line each annotates, the names and prototypes you set, the types you declared. **The hard part was provenance, and it needed a mechanism, not a filter.** A database 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, and the ELF loader sets `elf_gnu_hash_nbuckets` and `File class: 64-bit` the same way. Four probes, all negative: the FF_COMM flag is identical, `get_cmt` returns them all, `generate_disasm_line` tags every one of them COLOR_REGCMT (not COLOR_AUTOCMT), and they survive with auto-comments switched off. A first cut filtered by shape and produced a report whose first screen was ELF header trivia and `; jumptable ... case 99`. So idatui journals its own edits (idatui/journal.py) into a netnode in the database: it rides along in the .i64, it is still there next session, and the report is then exactly what was done here -- 2 findings out of a database carrying 693 other annotations. Recorded at the choke points in edit_ctl (rename, name-address, comment, retype) and in the struct editor; flushed on save, on export and on quit, so no edit pays a round trip. Without a journal (a database worked on in the IDA GUI, or predating this) the report falls back to filtering by shape -- dummy names, imports, loader segments, the analyzer's stereotyped switch/jumptable strings -- and says so in the document rather than claiming authorship it cannot prove. idatui/findings.py splits gather (needs IDA) from render (does not), so the formatting, grouping, sorting, escaping and the empty cases are tested offline: tests/test_findings.py, 32 checks, no worker, 0.1s. The pilot scenario covers the round trip that matters -- edit through the UI, export, find it in the file, and reload the journal from the .i64. Full suite: 842 passed, 0 failed, 51.2s. --- README.md | 6 + docs/RPC.md | 22 +++ idatui/app.py | 81 +++++++++- idatui/codemode_client.py | 101 ++++++++++++ idatui/domain.py | 69 +++++++++ idatui/drive.py | 10 +- idatui/edit_ctl.py | 5 + idatui/findings.py | 384 ++++++++++++++++++++++++++++++++++++++++++++++ idatui/journal.py | 107 +++++++++++++ idatui/rpc.py | 22 ++- tests/test_findings.py | 194 +++++++++++++++++++++++ tests/test_scenarios.py | 82 ++++++++++ 12 files changed, 1077 insertions(+), 6 deletions(-) create mode 100644 idatui/findings.py create mode 100644 idatui/journal.py create mode 100644 tests/test_findings.py 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 `.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 # -> .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 @@ -500,6 +500,107 @@ for item in page: rows.append({"addr": hex(int(item.address)), "text": text, "len": int(item.length), "type": item.type.name}) 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} diff --git a/idatui/domain.py b/idatui/domain.py index 6fe73c2..15f4ef4 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -251,6 +251,37 @@ def link_name(raw: str) -> str: return n[:at] if at > 0 else n +@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 @@ -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 -- cgit v1.3.1-sl0p