From 00a9a33e7c4d2a721d563c3610214d20ab754739 Mon Sep 17 00:00:00 2001 From: Psychevus Date: Sat, 26 Jul 2025 05:55:00 +0330 Subject: [PATCH] Add markdown and summary reporting --- README.md | 6 +- lambda_function.py | 14 ++++- main.py | 16 +++++- requirements.txt | 1 + tests/test_reports.py | 15 ++++- tests/test_smoke.py | 1 + utils/output.py | 127 ++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 167 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4c13104..dc46dd1 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ## Features * Wraps `subfinder`, `httpx`, `nuclei`, `nmap`, `testssl.sh`, optional modules like `dnsrecon`, `ffuf`, `screenshot`, `katana` behind one interface -* Structured JSON output with optional HTML or PDF reports +* Structured JSON output with optional HTML, Markdown or PDF reports * Docker container and AWS Lambda compatible * Extensible plugin system for custom modules * Opt-in pipeline mode to feed Subfinder → Httpx → Nuclei → Nmap → TestSSL @@ -49,7 +49,7 @@ Install the latest release from PyPI: ```bash pip install pentest-toolkit -pentest-toolkit example.com --pipeline +pentest-toolkit example.com --pipeline --report markdown ``` Run `pip install pentest-toolkit[full]` or `./install.sh` for automatic install of Go-based tools. @@ -60,7 +60,7 @@ git clone https://github.com/psychevus/pentest-toolkit.git cd pentest-toolkit python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt -r requirements-dev.txt -python main.py example.com --pipeline +python main.py example.com --pipeline --report markdown ``` ### Docker diff --git a/lambda_function.py b/lambda_function.py index a7b66a8..849f5d6 100644 --- a/lambda_function.py +++ b/lambda_function.py @@ -10,7 +10,13 @@ import requests from utils.logger import get_logger from utils.notify import notify_slack, notify_teams -from utils.output import write_html, write_json, write_pdf, safe_filename_component +from utils.output import ( + write_html, + write_json, + write_pdf, + write_markdown, + safe_filename_component, +) from utils.plugins import load_plugins from main import pipeline @@ -25,7 +31,7 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: Expected *event* keys: - ``target``: domain or IP address to scan (required) - ``tools``: optional list of tools - - ``report``: ``"html"`` or ``"pdf"`` + - ``report``: ``"html"``, ``"pdf"``, ``"markdown"`` or ``"summary"`` - ``webhook``: optional incoming webhook URL - ``webhook_type``: ``"slack"`` or ``"teams"`` (default ``"slack"``) - ``auto_install``: attempt to install missing binaries (bool) @@ -56,6 +62,10 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: write_html(findings, out_dir, prefix=prefix) elif report == "pdf": write_pdf(findings, out_dir, prefix=prefix) + elif report == "markdown": + write_markdown(findings, out_dir, prefix=prefix) + elif report == "summary": + write_markdown(findings, out_dir, prefix=prefix, summary_only=True) if webhook: try: diff --git a/main.py b/main.py index 34f66e7..b63037b 100644 --- a/main.py +++ b/main.py @@ -24,7 +24,13 @@ import modules # noqa: F401 # populate Module.registry import requests from utils.logger import get_logger -from utils.output import write_json, write_html, write_pdf, safe_filename_component +from utils.output import ( + write_json, + write_html, + write_pdf, + write_markdown, + safe_filename_component, +) from utils.notify import notify_slack, notify_teams from utils.deps import DependencyError, check_dependencies from utils.plugins import load_plugins @@ -194,8 +200,8 @@ def cli() -> None: ) parser.add_argument( "--report", - choices=["html", "pdf"], - help="Generate an HTML or PDF report in addition to JSON", + choices=["html", "pdf", "markdown", "summary"], + help="Generate an HTML, Markdown, PDF or summary report in addition to JSON", ) parser.add_argument( "--webhook", @@ -255,6 +261,10 @@ def run_one(t: str) -> List[Finding]: write_html(res, args.out, prefix=prefix) elif args.report == "pdf": write_pdf(res, args.out, prefix=prefix) + elif args.report == "markdown": + write_markdown(res, args.out, prefix=prefix) + elif args.report == "summary": + write_markdown(res, args.out, prefix=prefix, summary_only=True) if args.webhook: try: diff --git a/requirements.txt b/requirements.txt index 46ff393..3d22ae0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ rich>=13 pytest>=8 weasyprint>=61 requests>=2 +markdown>=3 diff --git a/tests/test_reports.py b/tests/test_reports.py index dcaff3f..16d6e00 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -1,7 +1,7 @@ from pathlib import Path from modules.base import Finding -from utils.output import write_html, write_pdf +from utils.output import write_html, write_pdf, write_markdown import pytest @@ -28,3 +28,16 @@ def test_write_pdf(tmp_path: Path): pytest.skip(f"pdf generation failed: {exc}") assert path.exists() assert path.suffix == ".pdf" + + +def test_write_markdown(tmp_path: Path): + path = write_markdown(_sample(), tmp_path) + assert path.exists() + assert path.suffix == ".md" + + +def test_write_summary(tmp_path: Path): + path = write_markdown(_sample(), tmp_path, summary_only=True) + assert path.exists() + text = path.read_text() + assert "Summary" in text diff --git a/tests/test_smoke.py b/tests/test_smoke.py index aa86935..793db5c 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -6,3 +6,4 @@ def test_help(): proc = run(["python", "main.py", "--help"], capture_output=True) assert proc.returncode == 0 assert b"Pentest-Toolkit" in proc.stdout + assert b"markdown" in proc.stdout diff --git a/utils/output.py b/utils/output.py index 12edb9a..c4fcdf8 100644 --- a/utils/output.py +++ b/utils/output.py @@ -4,9 +4,11 @@ import re from datetime import datetime, timezone from pathlib import Path -from typing import List +from typing import Dict, List, Tuple from weasyprint import HTML +from markdown import markdown +import xml.etree.ElementTree as ET from utils.logger import get_logger from modules.base import Finding @@ -24,6 +26,68 @@ def safe_filename_component(value: str) -> str: return re.sub(r"[^A-Za-z0-9._-]", "_", value) +def _count_nmap_ports(xml_path: str) -> int: + try: + tree = ET.parse(xml_path) + except Exception: # noqa: BLE001 + return 0 + count = 0 + for port in tree.findall(".//port"): + state = port.find("state") + if state is not None and state.get("state") == "open": + count += 1 + return count + + +def _count_testssl_issues(json_path: str) -> int: + try: + data = json.loads(Path(json_path).read_text()) + except Exception: # noqa: BLE001 + return 0 + issues = 0 + for entry in data: + sev = str(entry.get("severity", "")).upper() + if sev not in {"OK", "INFO"}: + issues += 1 + return issues + + +def summarise_findings(findings: List[Finding]) -> Tuple[Dict[str, int], List[Finding]]: + """Return summary statistics and high severity findings.""" + summary = { + "subdomains": 0, + "live_hosts": 0, + "nuclei": 0, + "open_ports": 0, + "ssl_issues": 0, + } + high: List[Finding] = [] + + for f in findings: + if f.tool == "subfinder": + summary["subdomains"] += 1 + elif f.tool == "httpx": + summary["live_hosts"] += 1 + elif f.tool == "nuclei": + summary["nuclei"] += 1 + sev = str(f.data.get("severity") or f.data.get("info", {}).get("severity", "")).lower() + if sev in {"high", "critical"}: + high.append(f) + elif f.tool == "nmap": + xml_path = f.data.get("xml") + if xml_path: + summary["open_ports"] += _count_nmap_ports(xml_path) + elif f.tool == "testssl": + json_path = f.data.get("report") + if json_path: + issues = _count_testssl_issues(json_path) + summary["ssl_issues"] += issues + if issues: + high.append(f) + + return summary, high + + def write_json( findings: List[Finding], out_dir: Path = Path("output"), @@ -98,14 +162,69 @@ def write_html( return path +def write_markdown( + findings: List[Finding], + out_dir: Path = Path("output"), + *, + prefix: str | None = None, + summary_only: bool = False, +) -> Path: + """Generate a Markdown report.""" + out_dir.mkdir(exist_ok=True) + name = "report_{}{}.md".format( + f"{safe_filename_component(prefix)}_" if prefix else "", + timestamp(), + ) + path = out_dir / name + + summary, high = summarise_findings(findings) + + lines = ["# Pentest-Toolkit Report", ""] + lines.append("## Summary") + for key, val in summary.items(): + lines.append(f"- {key.replace('_', ' ').title()}: {val}") + + if high: + lines.append("\n## Highlights") + for h in high: + ident = h.data.get("id") or h.data.get("url") or h.data.get("host") + sev = h.data.get("severity") or h.data.get("info", {}).get("severity") + lines.append(f"- **{sev}**: {ident}") + + recs = [] + if summary.get("ssl_issues"): + recs.append("Patch SSL configuration") + if high: + recs.append("Review vulnerabilities and disable unnecessary services") + if recs: + lines.append("\n## Recommendations") + for r in recs: + lines.append(f"- {r}") + + if not summary_only: + lines.append("\n## Findings by Tool") + lines.append("| Tool | Data |") + lines.append("| ---- | ---- |") + for f in findings: + data = json.dumps(f.data, indent=2).replace("|", "\\|") + lines.append(f"| {f.tool} | `{data}` |") + + tmp = path.with_suffix(".tmp") + tmp.write_text("\n".join(lines)) + tmp.replace(path) + logger.info("📄 Markdown report -> %s", path) + return path + + def write_pdf( findings: List[Finding], out_dir: Path = Path("output"), *, prefix: str | None = None, ) -> Path: - html_path = write_html(findings, out_dir, prefix=prefix) - pdf_path = html_path.with_suffix(".pdf") - HTML(filename=str(html_path)).write_pdf(str(pdf_path)) + md_path = write_markdown(findings, out_dir, prefix=prefix) + html = markdown(md_path.read_text(), extensions=["tables"]) + pdf_path = md_path.with_suffix(".pdf") + HTML(string=html).write_pdf(str(pdf_path)) logger.info("📄 PDF report -> %s", pdf_path) return pdf_path