- Complete Cloudflare Terraform configuration (DNS, WAF, tunnels, access) - WAF Intelligence MCP server with threat analysis and ML classification - GitOps automation with PR workflows and drift detection - Observatory monitoring stack with Prometheus/Grafana - IDE operator rules for governed development - Security playbooks and compliance frameworks - Autonomous remediation and state reconciliation
345 lines
12 KiB
Python
345 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Drift Visualizer
|
|
Compares Terraform state, DNS manifest, and live Cloudflare configuration.
|
|
Outputs JSON diff and HTML report.
|
|
|
|
Usage:
|
|
python3 drift-visualizer.py --snapshot <path> --manifest <path> --output <dir>
|
|
"""
|
|
|
|
import argparse
|
|
import html
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
|
|
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "reports")
|
|
|
|
|
|
class DriftAnalyzer:
|
|
"""Analyzes drift between different state sources."""
|
|
|
|
def __init__(self):
|
|
self.diffs: List[Dict[str, Any]] = []
|
|
|
|
def compare_dns_records(
|
|
self,
|
|
source_name: str,
|
|
source_records: List[Dict],
|
|
target_name: str,
|
|
target_records: List[Dict]
|
|
) -> List[Dict[str, Any]]:
|
|
"""Compare DNS records between two sources."""
|
|
diffs = []
|
|
|
|
# Build lookup maps
|
|
source_map = {(r.get("type"), r.get("name")): r for r in source_records}
|
|
target_map = {(r.get("type"), r.get("name")): r for r in target_records}
|
|
|
|
all_keys = set(source_map.keys()) | set(target_map.keys())
|
|
|
|
for key in all_keys:
|
|
rtype, name = key
|
|
source_rec = source_map.get(key)
|
|
target_rec = target_map.get(key)
|
|
|
|
if source_rec and not target_rec:
|
|
diffs.append({
|
|
"type": "missing",
|
|
"source": source_name,
|
|
"target": target_name,
|
|
"record_type": rtype,
|
|
"record_name": name,
|
|
"detail": f"Record exists in {source_name} but not in {target_name}",
|
|
"severity": "high",
|
|
})
|
|
elif target_rec and not source_rec:
|
|
diffs.append({
|
|
"type": "extra",
|
|
"source": source_name,
|
|
"target": target_name,
|
|
"record_type": rtype,
|
|
"record_name": name,
|
|
"detail": f"Record exists in {target_name} but not in {source_name}",
|
|
"severity": "medium",
|
|
})
|
|
else:
|
|
# Both exist - check for content/config drift
|
|
content_diff = self._compare_record_content(source_rec, target_rec)
|
|
if content_diff:
|
|
diffs.append({
|
|
"type": "modified",
|
|
"source": source_name,
|
|
"target": target_name,
|
|
"record_type": rtype,
|
|
"record_name": name,
|
|
"detail": content_diff,
|
|
"source_value": source_rec,
|
|
"target_value": target_rec,
|
|
"severity": "medium",
|
|
})
|
|
|
|
return diffs
|
|
|
|
def _compare_record_content(self, rec1: Dict, rec2: Dict) -> Optional[str]:
|
|
"""Compare record content and return diff description."""
|
|
diffs = []
|
|
|
|
if rec1.get("content") != rec2.get("content"):
|
|
diffs.append(f"content: {rec1.get('content')} -> {rec2.get('content')}")
|
|
|
|
if rec1.get("proxied") != rec2.get("proxied"):
|
|
diffs.append(f"proxied: {rec1.get('proxied')} -> {rec2.get('proxied')}")
|
|
|
|
if rec1.get("ttl") != rec2.get("ttl"):
|
|
diffs.append(f"ttl: {rec1.get('ttl')} -> {rec2.get('ttl')}")
|
|
|
|
return "; ".join(diffs) if diffs else None
|
|
|
|
def compare_settings(
|
|
self,
|
|
source_name: str,
|
|
source_settings: Dict,
|
|
target_name: str,
|
|
target_settings: Dict
|
|
) -> List[Dict[str, Any]]:
|
|
"""Compare zone settings."""
|
|
diffs = []
|
|
all_keys = set(source_settings.keys()) | set(target_settings.keys())
|
|
|
|
for key in all_keys:
|
|
src_val = source_settings.get(key)
|
|
tgt_val = target_settings.get(key)
|
|
|
|
if src_val != tgt_val:
|
|
diffs.append({
|
|
"type": "setting_drift",
|
|
"source": source_name,
|
|
"target": target_name,
|
|
"setting": key,
|
|
"source_value": src_val,
|
|
"target_value": tgt_val,
|
|
"severity": "medium" if key in ("ssl", "min_tls_version") else "low",
|
|
})
|
|
|
|
return diffs
|
|
|
|
def analyze(
|
|
self,
|
|
snapshot: Optional[Dict] = None,
|
|
manifest: Optional[Dict] = None,
|
|
terraform_state: Optional[Dict] = None
|
|
) -> Dict[str, Any]:
|
|
"""Run full drift analysis."""
|
|
self.diffs = []
|
|
comparisons = []
|
|
|
|
# Snapshot vs Manifest
|
|
if snapshot and manifest:
|
|
snapshot_dns = snapshot.get("state", {}).get("dns", {}).get("records", [])
|
|
manifest_dns = manifest.get("records", [])
|
|
|
|
dns_diffs = self.compare_dns_records(
|
|
"manifest", manifest_dns,
|
|
"cloudflare", snapshot_dns
|
|
)
|
|
self.diffs.extend(dns_diffs)
|
|
comparisons.append("manifest_vs_cloudflare")
|
|
|
|
# Summary
|
|
high = len([d for d in self.diffs if d.get("severity") == "high"])
|
|
medium = len([d for d in self.diffs if d.get("severity") == "medium"])
|
|
low = len([d for d in self.diffs if d.get("severity") == "low"])
|
|
|
|
return {
|
|
"analysis_type": "drift_report",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"comparisons": comparisons,
|
|
"summary": {
|
|
"total_diffs": len(self.diffs),
|
|
"high_severity": high,
|
|
"medium_severity": medium,
|
|
"low_severity": low,
|
|
"drift_detected": len(self.diffs) > 0,
|
|
},
|
|
"diffs": self.diffs,
|
|
}
|
|
|
|
|
|
def generate_html_report(analysis: Dict[str, Any]) -> str:
|
|
"""Generate HTML visualization of drift report."""
|
|
timestamp = analysis.get("timestamp", "")
|
|
summary = analysis.get("summary", {})
|
|
diffs = analysis.get("diffs", [])
|
|
|
|
# CSS styles
|
|
css = """
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
max-width: 1200px; margin: 0 auto; padding: 20px; background: #0d1117; color: #c9d1d9; }
|
|
h1 { color: #58a6ff; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
|
|
h2 { color: #8b949e; }
|
|
.summary { display: flex; gap: 20px; margin: 20px 0; }
|
|
.card { background: #161b22; padding: 20px; border-radius: 8px; border: 1px solid #30363d; flex: 1; }
|
|
.card h3 { margin-top: 0; color: #58a6ff; }
|
|
.stat { font-size: 2em; font-weight: bold; }
|
|
.high { color: #f85149; }
|
|
.medium { color: #d29922; }
|
|
.low { color: #3fb950; }
|
|
.ok { color: #3fb950; }
|
|
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
|
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
|
|
th { background: #161b22; color: #8b949e; }
|
|
tr:hover { background: #161b22; }
|
|
.badge { padding: 4px 8px; border-radius: 4px; font-size: 0.8em; font-weight: bold; }
|
|
.badge-high { background: #f85149; color: white; }
|
|
.badge-medium { background: #d29922; color: black; }
|
|
.badge-low { background: #238636; color: white; }
|
|
.badge-missing { background: #f85149; }
|
|
.badge-extra { background: #d29922; }
|
|
.badge-modified { background: #1f6feb; color: white; }
|
|
.no-drift { text-align: center; padding: 40px; color: #3fb950; }
|
|
code { background: #21262d; padding: 2px 6px; border-radius: 4px; }
|
|
</style>
|
|
"""
|
|
|
|
# Header
|
|
html_parts = [
|
|
"<!DOCTYPE html>",
|
|
"<html><head>",
|
|
"<meta charset='utf-8'>",
|
|
"<title>Cloudflare Drift Report</title>",
|
|
css,
|
|
"</head><body>",
|
|
"<h1>Cloudflare Drift Report</h1>",
|
|
f"<p>Generated: {timestamp}</p>",
|
|
]
|
|
|
|
# Summary cards
|
|
html_parts.append("<div class='summary'>")
|
|
html_parts.append(f"""
|
|
<div class='card'>
|
|
<h3>Total Diffs</h3>
|
|
<div class='stat {"ok" if summary.get("total_diffs") == 0 else "high"}'>{summary.get("total_diffs", 0)}</div>
|
|
</div>
|
|
""")
|
|
html_parts.append(f"""
|
|
<div class='card'>
|
|
<h3>High Severity</h3>
|
|
<div class='stat high'>{summary.get("high_severity", 0)}</div>
|
|
</div>
|
|
""")
|
|
html_parts.append(f"""
|
|
<div class='card'>
|
|
<h3>Medium Severity</h3>
|
|
<div class='stat medium'>{summary.get("medium_severity", 0)}</div>
|
|
</div>
|
|
""")
|
|
html_parts.append(f"""
|
|
<div class='card'>
|
|
<h3>Low Severity</h3>
|
|
<div class='stat low'>{summary.get("low_severity", 0)}</div>
|
|
</div>
|
|
""")
|
|
html_parts.append("</div>")
|
|
|
|
# Diffs table
|
|
if diffs:
|
|
html_parts.append("<h2>Drift Details</h2>")
|
|
html_parts.append("<table>")
|
|
html_parts.append("""
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>Severity</th>
|
|
<th>Record</th>
|
|
<th>Detail</th>
|
|
</tr>
|
|
""")
|
|
|
|
for diff in diffs:
|
|
dtype = diff.get("type", "unknown")
|
|
severity = diff.get("severity", "low")
|
|
record = f"{diff.get('record_type', '')} {diff.get('record_name', '')}"
|
|
detail = html.escape(str(diff.get("detail", "")))
|
|
|
|
html_parts.append(f"""
|
|
<tr>
|
|
<td><span class='badge badge-{dtype}'>{dtype}</span></td>
|
|
<td><span class='badge badge-{severity}'>{severity.upper()}</span></td>
|
|
<td><code>{html.escape(record)}</code></td>
|
|
<td>{detail}</td>
|
|
</tr>
|
|
""")
|
|
|
|
html_parts.append("</table>")
|
|
else:
|
|
html_parts.append("<div class='no-drift'>No drift detected. Configuration is in sync.</div>")
|
|
|
|
html_parts.append("</body></html>")
|
|
return "\n".join(html_parts)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Drift Visualizer")
|
|
parser.add_argument("--snapshot", help="Path to state snapshot JSON")
|
|
parser.add_argument("--manifest", help="Path to DNS manifest JSON/YAML")
|
|
parser.add_argument("--output-dir", default=OUTPUT_DIR, help="Output directory")
|
|
parser.add_argument("--format", choices=["json", "html", "both"], default="both",
|
|
help="Output format")
|
|
args = parser.parse_args()
|
|
|
|
# Load files
|
|
snapshot = None
|
|
manifest = None
|
|
|
|
if args.snapshot:
|
|
with open(args.snapshot) as f:
|
|
snapshot = json.load(f)
|
|
|
|
if args.manifest:
|
|
with open(args.manifest) as f:
|
|
manifest = json.load(f)
|
|
|
|
if not snapshot and not manifest:
|
|
print("Error: At least one of --snapshot or --manifest required")
|
|
return 1
|
|
|
|
# Ensure output directory
|
|
os.makedirs(args.output_dir, exist_ok=True)
|
|
|
|
# Run analysis
|
|
analyzer = DriftAnalyzer()
|
|
analysis = analyzer.analyze(snapshot=snapshot, manifest=manifest)
|
|
|
|
# Output
|
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
|
|
|
|
if args.format in ("json", "both"):
|
|
json_path = os.path.join(args.output_dir, f"drift-report-{timestamp}.json")
|
|
with open(json_path, "w") as f:
|
|
json.dump(analysis, f, indent=2)
|
|
print(f"JSON report: {json_path}")
|
|
|
|
if args.format in ("html", "both"):
|
|
html_content = generate_html_report(analysis)
|
|
html_path = os.path.join(args.output_dir, f"drift-report-{timestamp}.html")
|
|
with open(html_path, "w") as f:
|
|
f.write(html_content)
|
|
print(f"HTML report: {html_path}")
|
|
|
|
# Summary
|
|
summary = analysis.get("summary", {})
|
|
print(f"\nDrift Summary:")
|
|
print(f" Total diffs: {summary.get('total_diffs', 0)}")
|
|
print(f" High: {summary.get('high_severity', 0)}")
|
|
print(f" Medium: {summary.get('medium_severity', 0)}")
|
|
print(f" Low: {summary.get('low_severity', 0)}")
|
|
|
|
return 0 if summary.get("total_diffs", 0) == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
exit(main())
|