azure-cloud-architect (451-line SKILL.md, 3 scripts, 3 references): - 6-step workflow mirroring aws-solution-architect for Azure - Bicep/ARM templates, AKS, Functions, Cosmos DB, cost optimization - architecture_designer.py, cost_optimizer.py, bicep_generator.py security-pen-testing (850-line SKILL.md, 3 scripts, 3 references): - OWASP Top 10 systematic audit, offensive security testing - XSS/SQLi/SSRF/IDOR detection, secret scanning, API security - vulnerability_scanner.py, dependency_auditor.py, pentest_report_generator.py - Responsible disclosure workflow included terraform-patterns extended (487 → 740 lines): - Multi-cloud provider configuration - OpenTofu compatibility notes - Infracost integration for PR cost estimation - Import existing infrastructure patterns - Terragrunt DRY multi-environment patterns Updated engineering-team plugin.json (26 → 28 skills). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
463 lines
19 KiB
Python
463 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Pen Test Report Generator - Generate structured penetration testing reports from findings.
|
|
|
|
Table of Contents:
|
|
PentestReportGenerator - Main class for report generation
|
|
__init__ - Initialize with findings data
|
|
generate_markdown() - Generate markdown report
|
|
generate_json() - Generate structured JSON report
|
|
_executive_summary() - Build executive summary section
|
|
_findings_table() - Build severity-sorted findings table
|
|
_detailed_findings() - Build detailed findings with evidence
|
|
_remediation_matrix() - Build effort vs. impact remediation matrix
|
|
_calculate_risk_score() - Calculate overall risk score
|
|
main() - CLI entry point
|
|
|
|
Usage:
|
|
python pentest_report_generator.py --findings findings.json --format md --output report.md
|
|
python pentest_report_generator.py --findings findings.json --format json
|
|
python pentest_report_generator.py --findings findings.json --format md
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass, asdict, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
|
|
@dataclass
|
|
class Finding:
|
|
"""A single pen test finding."""
|
|
title: str
|
|
severity: str # critical, high, medium, low, info
|
|
cvss_score: float
|
|
category: str
|
|
description: str
|
|
evidence: str
|
|
impact: str
|
|
remediation: str
|
|
cvss_vector: str = ""
|
|
references: List[str] = field(default_factory=list)
|
|
effort: str = "medium" # low, medium, high — remediation effort
|
|
|
|
|
|
SEVERITY_ORDER = {"critical": 5, "high": 4, "medium": 3, "low": 2, "info": 1}
|
|
|
|
|
|
class PentestReportGenerator:
|
|
"""Generate professional penetration testing reports from structured findings."""
|
|
|
|
def __init__(self, findings: List[Finding], metadata: Optional[Dict] = None):
|
|
self.findings = sorted(findings, key=lambda f: SEVERITY_ORDER.get(f.severity, 0), reverse=True)
|
|
self.metadata = metadata or {}
|
|
self.generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
def generate_markdown(self) -> str:
|
|
"""Generate a complete markdown pen test report."""
|
|
sections = []
|
|
sections.append(self._header())
|
|
sections.append(self._executive_summary())
|
|
sections.append(self._scope_section())
|
|
sections.append(self._findings_table())
|
|
sections.append(self._detailed_findings())
|
|
sections.append(self._remediation_matrix())
|
|
sections.append(self._methodology_section())
|
|
sections.append(self._appendix())
|
|
return "\n\n".join(sections)
|
|
|
|
def generate_json(self) -> Dict:
|
|
"""Generate structured JSON report."""
|
|
return {
|
|
"report_metadata": {
|
|
"title": self.metadata.get("title", "Penetration Test Report"),
|
|
"target": self.metadata.get("target", "Not specified"),
|
|
"tester": self.metadata.get("tester", "Not specified"),
|
|
"date_range": self.metadata.get("date_range", "Not specified"),
|
|
"generated_at": self.generated_at,
|
|
"overall_risk_score": self._calculate_risk_score(),
|
|
"overall_risk_level": self._risk_level(),
|
|
},
|
|
"summary": {
|
|
"total_findings": len(self.findings),
|
|
"critical": len([f for f in self.findings if f.severity == "critical"]),
|
|
"high": len([f for f in self.findings if f.severity == "high"]),
|
|
"medium": len([f for f in self.findings if f.severity == "medium"]),
|
|
"low": len([f for f in self.findings if f.severity == "low"]),
|
|
"info": len([f for f in self.findings if f.severity == "info"]),
|
|
},
|
|
"findings": [asdict(f) for f in self.findings],
|
|
"remediation_priority": self._remediation_priority_list(),
|
|
}
|
|
|
|
def _header(self) -> str:
|
|
title = self.metadata.get("title", "Penetration Test Report")
|
|
target = self.metadata.get("target", "Not specified")
|
|
tester = self.metadata.get("tester", "Not specified")
|
|
date_range = self.metadata.get("date_range", "Not specified")
|
|
lines = [
|
|
f"# {title}",
|
|
"",
|
|
"| Field | Value |",
|
|
"|-------|-------|",
|
|
f"| **Target** | {target} |",
|
|
f"| **Tester** | {tester} |",
|
|
f"| **Date Range** | {date_range} |",
|
|
f"| **Report Generated** | {self.generated_at} |",
|
|
f"| **Overall Risk** | {self._risk_level()} (Score: {self._calculate_risk_score():.1f}/10) |",
|
|
f"| **Total Findings** | {len(self.findings)} |",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
def _executive_summary(self) -> str:
|
|
critical = len([f for f in self.findings if f.severity == "critical"])
|
|
high = len([f for f in self.findings if f.severity == "high"])
|
|
medium = len([f for f in self.findings if f.severity == "medium"])
|
|
low = len([f for f in self.findings if f.severity == "low"])
|
|
info = len([f for f in self.findings if f.severity == "info"])
|
|
risk_score = self._calculate_risk_score()
|
|
risk_level = self._risk_level()
|
|
|
|
lines = [
|
|
"## Executive Summary",
|
|
"",
|
|
f"This penetration test identified **{len(self.findings)} findings** across the target application. "
|
|
f"The overall risk level is **{risk_level}** with a score of **{risk_score:.1f}/10**.",
|
|
"",
|
|
"### Finding Severity Distribution",
|
|
"",
|
|
"| Severity | Count |",
|
|
"|----------|-------|",
|
|
f"| Critical | {critical} |",
|
|
f"| High | {high} |",
|
|
f"| Medium | {medium} |",
|
|
f"| Low | {low} |",
|
|
f"| Informational | {info} |",
|
|
]
|
|
|
|
# Top 3 findings
|
|
if self.findings:
|
|
lines.append("")
|
|
lines.append("### Top Priority Findings")
|
|
lines.append("")
|
|
for i, f in enumerate(self.findings[:3], 1):
|
|
lines.append(f"{i}. **{f.title}** ({f.severity.upper()}, CVSS {f.cvss_score}) — {f.impact[:120]}")
|
|
|
|
# Risk assessment
|
|
lines.append("")
|
|
if critical > 0:
|
|
lines.append("> **CRITICAL RISK**: Immediate remediation required. Critical vulnerabilities "
|
|
"allow attackers to compromise the system with minimal effort.")
|
|
elif high > 0:
|
|
lines.append("> **HIGH RISK**: Prompt remediation recommended. High-severity vulnerabilities "
|
|
"pose significant risk of exploitation.")
|
|
elif medium > 0:
|
|
lines.append("> **MODERATE RISK**: Remediation should be planned within the next sprint. "
|
|
"Medium findings may be chained for greater impact.")
|
|
else:
|
|
lines.append("> **LOW RISK**: The application has a reasonable security posture. "
|
|
"Address low-severity findings during regular maintenance.")
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _scope_section(self) -> str:
|
|
scope = self.metadata.get("scope", "Full application security assessment")
|
|
exclusions = self.metadata.get("exclusions", "None specified")
|
|
test_type = self.metadata.get("test_type", "Gray box")
|
|
lines = [
|
|
"## Scope",
|
|
"",
|
|
f"- **In Scope**: {scope}",
|
|
f"- **Exclusions**: {exclusions}",
|
|
f"- **Test Type**: {test_type}",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
def _findings_table(self) -> str:
|
|
lines = [
|
|
"## Findings Overview",
|
|
"",
|
|
"| # | Severity | CVSS | Title | Category |",
|
|
"|---|----------|------|-------|----------|",
|
|
]
|
|
for i, f in enumerate(self.findings, 1):
|
|
sev_badge = f.severity.upper()
|
|
lines.append(f"| {i} | {sev_badge} | {f.cvss_score} | {f.title} | {f.category} |")
|
|
return "\n".join(lines)
|
|
|
|
def _detailed_findings(self) -> str:
|
|
lines = ["## Detailed Findings"]
|
|
for i, f in enumerate(self.findings, 1):
|
|
lines.append("")
|
|
lines.append(f"### {i}. {f.title}")
|
|
lines.append("")
|
|
lines.append(f"**Severity:** {f.severity.upper()} | **CVSS:** {f.cvss_score}"
|
|
+ (f" | **Vector:** `{f.cvss_vector}`" if f.cvss_vector else ""))
|
|
lines.append(f"**Category:** {f.category}")
|
|
lines.append("")
|
|
lines.append("#### Description")
|
|
lines.append("")
|
|
lines.append(f"{f.description}")
|
|
lines.append("")
|
|
lines.append("#### Evidence")
|
|
lines.append("")
|
|
lines.append("```")
|
|
lines.append(f"{f.evidence}")
|
|
lines.append("```")
|
|
lines.append("")
|
|
lines.append("#### Impact")
|
|
lines.append("")
|
|
lines.append(f"{f.impact}")
|
|
lines.append("")
|
|
lines.append("#### Remediation")
|
|
lines.append("")
|
|
lines.append(f"{f.remediation}")
|
|
if f.references:
|
|
lines.append("")
|
|
lines.append("#### References")
|
|
lines.append("")
|
|
for ref in f.references:
|
|
lines.append(f"- {ref}")
|
|
return "\n".join(lines)
|
|
|
|
def _remediation_matrix(self) -> str:
|
|
lines = [
|
|
"## Remediation Priority Matrix",
|
|
"",
|
|
"Prioritize remediation based on severity and effort:",
|
|
"",
|
|
"| # | Finding | Severity | Effort | Priority |",
|
|
"|---|---------|----------|--------|----------|",
|
|
]
|
|
for i, f in enumerate(self.findings, 1):
|
|
priority = self._compute_priority(f)
|
|
lines.append(f"| {i} | {f.title} | {f.severity.upper()} | {f.effort} | {priority} |")
|
|
|
|
lines.append("")
|
|
lines.append("**Priority Key:** P1 = Fix immediately, P2 = Fix this sprint, "
|
|
"P3 = Fix this quarter, P4 = Backlog")
|
|
return "\n".join(lines)
|
|
|
|
def _methodology_section(self) -> str:
|
|
lines = [
|
|
"## Methodology",
|
|
"",
|
|
"Testing followed the OWASP Testing Guide v4.2 and PTES (Penetration Testing Execution Standard):",
|
|
"",
|
|
"1. **Reconnaissance** — Mapped attack surface, identified endpoints and technologies",
|
|
"2. **Vulnerability Discovery** — Automated scanning + manual testing for OWASP Top 10",
|
|
"3. **Exploitation** — Validated findings with proof-of-concept (non-destructive)",
|
|
"4. **Post-Exploitation** — Assessed lateral movement and data access potential",
|
|
"5. **Reporting** — Documented findings with evidence and remediation guidance",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
def _appendix(self) -> str:
|
|
lines = [
|
|
"## Appendix",
|
|
"",
|
|
"### CVSS Scoring Reference",
|
|
"",
|
|
"| Score Range | Severity |",
|
|
"|-------------|----------|",
|
|
"| 9.0 - 10.0 | Critical |",
|
|
"| 7.0 - 8.9 | High |",
|
|
"| 4.0 - 6.9 | Medium |",
|
|
"| 0.1 - 3.9 | Low |",
|
|
"| 0.0 | Informational |",
|
|
"",
|
|
"### Disclaimer",
|
|
"",
|
|
"This report represents a point-in-time assessment. New vulnerabilities may emerge after "
|
|
"the testing period. Regular security assessments are recommended.",
|
|
"",
|
|
f"---\n\n*Report generated on {self.generated_at}*",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
def _calculate_risk_score(self) -> float:
|
|
"""Calculate overall risk score (0-10) based on findings."""
|
|
if not self.findings:
|
|
return 0.0
|
|
# Weighted by severity
|
|
weights = {"critical": 10, "high": 7, "medium": 4, "low": 1.5, "info": 0.5}
|
|
total_weight = sum(weights.get(f.severity, 0) for f in self.findings)
|
|
# Normalize: cap at 10, scale based on number of findings
|
|
score = min(10.0, total_weight / max(len(self.findings) * 0.5, 1))
|
|
return round(score, 1)
|
|
|
|
def _risk_level(self) -> str:
|
|
"""Return risk level string based on score."""
|
|
score = self._calculate_risk_score()
|
|
if score >= 9.0:
|
|
return "CRITICAL"
|
|
elif score >= 7.0:
|
|
return "HIGH"
|
|
elif score >= 4.0:
|
|
return "MEDIUM"
|
|
elif score > 0:
|
|
return "LOW"
|
|
return "NONE"
|
|
|
|
def _compute_priority(self, finding: Finding) -> str:
|
|
"""Compute remediation priority from severity and effort."""
|
|
sev = SEVERITY_ORDER.get(finding.severity, 0)
|
|
effort_map = {"low": 3, "medium": 2, "high": 1}
|
|
effort_val = effort_map.get(finding.effort, 2)
|
|
score = sev * effort_val
|
|
if score >= 12:
|
|
return "P1"
|
|
elif score >= 8:
|
|
return "P2"
|
|
elif score >= 4:
|
|
return "P3"
|
|
return "P4"
|
|
|
|
def _remediation_priority_list(self) -> List[Dict]:
|
|
"""Return ordered list of remediation priorities for JSON output."""
|
|
result = []
|
|
for f in self.findings:
|
|
result.append({
|
|
"title": f.title,
|
|
"severity": f.severity,
|
|
"effort": f.effort,
|
|
"priority": self._compute_priority(f),
|
|
"remediation": f.remediation,
|
|
})
|
|
return result
|
|
|
|
|
|
def load_findings(path: str) -> tuple:
|
|
"""Load findings from a JSON file."""
|
|
try:
|
|
content = Path(path).read_text(encoding="utf-8")
|
|
data = json.loads(content)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
print(f"Error loading findings: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Support both list-of-findings and object-with-metadata formats
|
|
metadata = {}
|
|
findings_data = data
|
|
if isinstance(data, dict):
|
|
metadata = data.get("metadata", {})
|
|
findings_data = data.get("findings", [])
|
|
|
|
findings = []
|
|
for item in findings_data:
|
|
findings.append(Finding(
|
|
title=item.get("title", "Untitled Finding"),
|
|
severity=item.get("severity", "medium"),
|
|
cvss_score=float(item.get("cvss_score", 0.0)),
|
|
category=item.get("category", "Uncategorized"),
|
|
description=item.get("description", ""),
|
|
evidence=item.get("evidence", "No evidence provided"),
|
|
impact=item.get("impact", ""),
|
|
remediation=item.get("remediation", ""),
|
|
cvss_vector=item.get("cvss_vector", ""),
|
|
references=item.get("references", []),
|
|
effort=item.get("effort", "medium"),
|
|
))
|
|
return findings, metadata
|
|
|
|
|
|
def generate_sample_findings() -> str:
|
|
"""Generate a sample findings JSON for reference."""
|
|
sample = [
|
|
{
|
|
"title": "SQL Injection in Login Endpoint",
|
|
"severity": "critical",
|
|
"cvss_score": 9.8,
|
|
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
"category": "A03:2021 - Injection",
|
|
"description": "The /api/login endpoint is vulnerable to SQL injection via the email parameter.",
|
|
"evidence": "Request: POST /api/login {\"email\": \"' OR 1=1--\", \"password\": \"x\"}\nResponse: 200 OK with admin session token",
|
|
"impact": "Full database access, authentication bypass, potential remote code execution.",
|
|
"remediation": "Use parameterized queries. Replace string concatenation with prepared statements.",
|
|
"references": ["https://cwe.mitre.org/data/definitions/89.html"],
|
|
"effort": "low"
|
|
},
|
|
{
|
|
"title": "Stored XSS in User Profile",
|
|
"severity": "high",
|
|
"cvss_score": 7.1,
|
|
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N",
|
|
"category": "A03:2021 - Injection",
|
|
"description": "The user profile 'bio' field does not sanitize HTML input.",
|
|
"evidence": "Submitted <img src=x onerror=alert(document.cookie)> in bio field.\nVisiting the profile page executes the payload.",
|
|
"impact": "Session hijacking, account takeover, phishing via stored malicious content.",
|
|
"remediation": "Sanitize all user input with DOMPurify. Implement Content-Security-Policy.",
|
|
"references": ["https://cwe.mitre.org/data/definitions/79.html"],
|
|
"effort": "low"
|
|
}
|
|
]
|
|
return json.dumps(sample, indent=2)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Pen Test Report Generator — Generate professional penetration testing reports from structured findings.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
%(prog)s --findings findings.json --format md --output report.md
|
|
%(prog)s --findings findings.json --format json
|
|
%(prog)s --sample > sample_findings.json
|
|
|
|
Findings JSON format:
|
|
A JSON array of objects with: title, severity, cvss_score, category,
|
|
description, evidence, impact, remediation, cvss_vector, references, effort.
|
|
|
|
Use --sample to generate a template.
|
|
""",
|
|
)
|
|
parser.add_argument("--findings", metavar="FILE",
|
|
help="Path to findings JSON file")
|
|
parser.add_argument("--format", choices=["md", "json"], default="md",
|
|
help="Output format (default: md)")
|
|
parser.add_argument("--output", metavar="FILE",
|
|
help="Output file path (default: stdout)")
|
|
parser.add_argument("--json", action="store_true", dest="json_shortcut",
|
|
help="Shortcut for --format json")
|
|
parser.add_argument("--sample", action="store_true",
|
|
help="Print sample findings JSON and exit")
|
|
args = parser.parse_args()
|
|
|
|
if args.sample:
|
|
print(generate_sample_findings())
|
|
return
|
|
|
|
if not args.findings:
|
|
parser.error("--findings is required (use --sample to generate a template)")
|
|
|
|
if not Path(args.findings).exists():
|
|
print(f"Error: File not found: {args.findings}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
output_format = "json" if args.json_shortcut else args.format
|
|
findings, metadata = load_findings(args.findings)
|
|
|
|
if not findings:
|
|
print("No findings loaded. Check the JSON file format.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
generator = PentestReportGenerator(findings=findings, metadata=metadata)
|
|
|
|
if output_format == "json":
|
|
result = json.dumps(generator.generate_json(), indent=2)
|
|
else:
|
|
result = generator.generate_markdown()
|
|
|
|
if args.output:
|
|
Path(args.output).write_text(result, encoding="utf-8")
|
|
print(f"Report written to {args.output}")
|
|
else:
|
|
print(result)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|