mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
1069 lines
36 KiB
Python
1069 lines
36 KiB
Python
"""Report generation — Markdown, JSON, HTML, PDF.
|
|
|
|
Produces self-contained reports from project analysis data. Markdown and JSON
|
|
are authoritative formats per ADR-008. HTML and PDF are optional renderings
|
|
derived from the canonical formats.
|
|
|
|
Every report includes:
|
|
- Methodology section: profile, rules_version, backend, adapter, parameters
|
|
- Full provenance: cli_version, project_id, binary_id, binary_sha256,
|
|
analysis_id (UUID), generated_at
|
|
|
|
Report types:
|
|
- triage: Structured triage analysis output (observations, heuristics, unknowns)
|
|
- focused: Analysis focused on a specific entity (requires selector)
|
|
- project: Full project analysis state summary
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from uuid import UUID, uuid4
|
|
|
|
from binary_analysis import __version__ as _cli_version
|
|
from binary_analysis.domain.enums import ReportType
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Methodology builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def build_methodology(
|
|
profile: str = "standard",
|
|
rules_version: str = "1.0.0",
|
|
backend: str = "none",
|
|
adapter: str = "none",
|
|
parameters: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build the methodology section for a report.
|
|
|
|
All fields must be non-null per the validation contract.
|
|
|
|
Args:
|
|
profile: Analysis profile used (e.g., "standard", "quick", "deep").
|
|
rules_version: Version of the rules engine used.
|
|
backend: Backend name (e.g., "Ghidra", "none").
|
|
adapter: Adapter name (e.g., "ghidra", "fake").
|
|
parameters: Any parameter overrides from defaults.
|
|
|
|
Returns:
|
|
Methodology dict with profile, rules_version, backend, adapter, parameters.
|
|
"""
|
|
return {
|
|
"profile": profile,
|
|
"rules_version": rules_version,
|
|
"backend": backend,
|
|
"adapter": adapter,
|
|
"parameters": parameters if parameters is not None else {},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provenance builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def build_provenance(
|
|
*,
|
|
cli_version: str | None = None,
|
|
project_id: str | None = None,
|
|
binary_id: str | None = None,
|
|
binary_sha256: str | None = None,
|
|
analysis_id: UUID | None = None,
|
|
generated_at: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build the provenance section for a report.
|
|
|
|
Every report must include cli_version, project_id, binary_id,
|
|
binary_sha256, analysis_id (UUID), and generated_at (ISO 8601).
|
|
|
|
Two sequential reports on the same binary have different analysis_id values.
|
|
|
|
Args:
|
|
cli_version: CLI version string.
|
|
project_id: Project UUID.
|
|
binary_id: Binary UUID.
|
|
binary_sha256: Binary SHA-256 hash (64 hex chars).
|
|
analysis_id: Unique UUID for this analysis run (auto-generated if None).
|
|
generated_at: ISO 8601 timestamp (auto-generated if None).
|
|
|
|
Returns:
|
|
Provenance dict with all required fields.
|
|
"""
|
|
if analysis_id is None:
|
|
analysis_id = uuid4()
|
|
if generated_at is None:
|
|
generated_at = datetime.now(timezone.utc).isoformat()
|
|
|
|
return {
|
|
"cli_version": cli_version or _cli_version,
|
|
"project_id": project_id,
|
|
"binary_id": binary_id,
|
|
"binary_sha256": binary_sha256,
|
|
"analysis_id": str(analysis_id),
|
|
"generated_at": generated_at,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Report data collection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def collect_triage_data(
|
|
manifest: dict[str, Any],
|
|
adapter: Any,
|
|
binary: Any,
|
|
profile_name: str,
|
|
) -> dict[str, Any]:
|
|
"""Collect triage data from the analysis for a triage report.
|
|
|
|
Args:
|
|
manifest: Project manifest dict.
|
|
adapter: Backend adapter instance.
|
|
binary: Binary domain entity.
|
|
profile_name: Analysis profile name.
|
|
|
|
Returns:
|
|
Dict with observations, heuristics, unknowns from triage analysis.
|
|
"""
|
|
try:
|
|
triage_result = adapter.run_triage(binary)
|
|
except Exception:
|
|
return {
|
|
"observations": [],
|
|
"heuristics": [],
|
|
"unknowns": [],
|
|
"partial": True,
|
|
"error": "Triage analysis could not be completed.",
|
|
}
|
|
|
|
observations_data: list[dict[str, Any]] = []
|
|
for obs in triage_result.observations:
|
|
obs_dict: dict[str, Any] = {
|
|
"category": obs.category,
|
|
"description": obs.description,
|
|
"source": obs.source,
|
|
}
|
|
if obs.address is not None:
|
|
obs_dict["address"] = obs.address.to_dict()
|
|
if obs.evidence is not None:
|
|
obs_dict["evidence"] = obs.evidence
|
|
observations_data.append(obs_dict)
|
|
|
|
heuristics_data: list[dict[str, Any]] = []
|
|
for heur in triage_result.heuristics:
|
|
heur_dict: dict[str, Any] = {
|
|
"name": heur.name,
|
|
"description": heur.description,
|
|
"confidence": heur.confidence.value,
|
|
}
|
|
if heur.rule_id is not None:
|
|
heur_dict["rule_id"] = heur.rule_id
|
|
if heur.evidence:
|
|
heur_dict["evidence"] = heur.evidence
|
|
heuristics_data.append(heur_dict)
|
|
|
|
unknowns_data: list[dict[str, Any]] = []
|
|
for unk in triage_result.unknowns:
|
|
unk_dict: dict[str, Any] = {"question": unk.question}
|
|
if unk.address is not None:
|
|
unk_dict["address"] = unk.address.to_dict()
|
|
if unk.category is not None:
|
|
unk_dict["category"] = unk.category
|
|
unknowns_data.append(unk_dict)
|
|
|
|
return {
|
|
"observations": observations_data,
|
|
"heuristics": heuristics_data,
|
|
"unknowns": unknowns_data,
|
|
"partial": triage_result.partial,
|
|
}
|
|
|
|
|
|
def collect_focused_data(
|
|
adapter: Any,
|
|
binary: Any,
|
|
selector: str,
|
|
) -> dict[str, Any]:
|
|
"""Collect focused analysis data for a specific entity.
|
|
|
|
Args:
|
|
adapter: Backend adapter instance.
|
|
binary: Binary domain entity.
|
|
selector: Entity selector string (e.g., "function:main").
|
|
|
|
Returns:
|
|
Dict with focused entity data (decompilation, xrefs, etc.).
|
|
"""
|
|
|
|
selector_lower = selector.lower()
|
|
data: dict[str, Any] = {"selector": selector, "entity_type": "unknown"}
|
|
|
|
if selector_lower.startswith("function:"):
|
|
func_name = selector.split(":", 1)[1]
|
|
try:
|
|
functions = adapter.get_functions(binary)
|
|
target = None
|
|
for f in functions:
|
|
if f.name == func_name or (
|
|
f.address is not None and f.address.display == func_name
|
|
):
|
|
target = f
|
|
break
|
|
|
|
if target is None:
|
|
data["error"] = f"Function not found: {func_name}"
|
|
return data
|
|
|
|
data["entity_type"] = "function"
|
|
data["entity"] = {
|
|
"name": target.name,
|
|
"address": target.address.to_dict() if target.address else None,
|
|
"size_bytes": target.size_bytes,
|
|
"confidence": target.confidence.value,
|
|
"name_source": target.name_source.value,
|
|
}
|
|
|
|
# Decompile
|
|
try:
|
|
decomp = adapter.decompile(binary, target)
|
|
data["pseudocode"] = decomp.pseudocode if decomp else ""
|
|
if decomp and decomp.address_map:
|
|
data["address_map"] = [
|
|
{
|
|
"line": am.line,
|
|
"address": am.address.to_dict() if am.address else None,
|
|
}
|
|
for am in decomp.address_map
|
|
]
|
|
except Exception:
|
|
data["pseudocode"] = "(decompilation not available)"
|
|
|
|
# Xrefs
|
|
try:
|
|
xrefs = adapter.get_xrefs(binary, target)
|
|
data["xrefs"] = [
|
|
{
|
|
"from": x.from_addr.to_dict() if x.from_addr else None,
|
|
"to": x.to_addr.to_dict() if x.to_addr else None,
|
|
"kind": x.kind.value,
|
|
"confidence": x.confidence.value,
|
|
}
|
|
for x in xrefs
|
|
]
|
|
except Exception:
|
|
data["xrefs"] = []
|
|
|
|
# Callers / Callees
|
|
try:
|
|
callers = adapter.get_callers(binary, target)
|
|
data["callers"] = [
|
|
{
|
|
"name": c.caller_name if hasattr(c, "caller_name") else "unknown",
|
|
"address": c.caller_address.to_dict()
|
|
if hasattr(c, "caller_address") and c.caller_address
|
|
else None,
|
|
}
|
|
for c in callers
|
|
]
|
|
except Exception:
|
|
data["callers"] = []
|
|
|
|
try:
|
|
callees = adapter.get_callees(binary, target)
|
|
data["callees"] = [
|
|
{
|
|
"name": c.callee_name if hasattr(c, "callee_name") else "unknown",
|
|
"address": c.callee_address.to_dict()
|
|
if hasattr(c, "callee_address") and c.callee_address
|
|
else None,
|
|
}
|
|
for c in callees
|
|
]
|
|
except Exception:
|
|
data["callees"] = []
|
|
|
|
except Exception as e:
|
|
data["error"] = str(e)
|
|
|
|
return data
|
|
|
|
|
|
def collect_project_data(
|
|
manifest: dict[str, Any],
|
|
adapter: Any,
|
|
binary: Any,
|
|
) -> dict[str, Any]:
|
|
"""Collect full project analysis state summary.
|
|
|
|
Args:
|
|
manifest: Project manifest dict.
|
|
adapter: Backend adapter instance.
|
|
binary: Binary domain entity.
|
|
|
|
Returns:
|
|
Dict with project metadata, sections, functions, etc.
|
|
"""
|
|
data: dict[str, Any] = {
|
|
"project": {
|
|
"name": manifest.get("name", "unknown"),
|
|
"state": manifest.get("state", "unknown"),
|
|
"created_at": manifest.get("created_at"),
|
|
"binary_count": manifest.get("binary_count", 0),
|
|
"is_stale": manifest.get("is_stale", False),
|
|
},
|
|
"binary": {
|
|
"id": str(binary.id) if binary else None,
|
|
"sha256": binary.sha256 if binary else None,
|
|
"format": binary.format if binary else None,
|
|
"architecture": binary.architecture if binary else None,
|
|
"size_bytes": binary.size_bytes if binary else 0,
|
|
},
|
|
"sections": [],
|
|
"functions": [],
|
|
"imports": [],
|
|
"exports": [],
|
|
}
|
|
|
|
if binary is None:
|
|
return data
|
|
|
|
# Sections
|
|
try:
|
|
sections = adapter.get_sections(binary)
|
|
data["sections"] = [
|
|
{
|
|
"name": s.name,
|
|
"address": s.address.to_dict() if s.address else None,
|
|
"virtual_size": s.virtual_size,
|
|
"raw_size": s.raw_size,
|
|
"flags": s.flags,
|
|
"entropy": s.entropy,
|
|
}
|
|
for s in sections
|
|
]
|
|
except Exception:
|
|
pass
|
|
|
|
# Functions
|
|
try:
|
|
functions = adapter.get_functions(binary)
|
|
data["functions"] = [
|
|
{
|
|
"name": f.name,
|
|
"address": f.address.to_dict() if f.address else None,
|
|
"size_bytes": f.size_bytes,
|
|
"confidence": f.confidence.value,
|
|
}
|
|
for f in functions[:100]
|
|
]
|
|
data["function_count"] = len(functions)
|
|
except Exception:
|
|
data["function_count"] = 0
|
|
|
|
# Imports
|
|
try:
|
|
imports = adapter.get_imports(binary)
|
|
data["imports"] = [
|
|
{
|
|
"module": imp.module,
|
|
"symbol": imp.symbol,
|
|
"resolution": imp.resolution.value,
|
|
}
|
|
for imp in imports[:100]
|
|
]
|
|
data["import_count"] = len(imports)
|
|
except Exception:
|
|
data["import_count"] = 0
|
|
|
|
# Exports
|
|
try:
|
|
exports = adapter.get_exports(binary)
|
|
data["exports"] = [
|
|
{
|
|
"name": exp.name,
|
|
"address": exp.address.to_dict() if exp.address else None,
|
|
"kind": exp.kind,
|
|
}
|
|
for exp in exports[:100]
|
|
]
|
|
data["export_count"] = len(exports)
|
|
except Exception:
|
|
data["export_count"] = 0
|
|
|
|
return data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JSON report generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def generate_json_report(
|
|
report_type: ReportType,
|
|
report_data: dict[str, Any],
|
|
methodology: dict[str, Any],
|
|
provenance: dict[str, Any],
|
|
) -> str:
|
|
"""Generate a JSON report as a string.
|
|
|
|
The JSON output uses the canonical domain model schemas and is
|
|
considered an authoritative format alongside Markdown per ADR-008.
|
|
|
|
Args:
|
|
report_type: Type of report (triage, focused, project).
|
|
report_data: The collected report data.
|
|
methodology: Methodology section dict.
|
|
provenance: Provenance section dict.
|
|
|
|
Returns:
|
|
JSON string with the complete report envelope.
|
|
"""
|
|
report = {
|
|
"schema_version": "1.0.0",
|
|
"report_type": report_type.value,
|
|
"methodology": methodology,
|
|
"provenance": provenance,
|
|
"data": report_data,
|
|
}
|
|
return json.dumps(report, indent=2, ensure_ascii=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Markdown report generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# At module level for reusability in HTML conversion
|
|
|
|
_MD_HEADER_TPL = """# Binary Analysis Report
|
|
|
|
**Type:** {report_type}
|
|
**Generated:** {generated_at}
|
|
|
|
---
|
|
|
|
## Methodology
|
|
|
|
| Field | Value |
|
|
|-------|-------|
|
|
| Profile | {profile} |
|
|
| Rules Version | {rules_version} |
|
|
| Backend | {backend} |
|
|
| Adapter | {adapter} |
|
|
|
|
### Parameters
|
|
|
|
{parameters}
|
|
|
|
---
|
|
|
|
## Provenance
|
|
|
|
| Field | Value |
|
|
|-------|-------|
|
|
| CLI Version | {cli_version} |
|
|
| Project ID | {project_id} |
|
|
| Binary ID | {binary_id} |
|
|
| Binary SHA-256 | {binary_sha256} |
|
|
| Analysis ID | {analysis_id} |
|
|
| Generated At | {generated_at} |
|
|
|
|
---
|
|
"""
|
|
|
|
|
|
def _format_parameters(parameters: dict[str, Any]) -> str:
|
|
"""Format parameters dict as markdown table rows."""
|
|
if not parameters:
|
|
return "No parameter overrides."
|
|
lines = ["| Parameter | Value |", "|-----------|-------|"]
|
|
for k, v in parameters.items():
|
|
lines.append(f"| {k} | {v} |")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_address(addr: dict[str, Any] | None) -> str:
|
|
"""Format an address dict as a markdown code span."""
|
|
if addr is None:
|
|
return "`(null)`"
|
|
return f"`{addr.get('display', addr.get('offset', 'unknown'))}`"
|
|
|
|
|
|
def generate_markdown_report(
|
|
report_type: ReportType,
|
|
report_data: dict[str, Any],
|
|
methodology: dict[str, Any],
|
|
provenance: dict[str, Any],
|
|
) -> str:
|
|
"""Generate a self-contained Markdown report.
|
|
|
|
The Markdown output is self-contained with structured sections,
|
|
headings, tables, and code blocks. It is an authoritative format
|
|
alongside JSON per ADR-008.
|
|
|
|
Args:
|
|
report_type: Type of report (triage, focused, project).
|
|
report_data: The collected report data.
|
|
methodology: Methodology section dict.
|
|
provenance: Provenance section dict.
|
|
|
|
Returns:
|
|
Markdown string with the complete report.
|
|
"""
|
|
lines: list[str] = []
|
|
|
|
# Header
|
|
lines.append(
|
|
_MD_HEADER_TPL.format(
|
|
report_type=report_type.value,
|
|
generated_at=provenance.get("generated_at", ""),
|
|
profile=methodology.get("profile", "unknown"),
|
|
rules_version=methodology.get("rules_version", "unknown"),
|
|
backend=methodology.get("backend", "unknown"),
|
|
adapter=methodology.get("adapter", "unknown"),
|
|
parameters=_format_parameters(methodology.get("parameters", {})),
|
|
cli_version=provenance.get("cli_version", "unknown"),
|
|
project_id=provenance.get("project_id", "N/A"),
|
|
binary_id=provenance.get("binary_id", "N/A"),
|
|
binary_sha256=provenance.get("binary_sha256", "N/A"),
|
|
analysis_id=provenance.get("analysis_id", "N/A"),
|
|
)
|
|
)
|
|
|
|
# Report-specific content
|
|
if report_type == ReportType.TRIAGE:
|
|
_build_md_triage(lines, report_data)
|
|
elif report_type == ReportType.FOCUSED:
|
|
_build_md_focused(lines, report_data)
|
|
elif report_type == ReportType.PROJECT:
|
|
_build_md_project(lines, report_data)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _build_md_triage(lines: list[str], data: dict[str, Any]) -> None:
|
|
"""Build Markdown sections for a triage report."""
|
|
lines.append("## Triage Analysis\n")
|
|
|
|
# Observations
|
|
observations = data.get("observations", [])
|
|
lines.append(f"### Observations ({len(observations)})\n")
|
|
if observations:
|
|
lines.append("| Category | Description | Source | Address |")
|
|
lines.append("|----------|-------------|--------|---------|")
|
|
for obs in observations:
|
|
addr = _format_address(obs.get("address"))
|
|
lines.append(
|
|
f"| {obs.get('category', '')} | {obs.get('description', '')} "
|
|
f"| {obs.get('source', '')} | {addr} |"
|
|
)
|
|
else:
|
|
lines.append("_No observations recorded._")
|
|
lines.append("")
|
|
|
|
# Heuristics
|
|
heuristics = data.get("heuristics", [])
|
|
lines.append(f"### Heuristics ({len(heuristics)})\n")
|
|
if heuristics:
|
|
lines.append("| Name | Description | Confidence | Rule ID |")
|
|
lines.append("|------|-------------|------------|---------|")
|
|
for heur in heuristics:
|
|
lines.append(
|
|
f"| {heur.get('name', '')} | {heur.get('description', '')} "
|
|
f"| {heur.get('confidence', '')} | {heur.get('rule_id', '')} |"
|
|
)
|
|
else:
|
|
lines.append("_No heuristics generated._")
|
|
lines.append("")
|
|
|
|
# Unknowns
|
|
unknowns = data.get("unknowns", [])
|
|
lines.append(f"### Unknowns ({len(unknowns)})\n")
|
|
if unknowns:
|
|
for unk in unknowns:
|
|
addr = _format_address(unk.get("address"))
|
|
lines.append(f"- **Q:** {unk.get('question', '')} ")
|
|
lines.append(f" Address: {addr} ")
|
|
if unk.get("category"):
|
|
lines.append(f" Category: {unk['category']} ")
|
|
else:
|
|
lines.append("_No unresolved questions._")
|
|
lines.append("")
|
|
|
|
if data.get("partial"):
|
|
lines.append("> **Note:** This report contains partial results.\n")
|
|
|
|
|
|
def _build_md_focused(lines: list[str], data: dict[str, Any]) -> None:
|
|
"""Build Markdown sections for a focused report."""
|
|
lines.append("## Focused Analysis\n")
|
|
lines.append(f"**Selector:** `{data.get('selector', 'N/A')}`\n")
|
|
|
|
entity = data.get("entity", {})
|
|
entity_type = data.get("entity_type", "unknown")
|
|
|
|
if entity:
|
|
lines.append(f"### {entity_type.title()}: {entity.get('name', 'unknown')}\n")
|
|
lines.append("| Property | Value |")
|
|
lines.append("|----------|-------|")
|
|
for k, v in entity.items():
|
|
if k == "address" and isinstance(v, dict):
|
|
lines.append(f"| {k} | {_format_address(v)} |")
|
|
else:
|
|
lines.append(f"| {k} | {v} |")
|
|
lines.append("")
|
|
|
|
# Pseudocode
|
|
pseudocode = data.get("pseudocode")
|
|
if pseudocode:
|
|
lines.append("### Pseudocode\n")
|
|
lines.append("```c")
|
|
lines.append(pseudocode)
|
|
lines.append("```\n")
|
|
|
|
# Xrefs
|
|
xrefs = data.get("xrefs", [])
|
|
if xrefs:
|
|
lines.append(f"### Cross-References ({len(xrefs)})\n")
|
|
lines.append("| From | To | Kind | Confidence |")
|
|
lines.append("|------|----|------|------------|")
|
|
for x in xrefs:
|
|
lines.append(
|
|
f"| {_format_address(x.get('from'))} | {_format_address(x.get('to'))} "
|
|
f"| {x.get('kind', '')} | {x.get('confidence', '')} |"
|
|
)
|
|
lines.append("")
|
|
|
|
# Callers
|
|
callers = data.get("callers", [])
|
|
if callers:
|
|
lines.append(f"### Callers ({len(callers)})\n")
|
|
lines.append("| Name | Address |")
|
|
lines.append("|------|---------|")
|
|
for c in callers:
|
|
lines.append(f"| {c.get('name', '?')} | {_format_address(c.get('address'))} |")
|
|
lines.append("")
|
|
|
|
# Callees
|
|
callees = data.get("callees", [])
|
|
if callees:
|
|
lines.append(f"### Callees ({len(callees)})\n")
|
|
lines.append("| Name | Address |")
|
|
lines.append("|------|---------|")
|
|
for c in callees:
|
|
lines.append(f"| {c.get('name', '?')} | {_format_address(c.get('address'))} |")
|
|
lines.append("")
|
|
|
|
if data.get("error"):
|
|
lines.append(f"> **Error:** {data['error']}\n")
|
|
|
|
|
|
def _build_md_project(lines: list[str], data: dict[str, Any]) -> None:
|
|
"""Build Markdown sections for a project report."""
|
|
lines.append("## Project Summary\n")
|
|
|
|
# Project metadata
|
|
proj = data.get("project", {})
|
|
bin_info = data.get("binary", {})
|
|
|
|
lines.append("### Project\n")
|
|
lines.append("| Property | Value |")
|
|
lines.append("|----------|-------|")
|
|
lines.append(f"| Name | {proj.get('name', 'unknown')} |")
|
|
lines.append(f"| State | {proj.get('state', 'unknown')} |")
|
|
lines.append(f"| Created | {proj.get('created_at', 'N/A')} |")
|
|
lines.append(f"| Binary Count | {proj.get('binary_count', 0)} |")
|
|
lines.append(f"| Stale | {proj.get('is_stale', False)} |")
|
|
lines.append("")
|
|
|
|
# Binary info
|
|
lines.append("### Binary\n")
|
|
lines.append("| Property | Value |")
|
|
lines.append("|----------|-------|")
|
|
lines.append(f"| ID | `{bin_info.get('id', 'N/A')}` |")
|
|
lines.append(f"| SHA-256 | `{bin_info.get('sha256', 'N/A')}` |")
|
|
lines.append(f"| Format | {bin_info.get('format', 'N/A')} |")
|
|
lines.append(f"| Architecture | {bin_info.get('architecture', 'N/A')} |")
|
|
lines.append(f"| Size | {bin_info.get('size_bytes', 0)} bytes |")
|
|
lines.append("")
|
|
|
|
# Sections
|
|
sections = data.get("sections", [])
|
|
lines.append(f"### Sections ({len(sections)})\n")
|
|
if sections:
|
|
lines.append("| Name | Address | Virtual Size | Raw Size | Flags | Entropy |")
|
|
lines.append("|------|---------|-------------|----------|-------|---------|")
|
|
for s in sections:
|
|
lines.append(
|
|
f"| {s.get('name', '')} | {_format_address(s.get('address'))} "
|
|
f"| {s.get('virtual_size', 0)} | {s.get('raw_size', 0)} "
|
|
f"| {', '.join(s.get('flags', []))} | {s.get('entropy', 'N/A')} |"
|
|
)
|
|
else:
|
|
lines.append("_No sections available._")
|
|
lines.append("")
|
|
|
|
# Functions
|
|
func_count = data.get("function_count", 0)
|
|
functions = data.get("functions", [])
|
|
lines.append(f"### Functions ({func_count})\n")
|
|
if functions:
|
|
lines.append("| Name | Address | Size | Confidence |")
|
|
lines.append("|------|---------|------|------------|")
|
|
for f in functions[:50]:
|
|
lines.append(
|
|
f"| {f.get('name', '')} | {_format_address(f.get('address'))} "
|
|
f"| {f.get('size_bytes', 0)} | {f.get('confidence', '')} |"
|
|
)
|
|
if func_count > 50:
|
|
lines.append(f"\n_Showing 50 of {func_count} functions._")
|
|
else:
|
|
lines.append("_No functions available._")
|
|
lines.append("")
|
|
|
|
# Imports
|
|
imp_count = data.get("import_count", 0)
|
|
imports = data.get("imports", [])
|
|
lines.append(f"### Imports ({imp_count})\n")
|
|
if imports:
|
|
lines.append("| Module | Symbol | Resolution |")
|
|
lines.append("|--------|--------|------------|")
|
|
for imp in imports[:50]:
|
|
lines.append(
|
|
f"| {imp.get('module', '')} | {imp.get('symbol', '')} "
|
|
f"| {imp.get('resolution', '')} |"
|
|
)
|
|
if imp_count > 50:
|
|
lines.append(f"\n_Showing 50 of {imp_count} imports._")
|
|
else:
|
|
lines.append("_No imports available._")
|
|
lines.append("")
|
|
|
|
# Exports
|
|
exp_count = data.get("export_count", 0)
|
|
exports = data.get("exports", [])
|
|
lines.append(f"### Exports ({exp_count})\n")
|
|
if exports:
|
|
lines.append("| Name | Address | Kind |")
|
|
lines.append("|------|---------|------|")
|
|
for exp in exports[:50]:
|
|
lines.append(
|
|
f"| {exp.get('name', '')} | {_format_address(exp.get('address'))} "
|
|
f"| {exp.get('kind', '')} |"
|
|
)
|
|
if exp_count > 50:
|
|
lines.append(f"\n_Showing 50 of {exp_count} exports._")
|
|
else:
|
|
lines.append("_No exports available._")
|
|
lines.append("")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTML report generation (optional rendering)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def generate_html_report(
|
|
report_type: ReportType,
|
|
report_data: dict[str, Any],
|
|
methodology: dict[str, Any],
|
|
provenance: dict[str, Any],
|
|
) -> str:
|
|
"""Generate an HTML report derived from the canonical Markdown output.
|
|
|
|
HTML is an optional rendering format (not authoritative). If a dependency
|
|
is missing for rendering, the caller should fall back to the canonical
|
|
Markdown path with a warning.
|
|
|
|
Args:
|
|
report_type: Type of report.
|
|
report_data: The collected report data.
|
|
methodology: Methodology section dict.
|
|
provenance: Provenance section dict.
|
|
|
|
Returns:
|
|
HTML string with the rendered report.
|
|
"""
|
|
md = generate_markdown_report(report_type, report_data, methodology, provenance)
|
|
|
|
# Simple built-in Markdown-to-HTML conversion (no external dependency)
|
|
html_lines: list[str] = []
|
|
in_code_block = False
|
|
in_table = False
|
|
|
|
html_lines.append("<!DOCTYPE html>")
|
|
html_lines.append('<html lang="en">')
|
|
html_lines.append("<head>")
|
|
html_lines.append('<meta charset="utf-8">')
|
|
html_lines.append(f"<title>Binary Analysis Report — {report_type.value.title()}</title>")
|
|
html_lines.append("<style>")
|
|
html_lines.append(
|
|
"body { font-family: system-ui, sans-serif; max-width: 900px; "
|
|
"margin: 2rem auto; padding: 0 1rem; line-height: 1.6; }"
|
|
)
|
|
html_lines.append("table { border-collapse: collapse; width: 100%; margin: 1rem 0; }")
|
|
html_lines.append("th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; }")
|
|
html_lines.append("th { background: #f5f5f5; }")
|
|
html_lines.append("code { background: #f0f0f0; padding: 0.15em 0.3em; border-radius: 3px; }")
|
|
html_lines.append(
|
|
"pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; border-radius: 4px; }"
|
|
)
|
|
html_lines.append("pre code { background: none; padding: 0; }")
|
|
html_lines.append(
|
|
"blockquote { border-left: 4px solid #ddd; margin: 1rem 0; "
|
|
"padding: 0.5rem 1rem; color: #666; }"
|
|
)
|
|
html_lines.append("h2 { border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }")
|
|
html_lines.append("hr { border: none; border-top: 1px solid #eee; margin: 2rem 0; }")
|
|
html_lines.append("</style>")
|
|
html_lines.append("</head>")
|
|
html_lines.append("<body>")
|
|
|
|
for line in md.split("\n"):
|
|
stripped = line.strip()
|
|
|
|
# Code blocks
|
|
if stripped.startswith("```"):
|
|
if in_code_block:
|
|
html_lines.append("</code></pre>")
|
|
in_code_block = False
|
|
else:
|
|
html_lines.append("<pre><code>")
|
|
in_code_block = True
|
|
continue
|
|
if in_code_block:
|
|
html_lines.append(_html_escape(line))
|
|
continue
|
|
|
|
# Tables
|
|
if stripped.startswith("|") and stripped.endswith("|"):
|
|
if not in_table:
|
|
html_lines.append("<table>")
|
|
in_table = True
|
|
cells = [c.strip() for c in stripped[1:-1].split("|")]
|
|
if all(c.startswith("-") for c in cells if c):
|
|
continue # Separator row
|
|
# Determine if header row
|
|
if html_lines[-1] == "<table>":
|
|
tag = "th"
|
|
else:
|
|
prev = html_lines[-1]
|
|
if prev.startswith("<tr>") and "</tr>" not in prev:
|
|
tag = "th"
|
|
elif prev.startswith("</thead>"):
|
|
tag = "td"
|
|
html_lines.append(
|
|
"<tr>" + "".join(f"<{tag}>{_html_escape(c)}</{tag}>" for c in cells) + "</tr>"
|
|
)
|
|
continue
|
|
else:
|
|
if in_table:
|
|
html_lines.append("</table>")
|
|
in_table = False
|
|
|
|
# Headings
|
|
if stripped.startswith("# "):
|
|
html_lines.append(f"<h1>{_html_escape(stripped[2:])}</h1>")
|
|
elif stripped.startswith("## "):
|
|
html_lines.append(f"<h2>{_html_escape(stripped[3:])}</h2>")
|
|
elif stripped.startswith("### "):
|
|
html_lines.append(f"<h3>{_html_escape(stripped[4:])}</h3>")
|
|
elif stripped == "---":
|
|
html_lines.append("<hr>")
|
|
elif stripped.startswith("> "):
|
|
html_lines.append(f"<blockquote>{_html_escape(stripped[2:])}</blockquote>")
|
|
elif stripped.startswith("- "):
|
|
html_lines.append(f"<li>{_html_escape(stripped[2:])}</li>")
|
|
elif stripped.startswith(" "):
|
|
html_lines.append(f"<br>{_html_escape(stripped)}")
|
|
elif stripped.startswith("**") and stripped.endswith("**"):
|
|
html_lines.append(f"<p><strong>{_html_escape(stripped[2:-2])}</strong></p>")
|
|
elif stripped:
|
|
# Inline code spans
|
|
line_html = _html_escape_with_code(stripped)
|
|
if not html_lines[-1].startswith("<"):
|
|
html_lines.append(f"<p>{line_html}</p>")
|
|
elif html_lines[-1] == "<body>" or html_lines[-1].startswith("<h"):
|
|
pass # Skip empty lines after body/headings
|
|
else:
|
|
html_lines.append(f"<p>{line_html}</p>")
|
|
else:
|
|
html_lines.append("")
|
|
|
|
if in_table:
|
|
html_lines.append("</table>")
|
|
if in_code_block:
|
|
html_lines.append("</code></pre>")
|
|
|
|
html_lines.append("</body>")
|
|
html_lines.append("</html>")
|
|
return "\n".join(html_lines)
|
|
|
|
|
|
def _html_escape(text: str) -> str:
|
|
"""Escape HTML special characters."""
|
|
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
def _html_escape_with_code(text: str) -> str:
|
|
"""Escape HTML but preserve inline code spans."""
|
|
result = _html_escape(text)
|
|
# Restore code spans: `...` -> <code>...</code>
|
|
import re
|
|
|
|
result = re.sub(r"`([^`]+)`", r"<code>\1</code>", result)
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PDF report generation (optional rendering)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def generate_pdf_report(
|
|
report_type: ReportType,
|
|
report_data: dict[str, Any],
|
|
methodology: dict[str, Any],
|
|
provenance: dict[str, Any],
|
|
) -> tuple[str | None, str | None]:
|
|
"""Attempt to generate a PDF report.
|
|
|
|
PDF is an optional rendering format (not authoritative). Requires a PDF
|
|
generation dependency. Returns (pdf_path, error_message).
|
|
|
|
If no PDF engine is available, returns (None, error_message) so the
|
|
caller can fall back with a warning and the canonical path.
|
|
|
|
Args:
|
|
report_type: Type of report.
|
|
report_data: The collected report data.
|
|
methodology: Methodology section dict.
|
|
provenance: Provenance section dict.
|
|
|
|
Returns:
|
|
Tuple of (output_path_or_None, error_message_or_None).
|
|
"""
|
|
# Try to use weasyprint if available
|
|
try:
|
|
import weasyprint # type: ignore[import-not-found] # noqa: F401
|
|
except ImportError:
|
|
pass
|
|
else:
|
|
html_content = generate_html_report(report_type, report_data, methodology, provenance)
|
|
return (html_content, None) # caller will write and convert
|
|
|
|
# Try reportlab
|
|
try:
|
|
import reportlab # type: ignore[import-untyped] # noqa: F401
|
|
except ImportError:
|
|
pass
|
|
else:
|
|
html_content = generate_html_report(report_type, report_data, methodology, provenance)
|
|
return (html_content, None)
|
|
|
|
# No PDF engine available
|
|
return (None, "PDF rendering dependency unavailable (install weasyprint or reportlab)")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# High-level report generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def write_report(
|
|
project_path: str,
|
|
report_type: ReportType,
|
|
output_format: str,
|
|
report_data: dict[str, Any],
|
|
methodology: dict[str, Any],
|
|
provenance: dict[str, Any],
|
|
) -> tuple[str, list[str]]:
|
|
"""Write a report file to the project's reports/ directory.
|
|
|
|
Markdown and JSON are authoritative formats. HTML and PDF are optional
|
|
renderings derived from the canonical formats.
|
|
|
|
Args:
|
|
project_path: Absolute path to the project workspace.
|
|
report_type: Type of report (triage, focused, project).
|
|
output_format: Output format (markdown, json, html, pdf).
|
|
report_data: The collected report data.
|
|
methodology: Methodology section dict.
|
|
provenance: Provenance section dict.
|
|
|
|
Returns:
|
|
Tuple of (output_path, warnings_list).
|
|
|
|
Raises:
|
|
ValueError: If output_format is unsupported.
|
|
"""
|
|
reports_dir = os.path.join(project_path, "reports")
|
|
os.makedirs(reports_dir, exist_ok=True)
|
|
|
|
analysis_id = provenance.get("analysis_id", "unknown")[:8]
|
|
fmt_ext = output_format.lower()
|
|
# "markdown" maps to .md
|
|
if fmt_ext == "markdown":
|
|
fmt_ext = "md"
|
|
|
|
filename = f"report-{report_type.value.lower()}-{analysis_id}.{fmt_ext}"
|
|
output_path = os.path.join(reports_dir, filename)
|
|
warnings: list[str] = []
|
|
|
|
if output_format in ("markdown", "md"):
|
|
content = generate_markdown_report(report_type, report_data, methodology, provenance)
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
elif output_format == "json":
|
|
content = generate_json_report(report_type, report_data, methodology, provenance)
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
elif output_format == "html":
|
|
content = generate_html_report(report_type, report_data, methodology, provenance)
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
elif output_format == "pdf":
|
|
html_content, pdf_error = generate_pdf_report(
|
|
report_type, report_data, methodology, provenance
|
|
)
|
|
if pdf_error is not None:
|
|
# PDF engine unavailable; write canonical Markdown instead
|
|
md_filename = f"report-{report_type.value.lower()}-{analysis_id}.md"
|
|
md_path = os.path.join(reports_dir, md_filename)
|
|
content = generate_markdown_report(report_type, report_data, methodology, provenance)
|
|
with open(md_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
warnings.append(f"{pdf_error}. Wrote canonical Markdown report instead at: {md_path}")
|
|
return md_path, warnings
|
|
|
|
# Write the HTML, then convert to PDF if weasyprint is available
|
|
try:
|
|
import weasyprint
|
|
|
|
pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
|
|
with open(output_path, "wb") as f:
|
|
f.write(pdf_bytes)
|
|
except (ImportError, Exception) as e:
|
|
# Fall back to canonical Markdown
|
|
md_filename = f"report-{report_type.value.lower()}-{analysis_id}.md"
|
|
md_path = os.path.join(reports_dir, md_filename)
|
|
content = generate_markdown_report(report_type, report_data, methodology, provenance)
|
|
with open(md_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
warnings.append(
|
|
f"PDF rendering failed: {e}. Wrote canonical Markdown report instead at: {md_path}"
|
|
)
|
|
return md_path, warnings
|
|
|
|
else:
|
|
raise ValueError(f"Unsupported output format: {output_format}")
|
|
|
|
return output_path, warnings
|