#!/usr/bin/env python3 """ Malware analysis report generator. Compiles analysis results from JSON input into structured Markdown, HTML, or JSON reports. Includes executive summary, technical findings, IOCs, MITRE ATT&CK mapping, detection rules, and remediation steps. Usage: python3 report_generator.py --input analysis.json --output report.md --format markdown python3 report_generator.py --input analysis.json --output report.html --format html python3 report_generator.py --input analysis.json --output report.json --format json python3 report_generator.py --input analysis.json --executive-summary-only """ from __future__ import annotations import argparse import json import os import sys from datetime import datetime, timezone from pathlib import Path # --------------------------------------------------------------------------- # Optional dependency imports with graceful fallback # --------------------------------------------------------------------------- try: import jinja2 HAS_JINJA2 = True except ImportError: HAS_JINJA2 = False try: import markdown as md_lib HAS_MARKDOWN = True except ImportError: HAS_MARKDOWN = False # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- DEFAULT_CLASSIFICATION = "TLP:CLEAR" SEVERITY_ORDER = { "critical": 0, "high": 1, "medium": 2, "low": 3, "informational": 4, } IOC_TYPE_ORDER = [ "sha256", "sha1", "md5", "ssdeep", "domain", "ip", "url", "email", "mutex", "registry", "file_path", "service", "user_agent", "ja3", "certificate", ] REPORT_SECTIONS = [ "executive_summary", "sample_info", "findings", "iocs", "mitre_attack", "detection_rules", "remediation", "appendices", ] # --------------------------------------------------------------------------- # Input validation and normalization # --------------------------------------------------------------------------- def load_analysis_input(input_path: str) -> dict: """Load and validate analysis input JSON.""" try: with open(input_path, "r", encoding="utf-8") as f: data = json.load(f) except FileNotFoundError: print(f"Error: Input file not found: {input_path}", file=sys.stderr) sys.exit(1) except json.JSONDecodeError as e: print(f"Error: Invalid JSON in {input_path}: {e}", file=sys.stderr) sys.exit(1) return normalize_input(data) def normalize_input(data: dict) -> dict: """Normalize input data to ensure all required fields exist.""" normalized = { "sample": data.get("sample", {}), "findings": data.get("findings", []), "iocs": data.get("iocs", {}), "mitre_attack": data.get("mitre_attack", []), "detection_rules": data.get("detection_rules", []), "remediation": data.get("remediation", []), "appendices": data.get("appendices", []), "metadata": data.get("metadata", {}), } # Ensure sample has minimum fields sample = normalized["sample"] sample.setdefault("file_name", "Unknown") sample.setdefault("sha256", "N/A") sample.setdefault("file_type", "Unknown") sample.setdefault("file_size", 0) # Sort findings by severity normalized["findings"] = sorted( normalized["findings"], key=lambda f: SEVERITY_ORDER.get(f.get("severity", "informational").lower(), 99), ) return normalized # --------------------------------------------------------------------------- # Report ID and metadata generation # --------------------------------------------------------------------------- def generate_report_metadata( data: dict, classification: str = DEFAULT_CLASSIFICATION, analyst: str = "", title: str = "", ) -> dict: """Generate report metadata block.""" now = datetime.now(tz=timezone.utc) report_id = f"MAR-{now.strftime('%Y')}-{now.strftime('%m%d%H%M')}" sample = data.get("sample", {}) if not title: family = data.get("metadata", {}).get("malware_family", "") if family: title = f"Malware Analysis Report: {family}" else: title = f"Malware Analysis Report: {sample.get('file_name', 'Unknown Sample')}" return { "title": title, "report_id": report_id, "date": now.strftime("%Y-%m-%d"), "timestamp": now.isoformat(), "classification": classification, "analyst": analyst or os.environ.get("ANALYST_NAME", ""), "sample_sha256": sample.get("sha256", "N/A"), } # --------------------------------------------------------------------------- # Executive summary generation # --------------------------------------------------------------------------- def generate_executive_summary(data: dict) -> str: """Generate executive summary text from analysis data.""" sample = data.get("sample", {}) findings = data.get("findings", []) iocs = data.get("iocs", {}) mitre = data.get("mitre_attack", []) metadata = data.get("metadata", {}) family = metadata.get("malware_family", "an unidentified malware sample") severity = metadata.get("overall_severity", "undetermined") # Count findings by severity sev_counts = {} for f in findings: s = f.get("severity", "informational").lower() sev_counts[s] = sev_counts.get(s, 0) + 1 # Count IOCs total_iocs = sum(len(v) if isinstance(v, list) else 1 for v in iocs.values()) lines = [] lines.append( f"This report documents the analysis of **{sample.get('file_name', 'N/A')}** " f"(SHA256: `{sample.get('sha256', 'N/A')[:16]}...`), " f"identified as **{family}**." ) if severity != "undetermined": lines.append(f"The overall threat severity is assessed as **{severity.upper()}**.") if sev_counts: parts = [] for s in ["critical", "high", "medium", "low", "informational"]: if s in sev_counts: parts.append(f"{sev_counts[s]} {s}") lines.append(f"Analysis identified {', '.join(parts)} finding(s).") if total_iocs > 0: lines.append(f"A total of **{total_iocs}** indicators of compromise were extracted.") if mitre: lines.append( f"The sample maps to **{len(mitre)}** MITRE ATT&CK technique(s)." ) # Key behaviors behaviors = metadata.get("key_behaviors", []) if behaviors: lines.append("\n**Key behaviors observed:**") for b in behaviors[:5]: lines.append(f"- {b}") # Immediate actions actions = metadata.get("immediate_actions", []) if actions: lines.append("\n**Recommended immediate actions:**") for a in actions[:5]: lines.append(f"1. {a}") return "\n".join(lines) # --------------------------------------------------------------------------- # Markdown report rendering # --------------------------------------------------------------------------- def render_markdown(data: dict, report_meta: dict, exec_summary_only: bool = False) -> str: """Render the full report as Markdown.""" lines = [] classification = report_meta.get("classification", DEFAULT_CLASSIFICATION) # Header lines.append(f"# {report_meta['title']}") lines.append("") lines.append(f"**Report ID:** {report_meta['report_id']} ") lines.append(f"**Date:** {report_meta['date']} ") lines.append(f"**Classification:** {classification} ") if report_meta.get("analyst"): lines.append(f"**Analyst:** {report_meta['analyst']} ") lines.append("") lines.append("---") lines.append("") # Executive Summary lines.append("## Executive Summary") lines.append("") lines.append(generate_executive_summary(data)) lines.append("") if exec_summary_only: lines.append("---") lines.append(f"*{classification} | {report_meta['report_id']}*") return "\n".join(lines) # Sample Information lines.append("---") lines.append("") lines.append("## Sample Information") lines.append("") sample = data.get("sample", {}) lines.append("| Property | Value |") lines.append("|----------|-------|") lines.append(f"| File Name | `{sample.get('file_name', 'N/A')}` |") lines.append(f"| File Size | {_human_size(sample.get('file_size', 0))} |") lines.append(f"| File Type | {sample.get('file_type', 'N/A')} |") lines.append(f"| MD5 | `{sample.get('md5', 'N/A')}` |") lines.append(f"| SHA1 | `{sample.get('sha1', 'N/A')}` |") lines.append(f"| SHA256 | `{sample.get('sha256', 'N/A')}` |") if sample.get("ssdeep"): lines.append(f"| ssdeep | `{sample['ssdeep']}` |") if sample.get("first_seen"): lines.append(f"| First Seen | {sample['first_seen']} |") if sample.get("source"): lines.append(f"| Source | {sample['source']} |") lines.append("") # Technical Findings lines.append("---") lines.append("") lines.append("## Technical Findings") lines.append("") findings = data.get("findings", []) if findings: for i, finding in enumerate(findings, 1): severity = finding.get("severity", "informational").upper() title = finding.get("title", f"Finding {i}") lines.append(f"### {i}. [{severity}] {title}") lines.append("") if finding.get("description"): lines.append(f"**Description:** {finding['description']}") lines.append("") if finding.get("evidence"): lines.append("**Evidence:**") if isinstance(finding["evidence"], list): for e in finding["evidence"]: lines.append(f"- {e}") else: lines.append(f"```\n{finding['evidence']}\n```") lines.append("") if finding.get("mitre_attack"): techniques = finding["mitre_attack"] if isinstance(techniques, list): lines.append(f"**ATT&CK:** {', '.join(techniques)}") else: lines.append(f"**ATT&CK:** {techniques}") lines.append("") else: lines.append("*No findings documented.*") lines.append("") # Indicators of Compromise lines.append("---") lines.append("") lines.append("## Indicators of Compromise (IOCs)") lines.append("") iocs = data.get("iocs", {}) if iocs: lines.append("| Type | Value | Context | Confidence |") lines.append("|------|-------|---------|------------|") for ioc_type in IOC_TYPE_ORDER: if ioc_type not in iocs: continue entries = iocs[ioc_type] if not isinstance(entries, list): entries = [entries] for entry in entries: if isinstance(entry, dict): value = entry.get("value", "N/A") context = entry.get("context", "") confidence = entry.get("confidence", "medium") else: value = str(entry) context = "" confidence = "medium" lines.append(f"| {ioc_type} | `{value}` | {context} | {confidence} |") # Handle any types not in the standard order for ioc_type, entries in iocs.items(): if ioc_type in IOC_TYPE_ORDER: continue if not isinstance(entries, list): entries = [entries] for entry in entries: if isinstance(entry, dict): value = entry.get("value", "N/A") context = entry.get("context", "") confidence = entry.get("confidence", "medium") else: value = str(entry) context = "" confidence = "medium" lines.append(f"| {ioc_type} | `{value}` | {context} | {confidence} |") lines.append("") else: lines.append("*No IOCs extracted.*") lines.append("") # MITRE ATT&CK Mapping lines.append("---") lines.append("") lines.append("## MITRE ATT&CK Mapping") lines.append("") mitre = data.get("mitre_attack", []) if mitre: lines.append("| Technique ID | Name | Tactic | Evidence |") lines.append("|-------------|------|--------|----------|") for t in mitre: if isinstance(t, dict): tid = t.get("technique_id", "N/A") name = t.get("name", "N/A") tactic = t.get("tactic", "N/A") evidence = t.get("evidence", "") else: tid = str(t) name = "" tactic = "" evidence = "" lines.append(f"| {tid} | {name} | {tactic} | {evidence} |") lines.append("") else: lines.append("*No ATT&CK techniques mapped.*") lines.append("") # Detection Rules lines.append("---") lines.append("") lines.append("## Detection Rules") lines.append("") rules = data.get("detection_rules", []) if rules: for rule in rules: if isinstance(rule, dict): rule_type = rule.get("type", "generic").upper() rule_name = rule.get("name", "Unnamed Rule") rule_content = rule.get("content", "") lines.append(f"### {rule_type}: {rule_name}") lines.append("") if rule.get("description"): lines.append(f"{rule['description']}") lines.append("") lang = rule.get("language", "") lines.append(f"```{lang}") lines.append(rule_content) lines.append("```") lines.append("") else: lines.append(f"```\n{rule}\n```") lines.append("") else: lines.append("*No detection rules generated.*") lines.append("") # Remediation lines.append("---") lines.append("") lines.append("## Remediation Recommendations") lines.append("") remediation = data.get("remediation", []) if remediation: for i, step in enumerate(remediation, 1): if isinstance(step, dict): phase = step.get("phase", "General") action = step.get("action", "N/A") priority = step.get("priority", "medium").upper() lines.append(f"{i}. **[{priority}] {phase}:** {action}") if step.get("details"): lines.append(f" - {step['details']}") else: lines.append(f"{i}. {step}") lines.append("") else: lines.append("*No remediation steps documented.*") lines.append("") # Appendices appendices = data.get("appendices", []) if appendices: lines.append("---") lines.append("") lines.append("## Appendices") lines.append("") for i, appendix in enumerate(appendices, 1): if isinstance(appendix, dict): title = appendix.get("title", f"Appendix {i}") content = appendix.get("content", "") lines.append(f"### Appendix {i}: {title}") lines.append("") lines.append(content) lines.append("") else: lines.append(f"### Appendix {i}") lines.append("") lines.append(str(appendix)) lines.append("") # Footer lines.append("---") lines.append(f"*{classification} | {report_meta['report_id']} | Generated {report_meta['date']}*") return "\n".join(lines) # --------------------------------------------------------------------------- # HTML report rendering # --------------------------------------------------------------------------- HTML_TEMPLATE = """ {{ title }} {{ content }} """ def render_html(data: dict, report_meta: dict, exec_summary_only: bool = False) -> str: """Render the report as HTML.""" md_content = render_markdown(data, report_meta, exec_summary_only) if HAS_MARKDOWN: html_body = md_lib.markdown( md_content, extensions=["tables", "fenced_code"], ) else: # Basic fallback: wrap markdown in
 tags
        html_body = f"
{_escape_html(md_content)}
" if HAS_JINJA2: template = jinja2.Template(HTML_TEMPLATE) return template.render( title=report_meta["title"], content=html_body, classification=report_meta["classification"], report_id=report_meta["report_id"], date=report_meta["date"], ) else: # Simple string replacement fallback html = HTML_TEMPLATE html = html.replace("{{ title }}", report_meta["title"]) html = html.replace("{{ content }}", html_body) html = html.replace("{{ classification }}", report_meta["classification"]) html = html.replace("{{ report_id }}", report_meta["report_id"]) html = html.replace("{{ date }}", report_meta["date"]) return html def _escape_html(text: str) -> str: """Escape HTML special characters.""" return ( text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) ) # --------------------------------------------------------------------------- # JSON report rendering # --------------------------------------------------------------------------- def render_json(data: dict, report_meta: dict, exec_summary_only: bool = False) -> str: """Render the report as structured JSON.""" report = { "report_metadata": report_meta, "executive_summary": generate_executive_summary(data), } if not exec_summary_only: report["sample_info"] = data.get("sample", {}) report["findings"] = data.get("findings", []) report["iocs"] = data.get("iocs", {}) report["mitre_attack"] = data.get("mitre_attack", []) report["detection_rules"] = data.get("detection_rules", []) report["remediation"] = data.get("remediation", []) report["appendices"] = data.get("appendices", []) return json.dumps(report, indent=2, default=str) # --------------------------------------------------------------------------- # Utility # --------------------------------------------------------------------------- def _human_size(size_bytes: int) -> str: """Convert bytes to human-readable size.""" if size_bytes == 0: return "0 B" for unit in ("B", "KB", "MB", "GB"): if size_bytes < 1024: return f"{size_bytes:.1f} {unit}" size_bytes /= 1024 return f"{size_bytes:.1f} TB" # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser( description="Generate structured malware analysis reports", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" " %(prog)s --input analysis.json --output report.md --format markdown\n" " %(prog)s --input analysis.json --output report.html --format html\n" " %(prog)s --input analysis.json --output report.json --format json\n" " %(prog)s --input analysis.json --executive-summary-only\n" ), ) parser.add_argument( "--input", "-i", required=True, help="Path to analysis results JSON file", ) parser.add_argument( "--output", "-o", help="Output file path (default: stdout)", ) parser.add_argument( "--format", "-f", choices=["markdown", "html", "json"], default="markdown", help="Output format (default: markdown)", ) parser.add_argument( "--classification", "-c", default=DEFAULT_CLASSIFICATION, help=f"Report classification marking (default: {DEFAULT_CLASSIFICATION})", ) parser.add_argument( "--analyst", "-a", default="", help="Analyst name (or set ANALYST_NAME env var)", ) parser.add_argument( "--title", "-t", default="", help="Custom report title", ) parser.add_argument( "--executive-summary-only", action="store_true", help="Generate executive summary section only", ) args = parser.parse_args() # Load input data = load_analysis_input(args.input) # Generate metadata report_meta = generate_report_metadata( data, classification=args.classification, analyst=args.analyst, title=args.title, ) # Render renderers = { "markdown": render_markdown, "html": render_html, "json": render_json, } renderer = renderers[args.format] output = renderer(data, report_meta, exec_summary_only=args.executive_summary_only) # Write output if args.output: Path(args.output).parent.mkdir(parents=True, exist_ok=True) Path(args.output).write_text(output, encoding="utf-8") print(f"Report written to {args.output}", file=sys.stderr) else: print(output) if __name__ == "__main__": main()