#!/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 --manifest --output """ 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 = """ """ # Header html_parts = [ "", "", "", "Cloudflare Drift Report", css, "", "

Cloudflare Drift Report

", f"

Generated: {timestamp}

", ] # Summary cards html_parts.append("
") html_parts.append(f"""

Total Diffs

{summary.get("total_diffs", 0)}
""") html_parts.append(f"""

High Severity

{summary.get("high_severity", 0)}
""") html_parts.append(f"""

Medium Severity

{summary.get("medium_severity", 0)}
""") html_parts.append(f"""

Low Severity

{summary.get("low_severity", 0)}
""") html_parts.append("
") # Diffs table if diffs: html_parts.append("

Drift Details

") html_parts.append("") html_parts.append(""" """) 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""" """) html_parts.append("
Type Severity Record Detail
{dtype} {severity.upper()} {html.escape(record)} {detail}
") else: html_parts.append("
No drift detected. Configuration is in sync.
") html_parts.append("") 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())