Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
14 changes: 12 additions & 2 deletions lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 13 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ rich>=13
pytest>=8
weasyprint>=61
requests>=2
markdown>=3
15 changes: 14 additions & 1 deletion tests/test_reports.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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
1 change: 1 addition & 0 deletions tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
127 changes: 123 additions & 4 deletions utils/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
Expand Down Expand Up @@ -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