Files

212 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Active Directory Vulnerability Assessment Analyzer.
Parses PingCastle and BloodHound output to generate consolidated
AD security assessment reports with prioritized remediation actions.
"""
import argparse
import csv
import json
import re
import sys
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
RISK_WEIGHTS = {
"kerberoastable_admin": 10,
"unconstrained_delegation": 9,
"as_rep_roastable": 8,
"password_never_expires_admin": 8,
"adminsdholder_modified": 8,
"dcsync_non_dc": 9,
"gpo_abuse_path": 7,
"stale_admin_account": 6,
"ldap_signing_disabled": 6,
"ntlm_not_restricted": 5,
"excessive_domain_admins": 7,
"sid_history_present": 6,
"trust_sid_filtering_disabled": 7,
"unsupported_os_dc": 9,
"password_policy_weak": 5,
}
def parse_pingcastle_xml(xml_path):
"""Parse PingCastle HTML/XML health check report."""
findings = []
try:
tree = ET.parse(xml_path)
root = tree.getroot()
for risk in root.iter("RiskRule"):
finding = {
"source": "PingCastle",
"category": risk.findtext("Category", ""),
"rule_id": risk.findtext("RiskId", ""),
"title": risk.findtext("Rationale", ""),
"description": risk.findtext("Detail", ""),
"points": int(risk.findtext("Points", "0")),
}
findings.append(finding)
except ET.ParseError:
print(f"[-] Could not parse PingCastle XML: {xml_path}")
print(" Try exporting PingCastle results as XML format")
return findings
def parse_bloodhound_json(json_path):
"""Parse BloodHound CE exported data for critical findings."""
findings = []
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
nodes = data.get("nodes", [])
edges = data.get("edges", [])
elif isinstance(data, list):
nodes = data
edges = []
else:
return findings
for node in nodes:
props = node.get("properties", node.get("Properties", {}))
kind = node.get("kind", node.get("label", ""))
if kind == "User":
if props.get("hasspn", False) and props.get("admincount", False):
findings.append({
"source": "BloodHound",
"category": "Kerberos",
"rule_id": "kerberoastable_admin",
"title": f"Kerberoastable admin account: {props.get('name', 'unknown')}",
"description": f"User {props.get('name')} has SPN set and is in admin group",
"risk_weight": RISK_WEIGHTS["kerberoastable_admin"],
})
if props.get("dontreqpreauth", False):
findings.append({
"source": "BloodHound",
"category": "Kerberos",
"rule_id": "as_rep_roastable",
"title": f"AS-REP roastable account: {props.get('name', 'unknown')}",
"description": f"User {props.get('name')} has Kerberos pre-auth disabled",
"risk_weight": RISK_WEIGHTS["as_rep_roastable"],
})
if props.get("pwdneverexpires", False) and props.get("admincount", False):
findings.append({
"source": "BloodHound",
"category": "Privileged Accounts",
"rule_id": "password_never_expires_admin",
"title": f"Admin with non-expiring password: {props.get('name', 'unknown')}",
"description": f"Privileged user {props.get('name')} has password set to never expire",
"risk_weight": RISK_WEIGHTS["password_never_expires_admin"],
})
elif kind == "Computer":
if props.get("unconstraineddelegation", False):
name = props.get("name", "unknown")
if "DC" not in name.upper():
findings.append({
"source": "BloodHound",
"category": "Kerberos",
"rule_id": "unconstrained_delegation",
"title": f"Unconstrained delegation on non-DC: {name}",
"description": f"Computer {name} has unconstrained delegation enabled",
"risk_weight": RISK_WEIGHTS["unconstrained_delegation"],
})
return findings
def consolidate_findings(pingcastle_findings, bloodhound_findings):
"""Merge and deduplicate findings from multiple tools."""
all_findings = pingcastle_findings + bloodhound_findings
for finding in all_findings:
if "risk_weight" not in finding:
points = finding.get("points", 0)
if points >= 30:
finding["risk_weight"] = 10
elif points >= 20:
finding["risk_weight"] = 8
elif points >= 10:
finding["risk_weight"] = 6
elif points >= 5:
finding["risk_weight"] = 4
else:
finding["risk_weight"] = 2
rule_id = finding.get("rule_id", "")
if rule_id in RISK_WEIGHTS:
finding["risk_weight"] = max(finding["risk_weight"], RISK_WEIGHTS[rule_id])
all_findings.sort(key=lambda f: f.get("risk_weight", 0), reverse=True)
return all_findings
def generate_report(findings, output_path):
"""Generate consolidated AD assessment report."""
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"total_findings": len(findings),
"critical": len([f for f in findings if f.get("risk_weight", 0) >= 9]),
"high": len([f for f in findings if 7 <= f.get("risk_weight", 0) < 9]),
"medium": len([f for f in findings if 4 <= f.get("risk_weight", 0) < 7]),
"low": len([f for f in findings if f.get("risk_weight", 0) < 4]),
"findings": findings,
"categories": {},
}
for f in findings:
cat = f.get("category", "Other")
if cat not in report["categories"]:
report["categories"][cat] = 0
report["categories"][cat] += 1
with open(output_path, "w", encoding="utf-8") as fh:
json.dump(report, fh, indent=2)
print(f"[+] AD Assessment Report: {output_path}")
print(f" Total findings: {report['total_findings']}")
print(f" Critical: {report['critical']}")
print(f" High: {report['high']}")
print(f" Medium: {report['medium']}")
print(f" Low: {report['low']}")
print(f" Categories: {json.dumps(report['categories'], indent=6)}")
return report
def main():
parser = argparse.ArgumentParser(description="AD Vulnerability Assessment Analyzer")
parser.add_argument("--pingcastle", help="PingCastle XML report path")
parser.add_argument("--bloodhound", help="BloodHound JSON export path")
parser.add_argument("--output", default="ad_assessment_report.json", help="Output report path")
args = parser.parse_args()
pc_findings = []
bh_findings = []
if args.pingcastle:
print(f"[*] Parsing PingCastle report: {args.pingcastle}")
pc_findings = parse_pingcastle_xml(args.pingcastle)
print(f" Found {len(pc_findings)} PingCastle findings")
if args.bloodhound:
print(f"[*] Parsing BloodHound data: {args.bloodhound}")
bh_findings = parse_bloodhound_json(args.bloodhound)
print(f" Found {len(bh_findings)} BloodHound findings")
if not pc_findings and not bh_findings:
print("[-] No input files provided. Use --pingcastle and/or --bloodhound")
parser.print_help()
sys.exit(1)
findings = consolidate_findings(pc_findings, bh_findings)
generate_report(findings, args.output)
if __name__ == "__main__":
main()