#!/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())