import hashlib import html import zlib from .discovery import extract_strings, function_starts, score_starts from .formats import parse_header from .layout import sections, segments, shannon_entropy from .linkage import chained_fixups, exports, imports, relocations from .symbols import symbols REPORT_SCHEMA = "binary-inspector/report/v1" DIFF_SCHEMA = "binary-inspector/diff/v1" REPORT_FIELDS = { "schema": str, "file": dict, "header": dict, "sections": list, "segments": list, "symbols": list, "imports": list, "exports": list, "relocations": list, "fixups": list, "strings": list, "function_starts": dict, "byte_map": list, "summary": dict, } ITEM_FIELDS = { "sections": ("name", "offset", "size", "file_size", "perms", "entropy"), "segments": ("name", "offset", "size", "file_size", "perms", "entropy"), "symbols": ("name", "value", "kind", "binding", "section", "defined"), "imports": ("name", "library", "slots"), "exports": ("name", "address"), "relocations": ("section", "offset", "type", "symbol"), "fixups": ("offset", "address", "kind", "raw"), "strings": ("offset", "text", "encoding", "section"), "byte_map": ("offset", "length", "region", "entropy"), } def _covering(items, start, end): best, best_overlap = None, 0 for item in items: if not item["file_size"]: continue overlap = min(end, item["offset"] + item["file_size"]) - max(start, item["offset"]) if overlap > best_overlap or (overlap == best_overlap and overlap > 0 and item["file_size"] < best["file_size"]): best, best_overlap = item, overlap return best def byte_map(data, section_list, segment_list=(), cells=128): if cells < 1: raise ValueError("cells must be at least 1") cells = min(cells, len(data)) or 1 first = min((s["offset"] for s in section_list if s["file_size"]), default=len(data)) result = [] for index in range(cells): start = index * len(data) // cells end = (index + 1) * len(data) // cells section = _covering(section_list, start, end) if section: region = section["name"] elif end <= first: region = "headers" else: segment = _covering(segment_list, start, end) region = segment["name"] if segment else "unmapped" result.append({"index": index, "offset": start, "length": end - start, "region": region, "entropy": shannon_entropy(data[start:end])}) return result def build_report(data, name="input", truth=None, min_string=4, cells=128): header = parse_header(data) section_list = sections(data) segment_list = segments(data) symbol_list = symbols(data) starts = function_starts(data) if truth is not None: expected = function_starts(truth)["symbols"] starts["truth"] = expected starts["scores"] = {method: score_starts(found, expected) for method, found in starts.items() if method not in ("truth", "scores")} fixups = chained_fixups(data) report = { "schema": REPORT_SCHEMA, "file": {"name": name, "size": len(data), "sha256": hashlib.sha256(data).hexdigest()}, "header": header, "sections": section_list, "segments": segment_list, "symbols": symbol_list, "imports": imports(data), "exports": exports(data), "relocations": relocations(data), "fixups": fixups["fixups"], "strings": extract_strings(data, section_list, segment_list, min_string), "function_starts": starts, "byte_map": byte_map(data, section_list, segment_list, cells), } report["summary"] = { "format": header["format"], "arch": header["arch"], "kind": header["kind"], "sections": len(section_list), "segments": len(segment_list), "symbols": len(symbol_list), "defined_functions": len(starts["symbols"]), "imports": len(report["imports"]), "exports": len(report["exports"]), "relocations": len(report["relocations"]), "fixups": len(report["fixups"]), "strings": len(report["strings"]), "recovered_starts": len(starts["recovered"]), "file_entropy": shannon_entropy(data), } return report def validate_report(report): errors = [] if not isinstance(report, dict): return ["report must be an object"] for key, kind in REPORT_FIELDS.items(): if key not in report: errors.append(f"missing key {key}") elif not isinstance(report[key], kind): errors.append(f"{key} must be {kind.__name__}") if report.get("schema") not in (None, REPORT_SCHEMA) and isinstance(report.get("schema"), str): errors.append(f"schema must be {REPORT_SCHEMA}") for key, fields in ITEM_FIELDS.items(): for position, item in enumerate(report.get(key) or []): if not isinstance(item, dict): errors.append(f"{key}[{position}] must be an object") continue for field in fields: if field not in item: errors.append(f"{key}[{position}] missing {field}") for field in ("name", "size", "sha256"): if isinstance(report.get("file"), dict) and field not in report["file"]: errors.append(f"file missing {field}") for field in ("format", "arch", "kind"): if isinstance(report.get("header"), dict) and field not in report["header"]: errors.append(f"header missing {field}") return errors def region_color(region): if region == "headers": return "hsl(220 10% 55%)" if region == "unmapped": return "hsl(0 0% 40%)" hue = zlib.crc32(region.encode()) % 360 return f"hsl({hue} 65% 50%)" def _hex(value): return "" if value is None else f"0x{value:x}" def _table(columns, rows): head = "".join(f"{html.escape(c)}" for c in columns) body = "".join("" + "".join(f"{html.escape(str(cell))}" for cell in row) + "" for row in rows) if not rows: body = f'none' return f"{head}{body}
" STYLE = """ :root{--bg:#f7f6f2;--fg:#1d1f24;--muted:#5b6170;--line:#d9d6cc;--card:#ffffff} @media (prefers-color-scheme: dark){:root{--bg:#14161b;--fg:#e8e9ec;--muted:#a2a8b5;--line:#2c313b;--card:#1b1e25}} body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif} main{max-width:1100px;margin:0 auto;padding:24px 16px} h1{font-size:1.5rem;margin:0 0 4px}h2{font-size:1.1rem;margin:28px 0 8px} .muted{color:var(--muted)}code,td{font-family:ui-monospace,Menlo,monospace;font-size:13px} .facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px} .fact{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:8px} .fact b{display:block;font-size:1.1rem} .map{display:grid;grid-template-columns:repeat(32,1fr);gap:2px;background:var(--card);padding:8px;border:1px solid var(--line);border-radius:6px} .cell{aspect-ratio:1;border-radius:2px} .legend{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:8px;font-size:13px} .swatch{display:inline-block;width:12px;height:12px;border-radius:2px;margin-right:4px;vertical-align:-1px} .scroll{overflow-x:auto;border:1px solid var(--line);border-radius:6px;background:var(--card)} table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:4px 8px;border-bottom:1px solid var(--line);white-space:nowrap} th{position:sticky;top:0;background:var(--card)} """ def render_html(report): summary = report["summary"] cells = [] regions = [] for cell in report["byte_map"]: if cell["region"] not in regions: regions.append(cell["region"]) opacity = 0.25 + 0.75 * cell["entropy"] / 8 title = f"{cell['region']} @ 0x{cell['offset']:x} +{cell['length']} entropy {cell['entropy']}" cells.append(f'
') legend = "".join(f'{html.escape(r)}' for r in regions) facts = "".join(f'
{html.escape(k)}{html.escape(str(v))}
' for k, v in summary.items()) starts = report["function_starts"] score_rows = [[method, s["found"], s["true_positives"], len(s["false_positives"]), len(s["missed"]), s["precision"], s["recall"]] for method, s in starts.get("scores", {}).items()] method_rows = [[method, len(values), " ".join(_hex(v) for v in values[:12])] for method, values in starts.items() if method not in ("scores",)] parts = [ "", "", f"{html.escape(report['file']['name'])} inspection
", f"

{html.escape(report['file']['name'])}

", f"

{report['file']['size']} bytes, sha256 {report['file']['sha256']}, schema {html.escape(report['schema'])}

", f"
{facts}
", "

Byte map

One cell per file slice. Color names the region; brightness rises with Shannon entropy.

", f"
{''.join(cells)}
{legend}
", "

Sections

" + _table( ["#", "segment", "name", "type", "addr", "offset", "size", "perms", "entropy"], [[s["index"], s["segment"] or "", s["name"], s["type"], _hex(s["addr"]), _hex(s["offset"]), s["size"], s["perms"], s["entropy"]] for s in report["sections"]]) + "
", "

Segments

" + _table( ["name", "addr", "size", "offset", "file size", "perms", "entropy"], [[s["name"], _hex(s["addr"]), s["size"], _hex(s["offset"]), s["file_size"], s["perms"], s["entropy"]] for s in report["segments"]]) + "
", "

Function starts

" + _table(["method", "count", "addresses"], method_rows) + "
", ] if score_rows: parts.append("

Scores against the unstripped build

" + _table( ["method", "found", "true", "false", "missed", "precision", "recall"], score_rows) + "
") parts += [ "

Symbols

" + _table( ["name", "demangled", "value", "kind", "binding", "section", "table"], [[s["name"], s["demangled"] or "", _hex(s["value"]), s["kind"], s["binding"], s["section"] or "UND", s["table"]] for s in report["symbols"]]) + "
", "

Imports

" + _table( ["name", "library", "slots"], [[i["name"], i["library"] or "", " ".join(_hex(v) for v in i["slots"])] for i in report["imports"]]) + "
", "

Exports

" + _table( ["name", "address"], [[e["name"], _hex(e["address"])] for e in report["exports"]]) + "
", "

Relocations

" + _table( ["section", "offset", "type", "symbol", "addend"], [[r["section"] or "", _hex(r["offset"]), r["type"], r["symbol"] or "", "" if r["addend"] is None else r["addend"]] for r in report["relocations"]]) + "
", "

Chained fixups

" + _table( ["address", "section", "kind", "target", "raw"], [[_hex(f["address"]), f["section"] or "", f["kind"], f"{f['library']}/{f['name']}" if f["kind"] == "bind" else _hex(f["target"]), f"0x{f['raw']:016x}"] for f in report["fixups"]]) + "
", "

Strings

" + _table( ["offset", "section", "encoding", "text"], [[_hex(s["offset"]), s["section"] or s["segment"] or "", s["encoding"], s["text"]] for s in report["strings"]]) + "
", "
\n", ] return "".join(parts) def _section_totals(report): totals = {} for section in report["sections"]: label = f"{section['segment']},{section['name']}" if section.get("segment") else section["name"] entry = totals.setdefault(label, {"size": 0, "count": 0, "entropy": section["entropy"]}) entry["size"] += section["size"] entry["count"] += 1 return totals def _names(items): return {item["name"] for item in items if item["name"]} def _defined_values(report): return {symbol["name"]: symbol["value"] for symbol in report["symbols"] if symbol["defined"] and symbol["name"] and symbol["kind"] not in ("section", "file")} def _set_diff(old, new): return {"added": sorted(new - old), "removed": sorted(old - new)} def diff_reports(old, new): for label, report in (("old", old), ("new", new)): problems = validate_report(report) if problems: raise ValueError(f"{label} report is invalid: {problems[0]}") before, after = _section_totals(old), _section_totals(new) changed = [] for label in sorted(set(before) & set(after)): if before[label]["size"] != after[label]["size"]: changed.append({"name": label, "old_size": before[label]["size"], "new_size": after[label]["size"], "delta": after[label]["size"] - before[label]["size"], "old_entropy": before[label]["entropy"], "new_entropy": after[label]["entropy"]}) old_values, new_values = _defined_values(old), _defined_values(new) moved = [{"name": name, "old": old_values[name], "new": new_values[name]} for name in sorted(set(old_values) & set(new_values)) if old_values[name] != new_values[name]] return { "schema": DIFF_SCHEMA, "old": {"name": old["file"]["name"], "sha256": old["file"]["sha256"], "size": old["file"]["size"]}, "new": {"name": new["file"]["name"], "sha256": new["file"]["sha256"], "size": new["file"]["size"]}, "identical": old["file"]["sha256"] == new["file"]["sha256"], "header_changes": {key: [old["header"].get(key), new["header"].get(key)] for key in ("format", "arch", "kind", "entry") if old["header"].get(key) != new["header"].get(key)}, "sections": {**_set_diff(set(before), set(after)), "changed": changed}, "symbols": {**_set_diff(set(old_values), set(new_values)), "moved": moved}, "imports": _set_diff(_names(old["imports"]), _names(new["imports"])), "exports": _set_diff(_names(old["exports"]), _names(new["exports"])), "strings": _set_diff({s["text"] for s in old["strings"]}, {s["text"] for s in new["strings"]}), "function_starts": {"old": len(old["function_starts"]["recovered"]), "new": len(new["function_starts"]["recovered"])}, } def render_diff_text(diff): lines = [f"{diff['old']['name']} -> {diff['new']['name']}"] if diff["identical"]: lines.append("identical bytes") for key, (before, after) in diff["header_changes"].items(): lines.append(f"header {key}: {before} -> {after}") for item in diff["sections"]["changed"]: lines.append(f"section {item['name']}: {item['old_size']} -> {item['new_size']} ({item['delta']:+d})") for item in diff["symbols"]["moved"]: lines.append(f"symbol {item['name']}: 0x{item['old']:x} -> 0x{item['new']:x}") for group in ("sections", "symbols", "imports", "exports", "strings"): for name in diff[group]["added"]: lines.append(f"+ {group[:-1]} {name}") for name in diff[group]["removed"]: lines.append(f"- {group[:-1]} {name}") starts = diff["function_starts"] lines.append(f"recovered function starts: {starts['old']} -> {starts['new']}") return "\n".join(lines) + "\n"