Initial commit: Cloudflare infrastructure with WAF Intelligence
- 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
This commit is contained in:
209
scripts/anchor-cloudflare-state.sh
Executable file
209
scripts/anchor-cloudflare-state.sh
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Cloudflare State Anchor
|
||||
# Orchestrates state reconciliation, invariant checking, and ProofChain anchoring.
|
||||
#
|
||||
# Usage:
|
||||
# ./anchor-cloudflare-state.sh [--zone-id ZONE_ID] [--account-id ACCOUNT_ID]
|
||||
#
|
||||
# Environment Variables:
|
||||
# CLOUDFLARE_API_TOKEN - Required
|
||||
# CLOUDFLARE_ZONE_ID - Zone ID (or use --zone-id)
|
||||
# CLOUDFLARE_ACCOUNT_ID - Account ID (or use --account-id)
|
||||
# VAULTMESH_ANCHORS_PATH - Path to ProofChain anchors file (optional)
|
||||
#
|
||||
# Exit Codes:
|
||||
# 0 - Success, all invariants passed
|
||||
# 1 - Success, but invariants failed (anomalies detected)
|
||||
# 2 - Error during execution
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BASE_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
SNAPSHOTS_DIR="${BASE_DIR}/snapshots"
|
||||
RECEIPTS_DIR="${BASE_DIR}/receipts"
|
||||
ANOMALIES_DIR="${BASE_DIR}/anomalies"
|
||||
ANCHORS_PATH="${VAULTMESH_ANCHORS_PATH:-${BASE_DIR}/proofchain-anchors.jsonl}"
|
||||
|
||||
# Parse arguments
|
||||
ZONE_ID="${CLOUDFLARE_ZONE_ID:-}"
|
||||
ACCOUNT_ID="${CLOUDFLARE_ACCOUNT_ID:-}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--zone-id)
|
||||
ZONE_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--account-id)
|
||||
ACCOUNT_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate
|
||||
if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then
|
||||
echo "Error: CLOUDFLARE_API_TOKEN environment variable required"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -z "$ZONE_ID" ]]; then
|
||||
echo "Error: Zone ID required (--zone-id or CLOUDFLARE_ZONE_ID)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -z "$ACCOUNT_ID" ]]; then
|
||||
echo "Error: Account ID required (--account-id or CLOUDFLARE_ACCOUNT_ID)"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Ensure directories exist
|
||||
mkdir -p "$SNAPSHOTS_DIR" "$RECEIPTS_DIR" "$ANOMALIES_DIR"
|
||||
|
||||
# Timestamp for this run
|
||||
TIMESTAMP=$(date -u +%Y-%m-%dT%H-%M-%SZ)
|
||||
|
||||
echo "======================================"
|
||||
echo "Cloudflare State Anchor"
|
||||
echo "======================================"
|
||||
echo "Timestamp: $TIMESTAMP"
|
||||
echo "Zone ID: $ZONE_ID"
|
||||
echo "Account ID: $ACCOUNT_ID"
|
||||
echo ""
|
||||
|
||||
# Step 1: Run State Reconciler
|
||||
echo ">>> Step 1: Fetching Cloudflare state..."
|
||||
python3 "${SCRIPT_DIR}/state-reconciler.py" \
|
||||
--zone-id "$ZONE_ID" \
|
||||
--account-id "$ACCOUNT_ID" \
|
||||
--output-dir "$SNAPSHOTS_DIR" \
|
||||
--receipt-dir "$RECEIPTS_DIR"
|
||||
|
||||
# Find the latest snapshot
|
||||
LATEST_SNAPSHOT=$(ls -t "${SNAPSHOTS_DIR}"/cloudflare-*.json 2>/dev/null | head -1)
|
||||
if [[ -z "$LATEST_SNAPSHOT" ]]; then
|
||||
echo "Error: No snapshot found"
|
||||
exit 2
|
||||
fi
|
||||
echo "Snapshot: $LATEST_SNAPSHOT"
|
||||
|
||||
# Extract Merkle root from snapshot
|
||||
MERKLE_ROOT=$(python3 -c "
|
||||
import json
|
||||
with open('$LATEST_SNAPSHOT') as f:
|
||||
data = json.load(f)
|
||||
print(data['integrity']['merkle_root'])
|
||||
")
|
||||
echo "Merkle Root: $MERKLE_ROOT"
|
||||
echo ""
|
||||
|
||||
# Step 2: Run Invariant Checker
|
||||
echo ">>> Step 2: Checking invariants..."
|
||||
INVARIANT_EXIT=0
|
||||
python3 "${SCRIPT_DIR}/invariant-checker.py" \
|
||||
--snapshot "$LATEST_SNAPSHOT" \
|
||||
--output-dir "$ANOMALIES_DIR" || INVARIANT_EXIT=$?
|
||||
|
||||
# Find latest report
|
||||
LATEST_REPORT=$(ls -t "${ANOMALIES_DIR}"/invariant-report-*.json 2>/dev/null | head -1)
|
||||
echo "Invariant Report: $LATEST_REPORT"
|
||||
echo ""
|
||||
|
||||
# Extract summary
|
||||
if [[ -n "$LATEST_REPORT" ]]; then
|
||||
PASSED=$(python3 -c "import json; print(json.load(open('$LATEST_REPORT'))['summary']['passed'])")
|
||||
FAILED=$(python3 -c "import json; print(json.load(open('$LATEST_REPORT'))['summary']['failed'])")
|
||||
echo "Passed: $PASSED"
|
||||
echo "Failed: $FAILED"
|
||||
fi
|
||||
|
||||
# Step 3: Create ProofChain Anchor
|
||||
echo ""
|
||||
echo ">>> Step 3: Creating ProofChain anchor..."
|
||||
|
||||
# Compute combined hash
|
||||
COMBINED_HASH=$(cat "$LATEST_SNAPSHOT" "$LATEST_REPORT" 2>/dev/null | sha256sum | cut -d' ' -f1)
|
||||
|
||||
# Create anchor JSON
|
||||
ANCHOR_JSON=$(cat <<EOF
|
||||
{
|
||||
"anchor_type": "cf_state_anchor",
|
||||
"schema_version": "vm_anchor_v1",
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"zone_id": "$ZONE_ID",
|
||||
"account_id": "$ACCOUNT_ID",
|
||||
"snapshot_path": "$LATEST_SNAPSHOT",
|
||||
"report_path": "$LATEST_REPORT",
|
||||
"merkle_root": "$MERKLE_ROOT",
|
||||
"combined_hash": "$COMBINED_HASH",
|
||||
"invariants_passed": $PASSED,
|
||||
"invariants_failed": $FAILED,
|
||||
"status": "$([ $INVARIANT_EXIT -eq 0 ] && echo 'clean' || echo 'anomalies_detected')"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Append to anchors file
|
||||
echo "$ANCHOR_JSON" >> "$ANCHORS_PATH"
|
||||
echo "Anchor appended to: $ANCHORS_PATH"
|
||||
|
||||
# Step 4: Create combined receipt
|
||||
echo ""
|
||||
echo ">>> Step 4: Creating combined receipt..."
|
||||
|
||||
RECEIPT_PATH="${RECEIPTS_DIR}/cf-anchor-${TIMESTAMP}.json"
|
||||
cat > "$RECEIPT_PATH" <<EOF
|
||||
{
|
||||
"receipt_type": "cf_state_anchor_complete",
|
||||
"schema_version": "vm_cf_anchor_v1",
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"zone_id": "$ZONE_ID",
|
||||
"account_id": "$ACCOUNT_ID",
|
||||
"artifacts": {
|
||||
"snapshot": "$LATEST_SNAPSHOT",
|
||||
"invariant_report": "$LATEST_REPORT",
|
||||
"anchors_file": "$ANCHORS_PATH"
|
||||
},
|
||||
"integrity": {
|
||||
"merkle_root": "$MERKLE_ROOT",
|
||||
"combined_hash": "$COMBINED_HASH",
|
||||
"hash_algorithm": "sha256"
|
||||
},
|
||||
"invariants": {
|
||||
"passed": $PASSED,
|
||||
"failed": $FAILED,
|
||||
"status": "$([ $INVARIANT_EXIT -eq 0 ] && echo 'all_passed' || echo 'failures_detected')"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
echo "Receipt: $RECEIPT_PATH"
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "Anchor Complete"
|
||||
echo "======================================"
|
||||
echo "Snapshot: $LATEST_SNAPSHOT"
|
||||
echo "Report: $LATEST_REPORT"
|
||||
echo "Receipt: $RECEIPT_PATH"
|
||||
echo "Merkle Root: $MERKLE_ROOT"
|
||||
echo "Status: $([ $INVARIANT_EXIT -eq 0 ] && echo 'CLEAN' || echo 'ANOMALIES DETECTED')"
|
||||
echo "======================================"
|
||||
|
||||
# Output for CI pipelines
|
||||
echo ""
|
||||
echo "CI_MERKLE_ROOT=$MERKLE_ROOT"
|
||||
echo "CI_SNAPSHOT_PATH=$LATEST_SNAPSHOT"
|
||||
echo "CI_REPORT_PATH=$LATEST_REPORT"
|
||||
echo "CI_RECEIPT_PATH=$RECEIPT_PATH"
|
||||
echo "CI_INVARIANTS_STATUS=$([ $INVARIANT_EXIT -eq 0 ] && echo 'passed' || echo 'failed')"
|
||||
|
||||
# Exit with invariant check result
|
||||
exit $INVARIANT_EXIT
|
||||
173
scripts/autonomous_remediator_py.py
Normal file
173
scripts/autonomous_remediator_py.py
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Autonomous Remediator — Cloudflare Autonomic Mesh Engine
|
||||
Pure technical (D1) implementation
|
||||
|
||||
Runs continuously (systemd service) and performs:
|
||||
- DNS auto-remediation (re-proxy, restore records)
|
||||
- WAF baseline enforcement
|
||||
- Access policy enforcement (MFA, no bypass)
|
||||
- Tunnel health remediation (restart, rekey optional)
|
||||
- Drift correction using Terraform
|
||||
|
||||
Outputs VaultMesh receipts for each correction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import subprocess
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
|
||||
CF_API = "https://api.cloudflare.com/client/v4"
|
||||
CF_TOKEN = os.getenv("CF_API_TOKEN")
|
||||
CF_ACCOUNT = os.getenv("CF_ACCOUNT_ID")
|
||||
TF_DIR = os.getenv("TF_DIR", "./terraform")
|
||||
RECEIPT_DIR = os.getenv("VM_RECEIPT_DIR", "./receipts")
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {CF_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
os.makedirs(RECEIPT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def cf(endpoint, method="GET", data=None):
|
||||
url = f"{CF_API}{endpoint}"
|
||||
if method == "GET":
|
||||
r = requests.get(url, headers=HEADERS)
|
||||
else:
|
||||
r = requests.request(method, url, headers=HEADERS, json=data)
|
||||
r.raise_for_status()
|
||||
return r.json().get("result", {})
|
||||
|
||||
|
||||
def emit_receipt(action, details):
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
path = f"{RECEIPT_DIR}/auto-{action}-{ts}.json"
|
||||
with open(path, "w") as f:
|
||||
json.dump({"ts": ts, "action": action, "details": details}, f, indent=2)
|
||||
print(f"[REMEDIATOR] Receipt emitted: {path}")
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# DNS Remediation
|
||||
# ------------------------------
|
||||
|
||||
def fix_dns():
|
||||
zones = cf("/zones")
|
||||
for z in zones:
|
||||
zid = z["id"]
|
||||
zname = z["name"]
|
||||
recs = cf(f"/zones/{zid}/dns_records")
|
||||
|
||||
for r in recs:
|
||||
# Re-proxy unproxied A/AAAA
|
||||
if r["type"] in ("A", "AAAA") and not r.get("proxied"):
|
||||
print(f"[DNS] Re-proxying {r['name']} in {zname}")
|
||||
cf(f"/zones/{zid}/dns_records/{r['id']}", method="PUT",
|
||||
data={"type": r["type"], "name": r["name"], "content": r["content"], "proxied": True})
|
||||
emit_receipt("dns_reproxy", {"zone": zname, "record": r})
|
||||
|
||||
# Enforce DNSSEC
|
||||
dnssec = cf(f"/zones/{zid}/dnssec")
|
||||
if dnssec.get("status") != "active":
|
||||
print(f"[DNS] Enabling DNSSEC for {zname}")
|
||||
cf(f"/zones/{zid}/dnssec", method="PATCH", data={"status": "active"})
|
||||
emit_receipt("dnssec_enable", {"zone": zname})
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# WAF Enforcement
|
||||
# ------------------------------
|
||||
|
||||
def enforce_waf():
|
||||
zones = cf("/zones")
|
||||
for z in zones:
|
||||
zid = z["id"]
|
||||
zname = z["name"]
|
||||
pkgs = cf(f"/zones/{zid}/firewall/waf/packages")
|
||||
|
||||
# Ensure OWASP ruleset is present
|
||||
if not any("owasp" in p.get("name", "").lower() for p in pkgs):
|
||||
emit_receipt("missing_owasp", {"zone": zname})
|
||||
print(f"[WAF] Missing OWASP ruleset in {zname}")
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Access Policy Enforcement
|
||||
# ------------------------------
|
||||
|
||||
def enforce_access():
|
||||
policies = cf(f"/accounts/{CF_ACCOUNT}/access/policies")
|
||||
for p in policies:
|
||||
changed = False
|
||||
pid = p["id"]
|
||||
|
||||
# Enforce MFA requirement
|
||||
for rule in p.get("rules", []):
|
||||
if not rule.get("require_mfa"):
|
||||
rule["require_mfa"] = True
|
||||
changed = True
|
||||
|
||||
# No bypass allowed
|
||||
if p.get("decision") == "bypass":
|
||||
p["decision"] = "allow"
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
print(f"[ACCESS] Correcting policy {pid}")
|
||||
cf(f"/accounts/{CF_ACCOUNT}/access/policies/{pid}", method="PUT", data=p)
|
||||
emit_receipt("access_policy_fix", {"policy_id": pid})
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Tunnel Health Remediation
|
||||
# ------------------------------
|
||||
|
||||
def fix_tunnels():
|
||||
tunnels = cf(f"/accounts/{CF_ACCOUNT}/cfd_tunnel")
|
||||
for t in tunnels:
|
||||
if t.get("status") in ("degraded", "reconnecting", "down"):
|
||||
tid = t["id"]
|
||||
print(f"[TUNNEL] Restart recommended for {tid}")
|
||||
# Informational only — actual restart is manual or via systemd
|
||||
emit_receipt("tunnel_unhealthy", t)
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Terraform Drift Correction
|
||||
# ------------------------------
|
||||
|
||||
def correct_terraform_drift():
|
||||
print("[TF] Running terraform plan to detect drift...")
|
||||
proc = subprocess.run(["terraform", "-chdir", TF_DIR, "plan"], capture_output=True, text=True)
|
||||
|
||||
if "No changes" not in proc.stdout:
|
||||
print("[TF] Drift detected — applying corrective action")
|
||||
subprocess.run(["terraform", "-chdir", TF_DIR, "apply", "-auto-approve"])
|
||||
emit_receipt("terraform_drift_fix", {"output": proc.stdout})
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Main Loop
|
||||
# ------------------------------
|
||||
|
||||
def main():
|
||||
print("[REMEDIATOR] Autonomic Mesh running...")
|
||||
|
||||
while True:
|
||||
fix_dns()
|
||||
enforce_waf()
|
||||
enforce_access()
|
||||
fix_tunnels()
|
||||
correct_terraform_drift()
|
||||
|
||||
print("[REMEDIATOR] Cycle complete. Sleeping 5 minutes...")
|
||||
time.sleep(300)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
259
scripts/doc-invariants.sh
Executable file
259
scripts/doc-invariants.sh
Executable file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# DOC INVARIANTS CHECKER
|
||||
# ============================================================================
|
||||
# Enforces documentation law for VaultMesh.
|
||||
# Run from repo root: bash scripts/doc-invariants.sh
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = All invariants pass
|
||||
# 1 = One or more invariants violated
|
||||
#
|
||||
# Governed by: RED-BOOK.md
|
||||
# ============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
FAILED=0
|
||||
PASSED=0
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
pass() {
|
||||
echo -e "${GREEN}✓${NC} $1"
|
||||
((PASSED++))
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo -e "${RED}✗${NC} $1"
|
||||
((FAILED++))
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}⚠${NC} $1"
|
||||
}
|
||||
|
||||
echo "============================================"
|
||||
echo " VaultMesh Documentation Invariants Check"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# 1. STRUCTURAL INVARIANTS
|
||||
# ============================================================================
|
||||
|
||||
echo "── 1. Structural Invariants ──"
|
||||
|
||||
# 1.1 Single Front Door
|
||||
if [[ -f "README.md" ]]; then
|
||||
if grep -q "STRUCTURE.md" README.md && grep -q "FIRST_RUN.md\|DEPLOYMENT_GUIDE.md" README.md; then
|
||||
pass "1.1 Single Front Door: README.md exists and links to STRUCTURE.md + guides"
|
||||
else
|
||||
fail "1.1 Single Front Door: README.md missing links to STRUCTURE.md or guides"
|
||||
fi
|
||||
else
|
||||
fail "1.1 Single Front Door: README.md does not exist"
|
||||
fi
|
||||
|
||||
# 1.2 Single Index
|
||||
if [[ -f "STRUCTURE.md" ]]; then
|
||||
pass "1.2 Single Index: STRUCTURE.md exists"
|
||||
else
|
||||
fail "1.2 Single Index: STRUCTURE.md does not exist"
|
||||
fi
|
||||
|
||||
# Check for competing indexes (forbidden patterns)
|
||||
COMPETING_INDEXES=$(find . -maxdepth 1 -name "README_STRUCTURE*.md" -o -name "INDEX*.md" 2>/dev/null | grep -v archive_docs || true)
|
||||
if [[ -z "$COMPETING_INDEXES" ]]; then
|
||||
pass "1.2 Single Index: No competing index files found"
|
||||
else
|
||||
fail "1.2 Single Index: Found competing index files: $COMPETING_INDEXES"
|
||||
fi
|
||||
|
||||
# 1.3 Archive Boundary
|
||||
if [[ -d "archive_docs" ]]; then
|
||||
pass "1.3 Archive Boundary: archive_docs/ directory exists"
|
||||
else
|
||||
warn "1.3 Archive Boundary: archive_docs/ directory does not exist (optional)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# 2. CONTENT INVARIANTS
|
||||
# ============================================================================
|
||||
|
||||
echo "── 2. Content Invariants ──"
|
||||
|
||||
# 2.1 Multi-Account Single Source of Truth
|
||||
if [[ -f "MULTI_ACCOUNT_AUTH.md" ]]; then
|
||||
pass "2.1 Multi-Account SSOT: MULTI_ACCOUNT_AUTH.md exists"
|
||||
else
|
||||
fail "2.1 Multi-Account SSOT: MULTI_ACCOUNT_AUTH.md does not exist"
|
||||
fi
|
||||
|
||||
# 2.2 One Doctrine
|
||||
if [[ -f "RED-BOOK.md" ]]; then
|
||||
pass "2.2 One Doctrine: RED-BOOK.md exists"
|
||||
else
|
||||
fail "2.2 One Doctrine: RED-BOOK.md does not exist"
|
||||
fi
|
||||
|
||||
# 2.3 Playbooks Own Incidents
|
||||
REQUIRED_PLAYBOOKS=(
|
||||
"playbooks/DNS-COMPROMISE-PLAYBOOK.md"
|
||||
"playbooks/TUNNEL-ROTATION-PROTOCOL.md"
|
||||
"playbooks/waf_incident_playbook.md"
|
||||
)
|
||||
|
||||
ALL_PLAYBOOKS_EXIST=true
|
||||
for pb in "${REQUIRED_PLAYBOOKS[@]}"; do
|
||||
if [[ ! -f "$pb" ]]; then
|
||||
fail "2.3 Playbooks: Missing $pb"
|
||||
ALL_PLAYBOOKS_EXIST=false
|
||||
fi
|
||||
done
|
||||
|
||||
if $ALL_PLAYBOOKS_EXIST; then
|
||||
pass "2.3 Playbooks: All required playbooks exist"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# 3. LINK & REFERENCE INVARIANTS
|
||||
# ============================================================================
|
||||
|
||||
echo "── 3. Link & Reference Invariants ──"
|
||||
|
||||
# 3.1 No Dead Links in Active Space
|
||||
# Check for known deprecated filenames outside archive_docs/
|
||||
DEPRECATED_PATTERNS=(
|
||||
"dns_compromise_playbook\.md"
|
||||
"tunnel_rotation_protocol\.md"
|
||||
"ONE-PAGE-SECURITY-SHEET\.md"
|
||||
"README_STRUCTURE\.md"
|
||||
)
|
||||
|
||||
DEAD_LINK_FOUND=false
|
||||
for pattern in "${DEPRECATED_PATTERNS[@]}"; do
|
||||
# Search for pattern, excluding archive_docs/
|
||||
HITS=$(grep -r "$pattern" . --include="*.md" --include="*.yml" --include="*.yaml" --include="*.py" 2>/dev/null | grep -v "archive_docs/" | grep -v "doc-invariants.sh" || true)
|
||||
if [[ -n "$HITS" ]]; then
|
||||
fail "3.1 Dead Links: Found deprecated reference '$pattern' outside archive_docs/"
|
||||
echo " $HITS" | head -3
|
||||
DEAD_LINK_FOUND=true
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $DEAD_LINK_FOUND; then
|
||||
pass "3.1 Dead Links: No deprecated references found in active space"
|
||||
fi
|
||||
|
||||
# 3.2 Case-Exact Playbook Paths
|
||||
# Check for WRONG casing - lowercase variants when they should be uppercase
|
||||
# DNS-COMPROMISE-PLAYBOOK.md should NOT appear as dns-compromise-playbook.md
|
||||
CASE_VIOLATIONS=$(grep -r "dns-compromise-playbook\.md\|dns_compromise_playbook\.md" . --include="*.md" --include="*.yml" --include="*.yaml" 2>/dev/null | grep -v archive_docs/ | grep -v "DNS-COMPROMISE-PLAYBOOK" || true)
|
||||
if [[ -z "$CASE_VIOLATIONS" ]]; then
|
||||
pass "3.2 Case-Exact Paths: Playbook references use correct casing"
|
||||
else
|
||||
fail "3.2 Case-Exact Paths: Found lowercase playbook references (should be UPPERCASE)"
|
||||
echo " $CASE_VIOLATIONS" | head -3
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# 4. COGNITIVE / AI LAYER INVARIANTS
|
||||
# ============================================================================
|
||||
|
||||
echo "── 4. Cognitive Layer Invariants ──"
|
||||
|
||||
# 4.1 Cognition ≈ Fourfold Work
|
||||
COGNITION_DOCS=("COGNITION_FLOW.md" "DEMO_COGNITION.md")
|
||||
for doc in "${COGNITION_DOCS[@]}"; do
|
||||
if [[ -f "$doc" ]]; then
|
||||
if grep -qi "RED-BOOK\|Fourfold Work\|Nigredo.*Albedo.*Citrinitas.*Rubedo" "$doc"; then
|
||||
pass "4.1 Cognition Doctrine: $doc references Red Book"
|
||||
else
|
||||
fail "4.1 Cognition Doctrine: $doc does not reference Red Book / Fourfold Work"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 4.2 Guardrails Reference Doctrine
|
||||
if [[ -f "AGENT_GUARDRAILS.md" ]]; then
|
||||
if grep -qi "RED-BOOK" "AGENT_GUARDRAILS.md"; then
|
||||
pass "4.2 Guardrails Doctrine: AGENT_GUARDRAILS.md references Red Book"
|
||||
else
|
||||
fail "4.2 Guardrails Doctrine: AGENT_GUARDRAILS.md does not reference Red Book"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# 5. PLAYBOOK REGISTRATION
|
||||
# ============================================================================
|
||||
|
||||
echo "── 5. Playbook Registration ──"
|
||||
|
||||
# Check that all playbooks are registered in STRUCTURE.md
|
||||
for pb in "${REQUIRED_PLAYBOOKS[@]}"; do
|
||||
pb_name=$(basename "$pb")
|
||||
if grep -q "$pb_name" STRUCTURE.md 2>/dev/null; then
|
||||
pass "5.1 Registration: $pb_name listed in STRUCTURE.md"
|
||||
else
|
||||
fail "5.1 Registration: $pb_name NOT listed in STRUCTURE.md"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# 6. TOP-LEVEL DOC REGISTRY
|
||||
# ============================================================================
|
||||
|
||||
echo "── 6. Doc Registry ──"
|
||||
|
||||
# Every top-level .md (except README.md, STRUCTURE.md, LICENSE) must be in STRUCTURE.md
|
||||
UNREGISTERED_DOCS=false
|
||||
for f in *.md; do
|
||||
[[ "$f" == "README.md" || "$f" == "STRUCTURE.md" || "$f" == "LICENSE.md" ]] && continue
|
||||
if ! grep -q "$f" STRUCTURE.md 2>/dev/null; then
|
||||
fail "6.1 Registry: $f not listed in STRUCTURE.md"
|
||||
UNREGISTERED_DOCS=true
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $UNREGISTERED_DOCS; then
|
||||
pass "6.1 Registry: All top-level docs are indexed in STRUCTURE.md"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================================
|
||||
# SUMMARY
|
||||
# ============================================================================
|
||||
|
||||
echo "============================================"
|
||||
echo " Summary"
|
||||
echo "============================================"
|
||||
echo -e " ${GREEN}Passed:${NC} $PASSED"
|
||||
echo -e " ${RED}Failed:${NC} $FAILED"
|
||||
echo ""
|
||||
|
||||
if [[ $FAILED -gt 0 ]]; then
|
||||
echo -e "${RED}Doc invariants violated. Fix before merging.${NC}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}All doc invariants pass. ✓${NC}"
|
||||
exit 0
|
||||
fi
|
||||
208
scripts/drift_guardian_py.py
Normal file
208
scripts/drift_guardian_py.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Drift Guardian — Real-Time Cloudflare Drift Detection
|
||||
Pure technical (D1)
|
||||
|
||||
Purpose:
|
||||
• Poll Cloudflare state at short intervals
|
||||
• Compare live state → latest snapshot → invariants
|
||||
• Detect unauthorized modifications
|
||||
• Trigger remediation (optional hook)
|
||||
• Emit VaultMesh anomaly receipts
|
||||
|
||||
The Guardian = fast, reactive layer.
|
||||
The Remediator = corrective, authoritative layer.
|
||||
The Reconciler = canonical truth layer.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
|
||||
CF_API = "https://api.cloudflare.com/client/v4"
|
||||
CF_TOKEN = os.getenv("CF_API_TOKEN")
|
||||
CF_ACCOUNT = os.getenv("CF_ACCOUNT_ID")
|
||||
STATE_ROOT = os.getenv("VM_STATE_ROOT", "./cloudflare_state")
|
||||
SNAP_DIR = f"{STATE_ROOT}/snapshots"
|
||||
RECEIPT_DIR = f"{STATE_ROOT}/receipts"
|
||||
ANOM_DIR = f"{STATE_ROOT}/anomalies"
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {CF_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
os.makedirs(RECEIPT_DIR, exist_ok=True)
|
||||
os.makedirs(ANOM_DIR, exist_ok=True)
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Helpers
|
||||
# -----------------------------
|
||||
|
||||
def cf(endpoint):
|
||||
r = requests.get(f"{CF_API}{endpoint}", headers=HEADERS)
|
||||
r.raise_for_status()
|
||||
return r.json().get("result", {})
|
||||
|
||||
|
||||
def load_latest_snapshot():
|
||||
snaps = sorted(os.listdir(SNAP_DIR))
|
||||
if not snaps:
|
||||
return None
|
||||
latest = snaps[-1]
|
||||
with open(f"{SNAP_DIR}/{latest}") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def emit_anomaly(event_type, details):
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
anomaly = {"ts": ts, "event_type": event_type, "details": details}
|
||||
h = hashlib.sha256(json.dumps(anomaly, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
file_path = f"{ANOM_DIR}/drift-{ts}-{h[:8]}.json"
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(anomaly, f, indent=2)
|
||||
|
||||
print(f"[GUARDIAN] Drift detected → {file_path}")
|
||||
return file_path
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Drift Detection Logic
|
||||
# -----------------------------
|
||||
|
||||
def detect_dns_drift(snapshot):
|
||||
anomalies = []
|
||||
zones_live = cf("/zones")
|
||||
|
||||
# index snapshot zones by name
|
||||
snap_zones = {z["name"]: z for z in snapshot.get("zones", [])}
|
||||
|
||||
for z in zones_live:
|
||||
name = z["name"]
|
||||
zid = z["id"]
|
||||
|
||||
if name not in snap_zones:
|
||||
anomalies.append({"type": "zone_added", "zone": name})
|
||||
continue
|
||||
|
||||
# DNS record diff
|
||||
live_recs = cf(f"/zones/{zid}/dns_records")
|
||||
snap_recs = snapshot.get("dns", {}).get(name, [])
|
||||
|
||||
live_set = {(r["type"], r["name"], r.get("content")) for r in live_recs}
|
||||
snap_set = {(r["type"], r["name"], r.get("content")) for r in snap_recs}
|
||||
|
||||
added = live_set - snap_set
|
||||
removed = snap_set - live_set
|
||||
|
||||
if added:
|
||||
anomalies.append({"type": "dns_added", "zone": name, "records": list(added)})
|
||||
if removed:
|
||||
anomalies.append({"type": "dns_removed", "zone": name, "records": list(removed)})
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def detect_waf_drift(snapshot):
|
||||
anomalies = []
|
||||
zones_live = cf("/zones")
|
||||
snap_waf = snapshot.get("waf", {})
|
||||
|
||||
for z in zones_live:
|
||||
zname = z["name"]
|
||||
zid = z["id"]
|
||||
|
||||
live_pkgs = cf(f"/zones/{zid}/firewall/waf/packages")
|
||||
snap_pkgs = snap_waf.get(zname, [])
|
||||
|
||||
live_names = {p.get("name") for p in live_pkgs}
|
||||
snap_names = {p.get("name") for p in snap_pkgs}
|
||||
|
||||
if live_names != snap_names:
|
||||
anomalies.append({
|
||||
"type": "waf_ruleset_drift",
|
||||
"zone": zname,
|
||||
"expected": list(snap_names),
|
||||
"found": list(live_names)
|
||||
})
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def detect_access_drift(snapshot):
|
||||
anomalies = []
|
||||
live_apps = cf(f"/accounts/{CF_ACCOUNT}/access/apps")
|
||||
snap_apps = snapshot.get("access_apps", [])
|
||||
|
||||
live_set = {(a.get("name"), a.get("type")) for a in live_apps}
|
||||
snap_set = {(a.get("name"), a.get("type")) for a in snap_apps}
|
||||
|
||||
if live_set != snap_set:
|
||||
anomalies.append({
|
||||
"type": "access_app_drift",
|
||||
"expected": list(snap_set),
|
||||
"found": list(live_set)
|
||||
})
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def detect_tunnel_drift(snapshot):
|
||||
anomalies = []
|
||||
live = cf(f"/accounts/{CF_ACCOUNT}/cfd_tunnel")
|
||||
snap = snapshot.get("tunnels", [])
|
||||
|
||||
live_ids = {t.get("id") for t in live}
|
||||
snap_ids = {t.get("id") for t in snap}
|
||||
|
||||
if live_ids != snap_ids:
|
||||
anomalies.append({
|
||||
"type": "tunnel_id_drift",
|
||||
"expected": list(snap_ids),
|
||||
"found": list(live_ids)
|
||||
})
|
||||
|
||||
# health drift
|
||||
for t in live:
|
||||
if t.get("status") not in ("active", "healthy"):
|
||||
anomalies.append({"type": "tunnel_unhealthy", "tunnel": t})
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Main Guardian Loop
|
||||
# -----------------------------
|
||||
|
||||
def main():
|
||||
print("[GUARDIAN] Drift Guardian active…")
|
||||
|
||||
while True:
|
||||
snapshot = load_latest_snapshot()
|
||||
if not snapshot:
|
||||
print("[GUARDIAN] No snapshot found — run state-reconciler first.")
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
anomalies = []
|
||||
anomalies += detect_dns_drift(snapshot)
|
||||
anomalies += detect_waf_drift(snapshot)
|
||||
anomalies += detect_access_drift(snapshot)
|
||||
anomalies += detect_tunnel_drift(snapshot)
|
||||
|
||||
if anomalies:
|
||||
for a in anomalies:
|
||||
emit_anomaly(a.get("type"), a)
|
||||
else:
|
||||
print("[GUARDIAN] No drift detected.")
|
||||
|
||||
time.sleep(120) # check every 2 minutes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
101
scripts/infra-invariants.sh
Executable file
101
scripts/infra-invariants.sh
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# INFRA INVARIANTS CHECKER
|
||||
# ============================================================================
|
||||
# Enforces infrastructure law for VaultMesh.
|
||||
# Run from repo root: bash scripts/infra-invariants.sh
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = All invariants pass
|
||||
# 1 = One or more invariants violated
|
||||
#
|
||||
# Governed by: RED-BOOK.md
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "============================================"
|
||||
echo " VaultMesh Infrastructure Invariants Check"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
FAILED=0
|
||||
|
||||
# ============================================================================
|
||||
# 1. TERRAFORM FORMAT CHECK
|
||||
# ============================================================================
|
||||
|
||||
echo "── 1. Terraform Formatting ──"
|
||||
|
||||
cd terraform
|
||||
if terraform fmt -check -recursive > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓${NC} 1.1 All .tf files are properly formatted"
|
||||
else
|
||||
echo -e "${RED}✗${NC} 1.1 Terraform files need formatting"
|
||||
echo " Run: cd terraform && terraform fmt -recursive"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# 2. TERRAFORM VALIDATE
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "── 2. Terraform Validation ──"
|
||||
|
||||
terraform init -backend=false > /dev/null 2>&1
|
||||
if terraform validate > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓${NC} 2.1 Terraform configuration is valid"
|
||||
else
|
||||
echo -e "${RED}✗${NC} 2.1 Terraform validation failed"
|
||||
terraform validate
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# ============================================================================
|
||||
# 3. REQUIRED FILES
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "── 3. Required Terraform Files ──"
|
||||
|
||||
REQUIRED_TF_FILES=(
|
||||
"terraform/main.tf"
|
||||
"terraform/variables.tf"
|
||||
)
|
||||
|
||||
for tf in "${REQUIRED_TF_FILES[@]}"; do
|
||||
if [[ -f "$tf" ]]; then
|
||||
echo -e "${GREEN}✓${NC} 3.1 $tf exists"
|
||||
else
|
||||
echo -e "${RED}✗${NC} 3.1 Missing required file: $tf"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
# ============================================================================
|
||||
# SUMMARY
|
||||
# ============================================================================
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Summary"
|
||||
echo "============================================"
|
||||
|
||||
if [[ $FAILED -gt 0 ]]; then
|
||||
echo -e "${RED}Infra invariants violated. Fix before merging.${NC}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}All infra invariants pass. ✓${NC}"
|
||||
exit 0
|
||||
fi
|
||||
427
scripts/invariant-checker.py
Normal file
427
scripts/invariant-checker.py
Normal file
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cloudflare Invariant Checker
|
||||
Tests state snapshots against defined invariants and produces anomaly reports.
|
||||
|
||||
Usage:
|
||||
python3 invariant-checker.py --snapshot <path/to/snapshot.json>
|
||||
|
||||
Environment Variables:
|
||||
MANIFEST_PATH - Path to DNS manifest (optional)
|
||||
TERRAFORM_STATE_PATH - Path to Terraform state (optional)
|
||||
|
||||
Output:
|
||||
- anomalies/invariant-report-<timestamp>.json
|
||||
- Exit code 0 if all pass, 1 if any fail
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
ANOMALY_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "anomalies")
|
||||
|
||||
|
||||
class InvariantResult:
|
||||
"""Result of an invariant check."""
|
||||
|
||||
def __init__(self, name: str, passed: bool, message: str, details: Optional[Dict] = None):
|
||||
self.name = name
|
||||
self.passed = passed
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"invariant": self.name,
|
||||
"passed": self.passed,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
class InvariantChecker:
|
||||
"""Checks Cloudflare state against defined invariants."""
|
||||
|
||||
def __init__(self, snapshot: Dict[str, Any], manifest: Optional[Dict] = None, tf_state: Optional[Dict] = None):
|
||||
self.snapshot = snapshot
|
||||
self.state = snapshot.get("state", {})
|
||||
self.manifest = manifest
|
||||
self.tf_state = tf_state
|
||||
self.results: List[InvariantResult] = []
|
||||
|
||||
def check_all(self) -> List[InvariantResult]:
|
||||
"""Run all invariant checks."""
|
||||
self._check_dns_invariants()
|
||||
self._check_waf_invariants()
|
||||
self._check_access_invariants()
|
||||
self._check_tunnel_invariants()
|
||||
self._check_zone_settings_invariants()
|
||||
if self.manifest:
|
||||
self._check_manifest_drift()
|
||||
return self.results
|
||||
|
||||
# === DNS Invariants ===
|
||||
|
||||
def _check_dns_invariants(self):
|
||||
"""Check DNS-related invariants."""
|
||||
dns = self.state.get("dns", {})
|
||||
records = dns.get("records", [])
|
||||
|
||||
# INV-DNS-001: No unproxied A/AAAA records (unless explicitly internal)
|
||||
unproxied = [
|
||||
r for r in records
|
||||
if r.get("type") in ("A", "AAAA")
|
||||
and not r.get("proxied", False)
|
||||
and not r.get("name", "").startswith("_") # Allow service records
|
||||
]
|
||||
self.results.append(InvariantResult(
|
||||
"INV-DNS-001",
|
||||
len(unproxied) == 0,
|
||||
f"No unproxied A/AAAA records" if len(unproxied) == 0 else f"Found {len(unproxied)} unproxied A/AAAA records",
|
||||
{"unproxied_records": [r.get("name") for r in unproxied]}
|
||||
))
|
||||
|
||||
# INV-DNS-002: DNSSEC must be enabled
|
||||
dnssec = dns.get("dnssec", {})
|
||||
dnssec_enabled = dnssec.get("status") == "active"
|
||||
self.results.append(InvariantResult(
|
||||
"INV-DNS-002",
|
||||
dnssec_enabled,
|
||||
"DNSSEC is active" if dnssec_enabled else "DNSSEC is not active",
|
||||
{"dnssec_status": dnssec.get("status")}
|
||||
))
|
||||
|
||||
# INV-DNS-003: SPF record must exist
|
||||
spf_records = [r for r in records if r.get("type") == "TXT" and "v=spf1" in r.get("content", "")]
|
||||
self.results.append(InvariantResult(
|
||||
"INV-DNS-003",
|
||||
len(spf_records) > 0,
|
||||
"SPF record exists" if len(spf_records) > 0 else "No SPF record found",
|
||||
{"spf_count": len(spf_records)}
|
||||
))
|
||||
|
||||
# INV-DNS-004: DMARC record must exist
|
||||
dmarc_records = [r for r in records if r.get("name", "").startswith("_dmarc") and r.get("type") == "TXT"]
|
||||
self.results.append(InvariantResult(
|
||||
"INV-DNS-004",
|
||||
len(dmarc_records) > 0,
|
||||
"DMARC record exists" if len(dmarc_records) > 0 else "No DMARC record found",
|
||||
{"dmarc_count": len(dmarc_records)}
|
||||
))
|
||||
|
||||
# INV-DNS-005: No wildcard records (unless explicitly allowed)
|
||||
wildcards = [r for r in records if "*" in r.get("name", "")]
|
||||
self.results.append(InvariantResult(
|
||||
"INV-DNS-005",
|
||||
len(wildcards) == 0,
|
||||
"No wildcard records" if len(wildcards) == 0 else f"Found {len(wildcards)} wildcard records",
|
||||
{"wildcard_records": [r.get("name") for r in wildcards]}
|
||||
))
|
||||
|
||||
# === WAF Invariants ===
|
||||
|
||||
def _check_waf_invariants(self):
|
||||
"""Check WAF-related invariants."""
|
||||
waf = self.state.get("waf", {})
|
||||
rulesets = waf.get("rulesets", [])
|
||||
|
||||
# INV-WAF-001: Managed ruleset must be enabled
|
||||
managed_rulesets = [rs for rs in rulesets if rs.get("kind") == "managed"]
|
||||
self.results.append(InvariantResult(
|
||||
"INV-WAF-001",
|
||||
len(managed_rulesets) > 0,
|
||||
"Managed WAF ruleset enabled" if len(managed_rulesets) > 0 else "No managed WAF ruleset found",
|
||||
{"managed_ruleset_count": len(managed_rulesets)}
|
||||
))
|
||||
|
||||
# INV-WAF-002: Firewall rules must exist
|
||||
firewall_rules = waf.get("firewall_rules", [])
|
||||
self.results.append(InvariantResult(
|
||||
"INV-WAF-002",
|
||||
len(firewall_rules) > 0,
|
||||
f"Found {len(firewall_rules)} firewall rules" if len(firewall_rules) > 0 else "No firewall rules configured",
|
||||
{"firewall_rule_count": len(firewall_rules)}
|
||||
))
|
||||
|
||||
# === Zone Settings Invariants ===
|
||||
|
||||
def _check_zone_settings_invariants(self):
|
||||
"""Check zone settings invariants."""
|
||||
settings = self.state.get("zone_settings", {})
|
||||
|
||||
# INV-ZONE-001: TLS must be strict
|
||||
ssl_mode = settings.get("ssl")
|
||||
self.results.append(InvariantResult(
|
||||
"INV-ZONE-001",
|
||||
ssl_mode in ("strict", "full_strict"),
|
||||
f"TLS mode is {ssl_mode}" if ssl_mode in ("strict", "full_strict") else f"TLS mode is {ssl_mode}, should be strict",
|
||||
{"ssl_mode": ssl_mode}
|
||||
))
|
||||
|
||||
# INV-ZONE-002: Minimum TLS version must be 1.2+
|
||||
min_tls = settings.get("min_tls_version")
|
||||
valid_tls = min_tls in ("1.2", "1.3")
|
||||
self.results.append(InvariantResult(
|
||||
"INV-ZONE-002",
|
||||
valid_tls,
|
||||
f"Minimum TLS version is {min_tls}" if valid_tls else f"Minimum TLS version is {min_tls}, should be 1.2+",
|
||||
{"min_tls_version": min_tls}
|
||||
))
|
||||
|
||||
# INV-ZONE-003: Always Use HTTPS must be on
|
||||
always_https = settings.get("always_use_https") == "on"
|
||||
self.results.append(InvariantResult(
|
||||
"INV-ZONE-003",
|
||||
always_https,
|
||||
"Always Use HTTPS is enabled" if always_https else "Always Use HTTPS is disabled",
|
||||
{"always_use_https": settings.get("always_use_https")}
|
||||
))
|
||||
|
||||
# INV-ZONE-004: Browser check must be on
|
||||
browser_check = settings.get("browser_check") == "on"
|
||||
self.results.append(InvariantResult(
|
||||
"INV-ZONE-004",
|
||||
browser_check,
|
||||
"Browser Check is enabled" if browser_check else "Browser Check is disabled",
|
||||
{"browser_check": settings.get("browser_check")}
|
||||
))
|
||||
|
||||
# === Access Invariants ===
|
||||
|
||||
def _check_access_invariants(self):
|
||||
"""Check Zero Trust Access invariants."""
|
||||
access = self.state.get("access", {})
|
||||
apps = access.get("apps", [])
|
||||
|
||||
# INV-ACCESS-001: All Access apps must have at least one policy
|
||||
apps_without_policies = [a for a in apps if len(a.get("policies", [])) == 0]
|
||||
self.results.append(InvariantResult(
|
||||
"INV-ACCESS-001",
|
||||
len(apps_without_policies) == 0,
|
||||
"All Access apps have policies" if len(apps_without_policies) == 0 else f"{len(apps_without_policies)} apps have no policies",
|
||||
{"apps_without_policies": [a.get("name") for a in apps_without_policies]}
|
||||
))
|
||||
|
||||
# INV-ACCESS-002: No Access app in bypass mode
|
||||
bypass_apps = [a for a in apps if any(
|
||||
p.get("decision") == "bypass" for p in a.get("policies", [])
|
||||
)]
|
||||
self.results.append(InvariantResult(
|
||||
"INV-ACCESS-002",
|
||||
len(bypass_apps) == 0,
|
||||
"No Access apps in bypass mode" if len(bypass_apps) == 0 else f"{len(bypass_apps)} apps have bypass policies",
|
||||
{"bypass_apps": [a.get("name") for a in bypass_apps]}
|
||||
))
|
||||
|
||||
# INV-ACCESS-003: Session duration should not exceed 24h
|
||||
long_session_apps = [
|
||||
a for a in apps
|
||||
if self._parse_duration(a.get("session_duration", "24h")) > 86400
|
||||
]
|
||||
self.results.append(InvariantResult(
|
||||
"INV-ACCESS-003",
|
||||
len(long_session_apps) == 0,
|
||||
"All sessions <= 24h" if len(long_session_apps) == 0 else f"{len(long_session_apps)} apps have sessions > 24h",
|
||||
{"long_session_apps": [a.get("name") for a in long_session_apps]}
|
||||
))
|
||||
|
||||
def _parse_duration(self, duration: str) -> int:
|
||||
"""Parse duration string to seconds."""
|
||||
if not duration:
|
||||
return 0
|
||||
try:
|
||||
if duration.endswith("h"):
|
||||
return int(duration[:-1]) * 3600
|
||||
elif duration.endswith("m"):
|
||||
return int(duration[:-1]) * 60
|
||||
elif duration.endswith("s"):
|
||||
return int(duration[:-1])
|
||||
else:
|
||||
return int(duration)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
# === Tunnel Invariants ===
|
||||
|
||||
def _check_tunnel_invariants(self):
|
||||
"""Check Cloudflare Tunnel invariants."""
|
||||
tunnels = self.state.get("tunnels", {})
|
||||
tunnel_list = tunnels.get("list", [])
|
||||
|
||||
# INV-TUN-001: All tunnels must be healthy (not deleted, has connections)
|
||||
active_tunnels = [t for t in tunnel_list if not t.get("deleted_at")]
|
||||
unhealthy = [
|
||||
t for t in active_tunnels
|
||||
if len(t.get("connections", [])) == 0
|
||||
]
|
||||
self.results.append(InvariantResult(
|
||||
"INV-TUN-001",
|
||||
len(unhealthy) == 0,
|
||||
f"All {len(active_tunnels)} tunnels healthy" if len(unhealthy) == 0 else f"{len(unhealthy)} tunnels have no connections",
|
||||
{"unhealthy_tunnels": [t.get("name") for t in unhealthy]}
|
||||
))
|
||||
|
||||
# INV-TUN-002: No stale/orphan tunnels (deleted but still present)
|
||||
deleted_tunnels = [t for t in tunnel_list if t.get("deleted_at")]
|
||||
self.results.append(InvariantResult(
|
||||
"INV-TUN-002",
|
||||
len(deleted_tunnels) == 0,
|
||||
"No stale tunnels" if len(deleted_tunnels) == 0 else f"{len(deleted_tunnels)} deleted tunnels still present",
|
||||
{"stale_tunnels": [t.get("name") for t in deleted_tunnels]}
|
||||
))
|
||||
|
||||
# === Manifest Drift ===
|
||||
|
||||
def _check_manifest_drift(self):
|
||||
"""Check for drift between live state and manifest."""
|
||||
if not self.manifest:
|
||||
return
|
||||
|
||||
dns = self.state.get("dns", {})
|
||||
records = dns.get("records", [])
|
||||
manifest_records = self.manifest.get("records", [])
|
||||
|
||||
# Build lookup maps
|
||||
live_map = {(r.get("type"), r.get("name")): r for r in records}
|
||||
manifest_map = {(r.get("type"), r.get("name")): r for r in manifest_records}
|
||||
|
||||
# Find drift
|
||||
missing_in_live = set(manifest_map.keys()) - set(live_map.keys())
|
||||
extra_in_live = set(live_map.keys()) - set(manifest_map.keys())
|
||||
|
||||
# INV-DRIFT-001: All manifest records must exist in live
|
||||
self.results.append(InvariantResult(
|
||||
"INV-DRIFT-001",
|
||||
len(missing_in_live) == 0,
|
||||
"All manifest records present" if len(missing_in_live) == 0 else f"{len(missing_in_live)} records missing from live",
|
||||
{"missing_records": list(missing_in_live)}
|
||||
))
|
||||
|
||||
# INV-DRIFT-002: No unexpected records in live
|
||||
self.results.append(InvariantResult(
|
||||
"INV-DRIFT-002",
|
||||
len(extra_in_live) == 0,
|
||||
"No unexpected records" if len(extra_in_live) == 0 else f"{len(extra_in_live)} unexpected records in live",
|
||||
{"extra_records": list(extra_in_live)}
|
||||
))
|
||||
|
||||
|
||||
def generate_report(results: List[InvariantResult], snapshot_path: str) -> Dict[str, Any]:
|
||||
"""Generate invariant check report."""
|
||||
passed = [r for r in results if r.passed]
|
||||
failed = [r for r in results if not r.passed]
|
||||
|
||||
return {
|
||||
"report_type": "invariant_check",
|
||||
"schema_version": "vm_invariant_v1",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"snapshot_path": snapshot_path,
|
||||
"summary": {
|
||||
"total": len(results),
|
||||
"passed": len(passed),
|
||||
"failed": len(failed),
|
||||
"pass_rate": len(passed) / len(results) if results else 0,
|
||||
},
|
||||
"results": [r.to_dict() for r in results],
|
||||
"failed_invariants": [r.to_dict() for r in failed],
|
||||
}
|
||||
|
||||
|
||||
def create_anomaly_receipt(failed: List[InvariantResult], snapshot_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Create VaultMesh anomaly receipt for failed invariants."""
|
||||
if not failed:
|
||||
return None
|
||||
|
||||
return {
|
||||
"receipt_type": "cf_invariant_anomaly",
|
||||
"schema_version": "vm_cf_anomaly_v1",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"snapshot_path": snapshot_path,
|
||||
"anomaly_count": len(failed),
|
||||
"anomalies": [
|
||||
{
|
||||
"invariant": r.name,
|
||||
"message": r.message,
|
||||
"details": r.details,
|
||||
}
|
||||
for r in failed
|
||||
],
|
||||
"severity": "CRITICAL" if any(r.name.startswith("INV-DNS-002") or r.name.startswith("INV-ZONE-001") for r in failed) else "WARNING",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Cloudflare Invariant Checker")
|
||||
parser.add_argument("--snapshot", required=True, help="Path to state snapshot JSON")
|
||||
parser.add_argument("--manifest", default=os.environ.get("MANIFEST_PATH"),
|
||||
help="Path to DNS manifest")
|
||||
parser.add_argument("--output-dir", default=ANOMALY_DIR,
|
||||
help="Output directory for reports")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load snapshot
|
||||
with open(args.snapshot) as f:
|
||||
snapshot = json.load(f)
|
||||
|
||||
# Load manifest if provided
|
||||
manifest = None
|
||||
if args.manifest and os.path.exists(args.manifest):
|
||||
with open(args.manifest) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Ensure output directory exists
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
|
||||
# Run checks
|
||||
print(f"Checking invariants for snapshot: {args.snapshot}")
|
||||
checker = InvariantChecker(snapshot, manifest)
|
||||
results = checker.check_all()
|
||||
|
||||
# Generate report
|
||||
report = generate_report(results, args.snapshot)
|
||||
|
||||
# Write report
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
|
||||
report_filename = f"invariant-report-{timestamp}.json"
|
||||
report_path = os.path.join(args.output_dir, report_filename)
|
||||
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(report, f, indent=2, sort_keys=True)
|
||||
|
||||
print(f"Report written to: {report_path}")
|
||||
|
||||
# Create anomaly receipt if failures
|
||||
failed = [r for r in results if not r.passed]
|
||||
if failed:
|
||||
anomaly_receipt = create_anomaly_receipt(failed, args.snapshot)
|
||||
anomaly_filename = f"anomaly-{timestamp}.json"
|
||||
anomaly_path = os.path.join(args.output_dir, anomaly_filename)
|
||||
|
||||
with open(anomaly_path, "w") as f:
|
||||
json.dump(anomaly_receipt, f, indent=2, sort_keys=True)
|
||||
|
||||
print(f"Anomaly receipt written to: {anomaly_path}")
|
||||
|
||||
# Summary
|
||||
print("\n=== Invariant Check Summary ===")
|
||||
print(f"Total: {report['summary']['total']}")
|
||||
print(f"Passed: {report['summary']['passed']}")
|
||||
print(f"Failed: {report['summary']['failed']}")
|
||||
print(f"Pass Rate: {report['summary']['pass_rate']:.1%}")
|
||||
|
||||
if failed:
|
||||
print("\n=== Failed Invariants ===")
|
||||
for r in failed:
|
||||
print(f" [{r.name}] {r.message}")
|
||||
|
||||
# Exit with appropriate code
|
||||
return 0 if len(failed) == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
182
scripts/invariant_checker_py.py
Normal file
182
scripts/invariant_checker_py.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cloudflare Invariant Checker (Pure Technical)
|
||||
|
||||
Evaluates whether Cloudflare's live state satisfies required invariants:
|
||||
- DNS integrity (proxied, no wildcards, SPF/DKIM/DMARC match manifest)
|
||||
- DNSSEC + registrar lock enabled
|
||||
- WAF baseline compliance
|
||||
- Access policies enforce MFA and no-bypass rules
|
||||
- Tunnel health and credential age
|
||||
- Drift vs DNS Manifest
|
||||
- Drift vs Terraform (.tf files)
|
||||
|
||||
Outputs:
|
||||
anomalies/cf-invariants-<ts>.json
|
||||
receipts/cf-invariants-<ts>-<hash>.json
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
|
||||
CF_API = "https://api.cloudflare.com/client/v4"
|
||||
CF_TOKEN = os.getenv("CF_API_TOKEN")
|
||||
CF_ACCOUNT = os.getenv("CF_ACCOUNT_ID")
|
||||
ROOT = os.getenv("VM_STATE_ROOT", "./cloudflare_state")
|
||||
MANIFEST_PATH = os.getenv("DNS_MANIFEST", "./cloudflare_dns_manifest.json")
|
||||
TF_DIR = os.getenv("TF_DIR", "./terraform")
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {CF_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
os.makedirs(f"{ROOT}/anomalies", exist_ok=True)
|
||||
os.makedirs(f"{ROOT}/receipts", exist_ok=True)
|
||||
|
||||
|
||||
def merkle_root(obj):
|
||||
return hashlib.sha256(json.dumps(obj, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def cf(endpoint):
|
||||
r = requests.get(f"{CF_API}{endpoint}", headers=HEADERS)
|
||||
r.raise_for_status()
|
||||
return r.json().get("result", {})
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Helper: Load DNS Manifest
|
||||
# -------------------------------
|
||||
|
||||
def load_manifest():
|
||||
if not os.path.exists(MANIFEST_PATH):
|
||||
return None
|
||||
with open(MANIFEST_PATH, "r") as f:
|
||||
try:
|
||||
return json.load(f)
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Invariant Checks
|
||||
# -------------------------------
|
||||
|
||||
def check_dns(zones, manifest):
|
||||
anomalies = []
|
||||
for z in zones:
|
||||
zid = z["id"]
|
||||
zname = z["name"]
|
||||
recs = cf(f"/zones/{zid}/dns_records")
|
||||
|
||||
for r in recs:
|
||||
# 1 — No wildcards
|
||||
if r["name"].startswith("*"):
|
||||
anomalies.append({"zone": zname, "type": "wildcard_record", "record": r})
|
||||
|
||||
# 2 — Must be proxied unless manifest says internal
|
||||
internal = False
|
||||
if manifest and zname in manifest.get("internal_records", {}):
|
||||
internal_list = manifest["internal_records"][zname]
|
||||
if r["name"] in internal_list:
|
||||
internal = True
|
||||
|
||||
if not internal and r.get("proxied") is False:
|
||||
anomalies.append({"zone": zname, "type": "unproxied_record", "record": r})
|
||||
|
||||
# 3 — DNSSEC required
|
||||
dnssec = cf(f"/zones/{zid}/dnssec")
|
||||
if dnssec.get("status") != "active":
|
||||
anomalies.append({"zone": zname, "type": "dnssec_disabled"})
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def check_zone_security(zones):
|
||||
anomalies = []
|
||||
for z in zones:
|
||||
zid = z["id"]
|
||||
settings = cf(f"/zones/{zid}/settings/security_header")
|
||||
hsts = settings.get("value", {}).get("strict_transport_security")
|
||||
|
||||
if not hsts or not hsts.get("enabled"):
|
||||
anomalies.append({"zone": z["name"], "type": "hsts_disabled"})
|
||||
return anomalies
|
||||
|
||||
|
||||
def check_waf(zones):
|
||||
anomalies = []
|
||||
for z in zones:
|
||||
zid = z["id"]
|
||||
waf = cf(f"/zones/{zid}/firewall/waf/packages")
|
||||
if not waf:
|
||||
anomalies.append({"zone": z["name"], "type": "waf_missing"})
|
||||
continue
|
||||
# Require OWASP ruleset
|
||||
if not any("owasp" in pkg.get("name", "").lower() for pkg in waf):
|
||||
anomalies.append({"zone": z["name"], "type": "owasp_ruleset_missing"})
|
||||
return anomalies
|
||||
|
||||
|
||||
def check_access_policies():
|
||||
anomalies = []
|
||||
apps = cf(f"/accounts/{CF_ACCOUNT}/access/apps")
|
||||
policies = cf(f"/accounts/{CF_ACCOUNT}/access/policies")
|
||||
|
||||
for p in policies:
|
||||
if p.get("decision") == "bypass":
|
||||
anomalies.append({"type": "access_policy_bypass", "policy": p})
|
||||
if not any(r.get("require_mfa") for r in p.get("rules", [])):
|
||||
anomalies.append({"type": "access_policy_missing_mfa", "policy": p})
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def check_tunnels():
|
||||
anomalies = []
|
||||
tunnels = cf(f"/accounts/{CF_ACCOUNT}/cfd_tunnel")
|
||||
|
||||
for t in tunnels:
|
||||
if t.get("status") not in ("healthy", "active"):
|
||||
anomalies.append({"type": "tunnel_unhealthy", "tunnel": t})
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Main
|
||||
# -------------------------------
|
||||
|
||||
def main():
|
||||
anomalies = []
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
zones = cf("/zones")
|
||||
manifest = load_manifest()
|
||||
|
||||
anomalies += check_dns(zones, manifest)
|
||||
anomalies += check_zone_security(zones)
|
||||
anomalies += check_waf(zones)
|
||||
anomalies += check_access_policies()
|
||||
anomalies += check_tunnels()
|
||||
|
||||
anomaly_file = f"{ROOT}/anomalies/cf-invariants-{ts}.json"
|
||||
with open(anomaly_file, "w") as f:
|
||||
json.dump(anomalies, f, indent=2)
|
||||
|
||||
root = merkle_root(anomalies)
|
||||
receipt_file = f"{ROOT}/receipts/cf-invariants-{ts}-{root[:8]}.json"
|
||||
with open(receipt_file, "w") as f:
|
||||
json.dump({"ts": ts, "merkle_root": root, "anomalies_file": anomaly_file}, f, indent=2)
|
||||
|
||||
print("Anomaly report:", anomaly_file)
|
||||
print("Receipt:", receipt_file)
|
||||
print("Merkle root:", root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
400
scripts/seed_ide_rules.py
Normal file
400
scripts/seed_ide_rules.py
Normal file
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
IDE Operator Rules Seeder
|
||||
|
||||
Seeds operator rules into VS Code extension folders to provide
|
||||
policy-aware guidance for AI assistants and code generation.
|
||||
|
||||
This script:
|
||||
1. Finds VS Code extension directories
|
||||
2. Copies/symlinks operator rules to the appropriate locations
|
||||
3. Works across Mac, Linux, and Windows
|
||||
4. Can watch for extension updates and auto-reseed
|
||||
5. Verifies symlink integrity
|
||||
|
||||
Usage:
|
||||
python seed_ide_rules.py # Auto-detect and seed
|
||||
python seed_ide_rules.py --list # List target directories
|
||||
python seed_ide_rules.py --symlink # Use symlinks instead of copy
|
||||
python seed_ide_rules.py --dry-run # Show what would be done
|
||||
python seed_ide_rules.py --watch # Watch for extension updates and auto-reseed
|
||||
python seed_ide_rules.py --verify # Verify all symlinks are intact
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Set, Tuple
|
||||
|
||||
|
||||
# Source rules files to seed
|
||||
RULES_FILES = [
|
||||
"IDE_OPERATOR_RULES.md",
|
||||
"AGENT_GUARDRAILS.md",
|
||||
]
|
||||
|
||||
# Target extension patterns and their rule directories
|
||||
EXTENSION_TARGETS = [
|
||||
# Azure GitHub Copilot extension
|
||||
{
|
||||
"pattern": "ms-azuretools.vscode-azure-github-copilot-*",
|
||||
"subdir": "resources/azureRules",
|
||||
"target_name": "cloudflare.instructions.md",
|
||||
},
|
||||
# GitHub Copilot extension (if it has a rules dir)
|
||||
{
|
||||
"pattern": "github.copilot-*",
|
||||
"subdir": "resources",
|
||||
"target_name": "operator.instructions.md",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_vscode_extensions_dirs() -> List[Path]:
|
||||
"""Get VS Code extension directories for the current platform."""
|
||||
system = platform.system()
|
||||
home = Path.home()
|
||||
|
||||
dirs: List[Path] = []
|
||||
|
||||
if system == "Darwin": # macOS
|
||||
dirs = [
|
||||
home / ".vscode" / "extensions",
|
||||
home / ".vscode-insiders" / "extensions",
|
||||
home / ".cursor" / "extensions", # Cursor editor
|
||||
]
|
||||
elif system == "Linux":
|
||||
dirs = [
|
||||
home / ".vscode" / "extensions",
|
||||
home / ".vscode-server" / "extensions", # Remote SSH
|
||||
home / ".vscode-insiders" / "extensions",
|
||||
]
|
||||
elif system == "Windows":
|
||||
dirs = [
|
||||
home / ".vscode" / "extensions",
|
||||
home / ".vscode-insiders" / "extensions",
|
||||
Path(os.environ.get("APPDATA", "")) / "Code" / "User" / "extensions",
|
||||
]
|
||||
|
||||
return [d for d in dirs if d.exists()]
|
||||
|
||||
|
||||
def find_target_extensions(base_dirs: List[Path]) -> List[Tuple[Path, dict]]:
|
||||
"""Find matching extension directories."""
|
||||
targets: List[Tuple[Path, dict]] = []
|
||||
|
||||
for base_dir in base_dirs:
|
||||
for ext_config in EXTENSION_TARGETS:
|
||||
pattern = ext_config["pattern"]
|
||||
# Use glob to find matching extensions
|
||||
for ext_path in base_dir.glob(pattern):
|
||||
if ext_path.is_dir():
|
||||
targets.append((ext_path, ext_config))
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def get_source_rules_path() -> Path:
|
||||
"""Get the path to the source rules file."""
|
||||
# Try relative to this script first
|
||||
script_dir = Path(__file__).parent.parent
|
||||
|
||||
for rules_file in RULES_FILES:
|
||||
source = script_dir / rules_file
|
||||
if source.exists():
|
||||
return source
|
||||
|
||||
# Try current working directory
|
||||
for rules_file in RULES_FILES:
|
||||
source = Path.cwd() / rules_file
|
||||
if source.exists():
|
||||
return source
|
||||
|
||||
# Try parent of cwd (in case running from scripts/)
|
||||
for rules_file in RULES_FILES:
|
||||
source = Path.cwd().parent / rules_file
|
||||
if source.exists():
|
||||
return source
|
||||
|
||||
raise FileNotFoundError(
|
||||
f"Could not find any of {RULES_FILES}. "
|
||||
"Run this script from the CLOUDFLARE repo root."
|
||||
)
|
||||
|
||||
|
||||
def seed_rules(
|
||||
source: Path,
|
||||
targets: List[Tuple[Path, dict]],
|
||||
use_symlink: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> List[str]:
|
||||
"""Seed rules to target directories."""
|
||||
results: List[str] = []
|
||||
|
||||
for ext_path, config in targets:
|
||||
subdir = config["subdir"]
|
||||
target_name = config["target_name"]
|
||||
|
||||
target_dir = ext_path / subdir
|
||||
target_file = target_dir / target_name
|
||||
|
||||
# Create target directory if needed
|
||||
if not dry_run:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
action = "symlink" if use_symlink else "copy"
|
||||
|
||||
if dry_run:
|
||||
results.append(f"[DRY RUN] Would {action}: {source} → {target_file}")
|
||||
continue
|
||||
|
||||
try:
|
||||
# Remove existing file/symlink
|
||||
if target_file.exists() or target_file.is_symlink():
|
||||
target_file.unlink()
|
||||
|
||||
if use_symlink:
|
||||
target_file.symlink_to(source.resolve())
|
||||
results.append(f"✅ Symlinked: {target_file}")
|
||||
else:
|
||||
shutil.copy2(source, target_file)
|
||||
results.append(f"✅ Copied: {target_file}")
|
||||
|
||||
except PermissionError:
|
||||
results.append(f"❌ Permission denied: {target_file}")
|
||||
except Exception as e:
|
||||
results.append(f"❌ Failed: {target_file} — {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def list_targets(targets: List[Tuple[Path, dict]]) -> None:
|
||||
"""List all target directories."""
|
||||
print("\n📁 Found VS Code extension targets:\n")
|
||||
|
||||
if not targets:
|
||||
print(" No matching extensions found.")
|
||||
print(" Install ms-azuretools.vscode-azure-github-copilot to enable seeding.")
|
||||
return
|
||||
|
||||
for ext_path, config in targets:
|
||||
print(f" 📦 {ext_path.name}")
|
||||
print(f" Path: {ext_path}")
|
||||
print(f" Target: {config['subdir']}/{config['target_name']}")
|
||||
print()
|
||||
|
||||
|
||||
def verify_symlinks(
|
||||
targets: List[Tuple[Path, dict]],
|
||||
source: Path,
|
||||
) -> List[str]:
|
||||
"""Verify all symlinks point to correct source."""
|
||||
results: List[str] = []
|
||||
|
||||
for ext_path, config in targets:
|
||||
target_file = ext_path / config["subdir"] / config["target_name"]
|
||||
|
||||
if target_file.is_symlink():
|
||||
try:
|
||||
if target_file.resolve() == source.resolve():
|
||||
results.append(f"✅ Valid: {config['target_name']} in {ext_path.name}")
|
||||
else:
|
||||
results.append(
|
||||
f"⚠️ Stale: {target_file.name} → {target_file.resolve()}"
|
||||
)
|
||||
except OSError:
|
||||
results.append(f"💀 Broken symlink: {target_file}")
|
||||
elif target_file.exists():
|
||||
results.append(f"📄 Copy (not symlink): {target_file.name} in {ext_path.name}")
|
||||
else:
|
||||
results.append(f"❌ Missing: {config['target_name']} in {ext_path.name}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def watch_and_reseed(
|
||||
source: Path,
|
||||
use_symlink: bool = True,
|
||||
interval: int = 60,
|
||||
) -> None:
|
||||
"""Watch for new extensions and auto-reseed."""
|
||||
print(f"👁️ Watching for extension updates (every {interval}s)...")
|
||||
print(" Press Ctrl+C to stop\n")
|
||||
|
||||
known_extensions: Set[str] = set()
|
||||
|
||||
# Initial seed
|
||||
base_dirs = get_vscode_extensions_dirs()
|
||||
targets = find_target_extensions(base_dirs)
|
||||
known_extensions = {str(t[0]) for t in targets}
|
||||
|
||||
results = seed_rules(source, targets, use_symlink=use_symlink)
|
||||
seeded = sum(1 for r in results if r.startswith("✅"))
|
||||
print(f"📊 Initial seed: {seeded}/{len(results)} targets")
|
||||
|
||||
while True:
|
||||
try:
|
||||
time.sleep(interval)
|
||||
|
||||
base_dirs = get_vscode_extensions_dirs()
|
||||
targets = find_target_extensions(base_dirs)
|
||||
current = {str(t[0]) for t in targets}
|
||||
|
||||
new_extensions = current - known_extensions
|
||||
removed_extensions = known_extensions - current
|
||||
|
||||
if new_extensions:
|
||||
print(f"\n🆕 {len(new_extensions)} new extension(s) detected")
|
||||
# Only seed new ones
|
||||
new_targets = [(p, c) for p, c in targets if str(p) in new_extensions]
|
||||
results = seed_rules(source, new_targets, use_symlink=use_symlink)
|
||||
for r in results:
|
||||
print(f" {r}")
|
||||
|
||||
if removed_extensions:
|
||||
print(f"\n🗑️ {len(removed_extensions)} extension(s) removed")
|
||||
|
||||
known_extensions = current
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n👋 Stopped watching")
|
||||
break
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Seed IDE operator rules into VS Code extensions"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list", "-l",
|
||||
action="store_true",
|
||||
help="List target extension directories",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--symlink", "-s",
|
||||
action="store_true",
|
||||
help="Use symlinks instead of copying files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", "-n",
|
||||
action="store_true",
|
||||
help="Show what would be done without making changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--watch", "-w",
|
||||
action="store_true",
|
||||
help="Watch for extension updates and auto-reseed (runs in foreground)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verify", "-v",
|
||||
action="store_true",
|
||||
help="Verify all symlinks are intact",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=int,
|
||||
default=60,
|
||||
help="Watch interval in seconds (default: 60)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
type=Path,
|
||||
help="Source rules file (default: auto-detect)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Find VS Code extension directories
|
||||
base_dirs = get_vscode_extensions_dirs()
|
||||
|
||||
if not base_dirs:
|
||||
print("❌ No VS Code extension directories found.")
|
||||
print(" Make sure VS Code is installed.")
|
||||
return 1
|
||||
|
||||
print(f"🔍 Searching in {len(base_dirs)} VS Code extension directories...")
|
||||
|
||||
# Find target extensions
|
||||
targets = find_target_extensions(base_dirs)
|
||||
|
||||
if args.list:
|
||||
list_targets(targets)
|
||||
return 0
|
||||
|
||||
if not targets:
|
||||
print("\n⚠️ No matching extensions found.")
|
||||
print(" Install one of these extensions to enable rule seeding:")
|
||||
print(" - ms-azuretools.vscode-azure-github-copilot")
|
||||
print(" - github.copilot")
|
||||
return 1
|
||||
|
||||
# Get source file
|
||||
try:
|
||||
source = args.source or get_source_rules_path()
|
||||
except FileNotFoundError as e:
|
||||
print(f"❌ {e}")
|
||||
return 1
|
||||
|
||||
# Handle --verify
|
||||
if args.verify:
|
||||
print(f"📄 Source: {source}")
|
||||
print(f"🔍 Verifying {len(targets)} target(s)...\n")
|
||||
results = verify_symlinks(targets, source)
|
||||
print("\n".join(results))
|
||||
|
||||
valid = sum(1 for r in results if r.startswith("✅"))
|
||||
stale = sum(1 for r in results if r.startswith("⚠️"))
|
||||
missing = sum(1 for r in results if r.startswith("❌"))
|
||||
broken = sum(1 for r in results if r.startswith("💀"))
|
||||
|
||||
print(f"\n📊 {valid}/{len(results)} symlinks valid")
|
||||
if stale:
|
||||
print(f" ⚠️ {stale} stale (run --symlink to fix)")
|
||||
if missing:
|
||||
print(f" ❌ {missing} missing (run --symlink to create)")
|
||||
if broken:
|
||||
print(f" 💀 {broken} broken (run --symlink to fix)")
|
||||
|
||||
return 0 if (missing == 0 and broken == 0) else 1
|
||||
|
||||
# Handle --watch
|
||||
if args.watch:
|
||||
print(f"📄 Source: {source}")
|
||||
watch_and_reseed(source, use_symlink=True, interval=args.interval)
|
||||
return 0
|
||||
|
||||
print(f"📄 Source: {source}")
|
||||
print(f"🎯 Found {len(targets)} target extension(s)")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n🔍 Dry run mode — no changes will be made\n")
|
||||
|
||||
# Seed the rules
|
||||
results = seed_rules(
|
||||
source=source,
|
||||
targets=targets,
|
||||
use_symlink=args.symlink,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
print("\n" + "\n".join(results))
|
||||
|
||||
# Summary
|
||||
success = sum(1 for r in results if r.startswith("✅"))
|
||||
failed = sum(1 for r in results if r.startswith("❌"))
|
||||
|
||||
if not args.dry_run:
|
||||
print(f"\n📊 Seeded {success}/{len(results)} targets")
|
||||
if failed:
|
||||
print(f" ⚠️ {failed} failed — check permissions")
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
408
scripts/state-reconciler.py
Normal file
408
scripts/state-reconciler.py
Normal file
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cloudflare State Reconciler
|
||||
Fetches live Cloudflare configuration and produces cryptographically verifiable snapshots.
|
||||
|
||||
Usage:
|
||||
python3 state-reconciler.py --zone-id <ZONE_ID> --account-id <ACCOUNT_ID>
|
||||
|
||||
Environment Variables:
|
||||
CLOUDFLARE_API_TOKEN - API token with read permissions
|
||||
CLOUDFLARE_ZONE_ID - Zone ID (optional, can use --zone-id)
|
||||
CLOUDFLARE_ACCOUNT_ID - Account ID (optional, can use --account-id)
|
||||
|
||||
Output:
|
||||
- snapshots/cloudflare-<timestamp>.json
|
||||
- receipts/cf-state-<timestamp>.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
import requests
|
||||
|
||||
# Configuration
|
||||
CF_API_BASE = "https://api.cloudflare.com/client/v4"
|
||||
SNAPSHOT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "snapshots")
|
||||
RECEIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "receipts")
|
||||
|
||||
|
||||
class CloudflareClient:
|
||||
"""Cloudflare API client for state fetching."""
|
||||
|
||||
def __init__(self, api_token: str):
|
||||
self.api_token = api_token
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
|
||||
def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Make API request with error handling."""
|
||||
url = f"{CF_API_BASE}{endpoint}"
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data.get("success", False):
|
||||
errors = data.get("errors", [])
|
||||
raise Exception(f"Cloudflare API error: {errors}")
|
||||
return data
|
||||
|
||||
def _paginate(self, endpoint: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch all pages of a paginated endpoint."""
|
||||
results = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
while True:
|
||||
data = self._request("GET", endpoint, params={"page": page, "per_page": per_page})
|
||||
results.extend(data.get("result", []))
|
||||
result_info = data.get("result_info", {})
|
||||
total_pages = result_info.get("total_pages", 1)
|
||||
if page >= total_pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return results
|
||||
|
||||
# DNS
|
||||
def get_dns_records(self, zone_id: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch all DNS records for a zone."""
|
||||
return self._paginate(f"/zones/{zone_id}/dns_records")
|
||||
|
||||
def get_dnssec(self, zone_id: str) -> Dict[str, Any]:
|
||||
"""Fetch DNSSEC status for a zone."""
|
||||
data = self._request("GET", f"/zones/{zone_id}/dnssec")
|
||||
return data.get("result", {})
|
||||
|
||||
# Zone Settings
|
||||
def get_zone_settings(self, zone_id: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch all zone settings."""
|
||||
data = self._request("GET", f"/zones/{zone_id}/settings")
|
||||
return data.get("result", [])
|
||||
|
||||
def get_zone_info(self, zone_id: str) -> Dict[str, Any]:
|
||||
"""Fetch zone information."""
|
||||
data = self._request("GET", f"/zones/{zone_id}")
|
||||
return data.get("result", {})
|
||||
|
||||
# WAF / Firewall
|
||||
def get_firewall_rules(self, zone_id: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch firewall rules."""
|
||||
return self._paginate(f"/zones/{zone_id}/firewall/rules")
|
||||
|
||||
def get_rulesets(self, zone_id: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch zone rulesets."""
|
||||
data = self._request("GET", f"/zones/{zone_id}/rulesets")
|
||||
return data.get("result", [])
|
||||
|
||||
# Access
|
||||
def get_access_apps(self, account_id: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch Access applications."""
|
||||
return self._paginate(f"/accounts/{account_id}/access/apps")
|
||||
|
||||
def get_access_policies(self, account_id: str, app_id: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch policies for an Access application."""
|
||||
return self._paginate(f"/accounts/{account_id}/access/apps/{app_id}/policies")
|
||||
|
||||
# Tunnels
|
||||
def get_tunnels(self, account_id: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch Cloudflare Tunnels."""
|
||||
return self._paginate(f"/accounts/{account_id}/cfd_tunnel")
|
||||
|
||||
def get_tunnel_connections(self, account_id: str, tunnel_id: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch tunnel connections."""
|
||||
data = self._request("GET", f"/accounts/{account_id}/cfd_tunnel/{tunnel_id}/connections")
|
||||
return data.get("result", [])
|
||||
|
||||
# Logpush
|
||||
def get_logpush_jobs(self, zone_id: str) -> List[Dict[str, Any]]:
|
||||
"""Fetch Logpush jobs."""
|
||||
data = self._request("GET", f"/zones/{zone_id}/logpush/jobs")
|
||||
return data.get("result", [])
|
||||
|
||||
# API Tokens (metadata only)
|
||||
def get_api_tokens(self) -> List[Dict[str, Any]]:
|
||||
"""Fetch API token metadata (not secrets)."""
|
||||
data = self._request("GET", "/user/tokens")
|
||||
return data.get("result", [])
|
||||
|
||||
|
||||
def compute_sha256(data: Any) -> str:
|
||||
"""Compute SHA-256 hash of JSON-serialized data."""
|
||||
serialized = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(serialized.encode()).hexdigest()
|
||||
|
||||
|
||||
def compute_merkle_root(hashes: List[str]) -> str:
|
||||
"""Compute Merkle root from list of hashes."""
|
||||
if not hashes:
|
||||
return hashlib.sha256(b"").hexdigest()
|
||||
|
||||
# Pad to power of 2
|
||||
while len(hashes) & (len(hashes) - 1) != 0:
|
||||
hashes.append(hashes[-1])
|
||||
|
||||
while len(hashes) > 1:
|
||||
new_level = []
|
||||
for i in range(0, len(hashes), 2):
|
||||
combined = hashes[i] + hashes[i + 1]
|
||||
new_level.append(hashlib.sha256(combined.encode()).hexdigest())
|
||||
hashes = new_level
|
||||
|
||||
return hashes[0]
|
||||
|
||||
|
||||
def normalize_dns_record(record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalize DNS record for consistent hashing."""
|
||||
return {
|
||||
"id": record.get("id"),
|
||||
"type": record.get("type"),
|
||||
"name": record.get("name"),
|
||||
"content": record.get("content"),
|
||||
"proxied": record.get("proxied"),
|
||||
"ttl": record.get("ttl"),
|
||||
"priority": record.get("priority"),
|
||||
"created_on": record.get("created_on"),
|
||||
"modified_on": record.get("modified_on"),
|
||||
}
|
||||
|
||||
|
||||
def normalize_tunnel(tunnel: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalize tunnel for consistent hashing."""
|
||||
return {
|
||||
"id": tunnel.get("id"),
|
||||
"name": tunnel.get("name"),
|
||||
"status": tunnel.get("status"),
|
||||
"created_at": tunnel.get("created_at"),
|
||||
"deleted_at": tunnel.get("deleted_at"),
|
||||
"remote_config": tunnel.get("remote_config"),
|
||||
}
|
||||
|
||||
|
||||
def normalize_access_app(app: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalize Access app for consistent hashing."""
|
||||
return {
|
||||
"id": app.get("id"),
|
||||
"name": app.get("name"),
|
||||
"domain": app.get("domain"),
|
||||
"type": app.get("type"),
|
||||
"session_duration": app.get("session_duration"),
|
||||
"auto_redirect_to_identity": app.get("auto_redirect_to_identity"),
|
||||
"created_at": app.get("created_at"),
|
||||
"updated_at": app.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def fetch_cloudflare_state(
|
||||
client: CloudflareClient,
|
||||
zone_id: str,
|
||||
account_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch complete Cloudflare state."""
|
||||
|
||||
state = {
|
||||
"metadata": {
|
||||
"zone_id": zone_id,
|
||||
"account_id": account_id,
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"schema_version": "cf_state_v1",
|
||||
},
|
||||
"dns": {},
|
||||
"zone_settings": {},
|
||||
"waf": {},
|
||||
"access": {},
|
||||
"tunnels": {},
|
||||
"logpush": {},
|
||||
"api_tokens": {},
|
||||
}
|
||||
|
||||
print("Fetching zone info...")
|
||||
state["zone_info"] = client.get_zone_info(zone_id)
|
||||
|
||||
print("Fetching DNS records...")
|
||||
raw_dns = client.get_dns_records(zone_id)
|
||||
state["dns"]["records"] = [normalize_dns_record(r) for r in raw_dns]
|
||||
state["dns"]["dnssec"] = client.get_dnssec(zone_id)
|
||||
|
||||
print("Fetching zone settings...")
|
||||
settings = client.get_zone_settings(zone_id)
|
||||
state["zone_settings"] = {s["id"]: s["value"] for s in settings}
|
||||
|
||||
print("Fetching firewall rules...")
|
||||
state["waf"]["firewall_rules"] = client.get_firewall_rules(zone_id)
|
||||
state["waf"]["rulesets"] = client.get_rulesets(zone_id)
|
||||
|
||||
print("Fetching Access apps...")
|
||||
access_apps = client.get_access_apps(account_id)
|
||||
state["access"]["apps"] = []
|
||||
for app in access_apps:
|
||||
normalized = normalize_access_app(app)
|
||||
normalized["policies"] = client.get_access_policies(account_id, app["id"])
|
||||
state["access"]["apps"].append(normalized)
|
||||
|
||||
print("Fetching tunnels...")
|
||||
tunnels = client.get_tunnels(account_id)
|
||||
state["tunnels"]["list"] = []
|
||||
for tunnel in tunnels:
|
||||
normalized = normalize_tunnel(tunnel)
|
||||
if tunnel.get("status") != "deleted":
|
||||
normalized["connections"] = client.get_tunnel_connections(account_id, tunnel["id"])
|
||||
state["tunnels"]["list"].append(normalized)
|
||||
|
||||
print("Fetching Logpush jobs...")
|
||||
state["logpush"]["jobs"] = client.get_logpush_jobs(zone_id)
|
||||
|
||||
print("Fetching API token metadata...")
|
||||
tokens = client.get_api_tokens()
|
||||
# Remove sensitive fields
|
||||
state["api_tokens"]["list"] = [
|
||||
{
|
||||
"id": t.get("id"),
|
||||
"name": t.get("name"),
|
||||
"status": t.get("status"),
|
||||
"issued_on": t.get("issued_on"),
|
||||
"modified_on": t.get("modified_on"),
|
||||
"not_before": t.get("not_before"),
|
||||
"expires_on": t.get("expires_on"),
|
||||
}
|
||||
for t in tokens
|
||||
]
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def compute_state_hashes(state: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Compute per-section hashes."""
|
||||
sections = ["dns", "zone_settings", "waf", "access", "tunnels", "logpush", "api_tokens"]
|
||||
hashes = {}
|
||||
|
||||
for section in sections:
|
||||
if section in state:
|
||||
hashes[section] = compute_sha256(state[section])
|
||||
|
||||
return hashes
|
||||
|
||||
|
||||
def create_snapshot(state: Dict[str, Any], section_hashes: Dict[str, str], merkle_root: str) -> Dict[str, Any]:
|
||||
"""Create complete snapshot with integrity data."""
|
||||
return {
|
||||
"snapshot_version": "1.0.0",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"state": state,
|
||||
"integrity": {
|
||||
"section_hashes": section_hashes,
|
||||
"merkle_root": merkle_root,
|
||||
"hash_algorithm": "sha256",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def create_receipt(
|
||||
snapshot_path: str,
|
||||
merkle_root: str,
|
||||
zone_id: str,
|
||||
account_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Create VaultMesh receipt for state snapshot."""
|
||||
return {
|
||||
"receipt_type": "cf_state_snapshot",
|
||||
"schema_version": "vm_cf_snapshot_v1",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"zone_id": zone_id,
|
||||
"account_id": account_id,
|
||||
"snapshot_path": snapshot_path,
|
||||
"merkle_root": merkle_root,
|
||||
"hash_algorithm": "sha256",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Cloudflare State Reconciler")
|
||||
parser.add_argument("--zone-id", default=os.environ.get("CLOUDFLARE_ZONE_ID"),
|
||||
help="Cloudflare Zone ID")
|
||||
parser.add_argument("--account-id", default=os.environ.get("CLOUDFLARE_ACCOUNT_ID"),
|
||||
help="Cloudflare Account ID")
|
||||
parser.add_argument("--output-dir", default=SNAPSHOT_DIR,
|
||||
help="Output directory for snapshots")
|
||||
parser.add_argument("--receipt-dir", default=RECEIPT_DIR,
|
||||
help="Output directory for receipts")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate inputs
|
||||
api_token = os.environ.get("CLOUDFLARE_API_TOKEN")
|
||||
if not api_token:
|
||||
print("Error: CLOUDFLARE_API_TOKEN environment variable required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not args.zone_id:
|
||||
print("Error: Zone ID required (--zone-id or CLOUDFLARE_ZONE_ID)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not args.account_id:
|
||||
print("Error: Account ID required (--account-id or CLOUDFLARE_ACCOUNT_ID)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure output directories exist
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
os.makedirs(args.receipt_dir, exist_ok=True)
|
||||
|
||||
# Initialize client
|
||||
client = CloudflareClient(api_token)
|
||||
|
||||
# Fetch state
|
||||
print(f"Fetching Cloudflare state for zone {args.zone_id}...")
|
||||
state = fetch_cloudflare_state(client, args.zone_id, args.account_id)
|
||||
|
||||
# Compute hashes
|
||||
print("Computing integrity hashes...")
|
||||
section_hashes = compute_state_hashes(state)
|
||||
merkle_root = compute_merkle_root(list(section_hashes.values()))
|
||||
|
||||
# Create snapshot
|
||||
snapshot = create_snapshot(state, section_hashes, merkle_root)
|
||||
|
||||
# Write snapshot
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
|
||||
snapshot_filename = f"cloudflare-{timestamp}.json"
|
||||
snapshot_path = os.path.join(args.output_dir, snapshot_filename)
|
||||
|
||||
with open(snapshot_path, "w") as f:
|
||||
json.dump(snapshot, f, indent=2, sort_keys=True)
|
||||
|
||||
print(f"Snapshot written to: {snapshot_path}")
|
||||
|
||||
# Create and write receipt
|
||||
receipt = create_receipt(snapshot_path, merkle_root, args.zone_id, args.account_id)
|
||||
receipt_filename = f"cf-state-{timestamp}.json"
|
||||
receipt_path = os.path.join(args.receipt_dir, receipt_filename)
|
||||
|
||||
with open(receipt_path, "w") as f:
|
||||
json.dump(receipt, f, indent=2, sort_keys=True)
|
||||
|
||||
print(f"Receipt written to: {receipt_path}")
|
||||
|
||||
# Summary
|
||||
print("\n=== State Reconciler Summary ===")
|
||||
print(f"Zone ID: {args.zone_id}")
|
||||
print(f"Account ID: {args.account_id}")
|
||||
print(f"Merkle Root: {merkle_root}")
|
||||
print(f"DNS Records: {len(state['dns'].get('records', []))}")
|
||||
print(f"Access Apps: {len(state['access'].get('apps', []))}")
|
||||
print(f"Tunnels: {len(state['tunnels'].get('list', []))}")
|
||||
print(f"Snapshot: {snapshot_filename}")
|
||||
print(f"Receipt: {receipt_filename}")
|
||||
|
||||
# Output merkle root for piping
|
||||
print(f"\nMERKLE_ROOT={merkle_root}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
155
scripts/state_reconciler_py.py
Normal file
155
scripts/state_reconciler_py.py
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cloudflare State Reconciler (Pure Technical)
|
||||
Generates a canonical JSON snapshot + Merkle root representing:
|
||||
- DNS records
|
||||
- DNSSEC + registrar lock status
|
||||
- WAF rules
|
||||
- Firewall rules
|
||||
- Zero-Trust Access apps + policies
|
||||
- Tunnels + status metadata
|
||||
- API token metadata (non-secret)
|
||||
|
||||
Outputs:
|
||||
snapshots/cloudflare-<ts>.json
|
||||
receipts/cloudflare-state-<ts>-<merkle>.json
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
|
||||
CF_API = "https://api.cloudflare.com/client/v4"
|
||||
CF_TOKEN = os.getenv("CF_API_TOKEN")
|
||||
CF_ACCOUNT = os.getenv("CF_ACCOUNT_ID")
|
||||
OUT_ROOT = os.getenv("VM_STATE_ROOT", "./cloudflare_state")
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {CF_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
os.makedirs(f"{OUT_ROOT}/snapshots", exist_ok=True)
|
||||
os.makedirs(f"{OUT_ROOT}/receipts", exist_ok=True)
|
||||
|
||||
|
||||
def merkle_root(obj):
|
||||
data = json.dumps(obj, sort_keys=True).encode()
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def cf(endpoint):
|
||||
r = requests.get(f"{CF_API}{endpoint}", headers=HEADERS)
|
||||
r.raise_for_status()
|
||||
return r.json().get("result", {})
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Fetch Cloudflare State Sections
|
||||
# -------------------------------
|
||||
|
||||
def fetch_dns(zones):
|
||||
items = {}
|
||||
for z in zones:
|
||||
zid = z["id"]
|
||||
rec = cf(f"/zones/{zid}/dns_records")
|
||||
items[z["name"]] = rec
|
||||
return items
|
||||
|
||||
|
||||
def fetch_zones():
|
||||
return cf(f"/zones")
|
||||
|
||||
|
||||
def fetch_waf(zones):
|
||||
rules = {}
|
||||
for z in zones:
|
||||
zid = z["id"]
|
||||
waf = cf(f"/zones/{zid}/firewall/waf/packages")
|
||||
rules[z["name"]] = waf
|
||||
return rules
|
||||
|
||||
|
||||
def fetch_firewall_rules(zones):
|
||||
fr = {}
|
||||
for z in zones:
|
||||
zid = z["id"]
|
||||
rules = cf(f"/zones/{zid}/firewall/rules")
|
||||
fr[z["name"]] = rules
|
||||
return fr
|
||||
|
||||
|
||||
def fetch_tunnels():
|
||||
return cf(f"/accounts/{CF_ACCOUNT}/cfd_tunnel")
|
||||
|
||||
|
||||
def fetch_access_apps():
|
||||
return cf(f"/accounts/{CF_ACCOUNT}/access/apps")
|
||||
|
||||
|
||||
def fetch_access_policies():
|
||||
return cf(f"/accounts/{CF_ACCOUNT}/access/policies")
|
||||
|
||||
|
||||
def fetch_api_tokens():
|
||||
# Metadata only, not secrets
|
||||
r = requests.get(f"{CF_API}/user/tokens", headers=HEADERS)
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
return r.json().get("result", [])
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Snapshot Assembly
|
||||
# -------------------------------
|
||||
|
||||
def build_snapshot():
|
||||
zones = fetch_zones()
|
||||
|
||||
snapshot = {
|
||||
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"zones": zones,
|
||||
"dns": fetch_dns(zones),
|
||||
"waf": fetch_waf(zones),
|
||||
"firewall_rules": fetch_firewall_rules(zones),
|
||||
"access_apps": fetch_access_apps(),
|
||||
"access_policies": fetch_access_policies(),
|
||||
"tunnels": fetch_tunnels(),
|
||||
"api_tokens": fetch_api_tokens(),
|
||||
}
|
||||
return snapshot
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Main
|
||||
# -------------------------------
|
||||
|
||||
def main():
|
||||
snap = build_snapshot()
|
||||
root = merkle_root(snap)
|
||||
|
||||
ts = snap["ts"].replace(":", "-")
|
||||
snap_path = f"{OUT_ROOT}/snapshots/cloudflare-{ts}.json"
|
||||
|
||||
with open(snap_path, "w") as f:
|
||||
json.dump(snap, f, indent=2)
|
||||
|
||||
receipt = {
|
||||
"ts": snap["ts"],
|
||||
"merkle_root": root,
|
||||
"snapshot_file": os.path.basename(snap_path)
|
||||
}
|
||||
|
||||
receipt_path = f"{OUT_ROOT}/receipts/cloudflare-state-{ts}-{root[:8]}.json"
|
||||
with open(receipt_path, "w") as f:
|
||||
json.dump(receipt, f, indent=2)
|
||||
|
||||
print("Snapshot:", snap_path)
|
||||
print("Receipt:", receipt_path)
|
||||
print("Merkle root:", root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
377
scripts/tunnel-rotation-scheduler.py
Normal file
377
scripts/tunnel-rotation-scheduler.py
Normal file
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tunnel Rotation Scheduler
|
||||
Automatically rotates Cloudflare Tunnel credentials based on age policy.
|
||||
|
||||
Usage:
|
||||
python3 tunnel-rotation-scheduler.py --account-id <ACCOUNT_ID>
|
||||
|
||||
Environment Variables:
|
||||
CLOUDFLARE_API_TOKEN - API token with Tunnel permissions
|
||||
CLOUDFLARE_ACCOUNT_ID - Account ID (or use --account-id)
|
||||
TUNNEL_MAX_AGE_DAYS - Maximum tunnel credential age (default: 90)
|
||||
|
||||
Output:
|
||||
- Creates new tunnel with fresh credentials
|
||||
- Updates DNS routes
|
||||
- Destroys old tunnel
|
||||
- Emits rotation receipts
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import requests
|
||||
|
||||
# Configuration
|
||||
CF_API_BASE = "https://api.cloudflare.com/client/v4"
|
||||
RECEIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "receipts")
|
||||
DEFAULT_MAX_AGE_DAYS = 90
|
||||
|
||||
|
||||
class TunnelRotator:
|
||||
"""Handles Cloudflare Tunnel credential rotation."""
|
||||
|
||||
def __init__(self, api_token: str, account_id: str, max_age_days: int = DEFAULT_MAX_AGE_DAYS):
|
||||
self.api_token = api_token
|
||||
self.account_id = account_id
|
||||
self.max_age_days = max_age_days
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
self.rotations: List[Dict[str, Any]] = []
|
||||
|
||||
def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Make API request with error handling."""
|
||||
url = f"{CF_API_BASE}{endpoint}"
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data.get("success", False):
|
||||
errors = data.get("errors", [])
|
||||
raise Exception(f"Cloudflare API error: {errors}")
|
||||
return data
|
||||
|
||||
def get_tunnels(self) -> List[Dict[str, Any]]:
|
||||
"""Fetch all tunnels for the account."""
|
||||
data = self._request("GET", f"/accounts/{self.account_id}/cfd_tunnel")
|
||||
return data.get("result", [])
|
||||
|
||||
def get_tunnel_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find tunnel by name."""
|
||||
tunnels = self.get_tunnels()
|
||||
for t in tunnels:
|
||||
if t.get("name") == name and not t.get("deleted_at"):
|
||||
return t
|
||||
return None
|
||||
|
||||
def check_tunnel_age(self, tunnel: Dict[str, Any]) -> Tuple[int, bool]:
|
||||
"""Check tunnel age and whether rotation is needed."""
|
||||
created_at = tunnel.get("created_at")
|
||||
if not created_at:
|
||||
return 0, False
|
||||
|
||||
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||
age = datetime.now(timezone.utc) - created
|
||||
age_days = age.days
|
||||
|
||||
needs_rotation = age_days >= self.max_age_days
|
||||
return age_days, needs_rotation
|
||||
|
||||
def generate_tunnel_secret(self) -> str:
|
||||
"""Generate cryptographically secure tunnel secret."""
|
||||
return base64.b64encode(secrets.token_bytes(32)).decode()
|
||||
|
||||
def create_tunnel(self, name: str, secret: str) -> Dict[str, Any]:
|
||||
"""Create a new tunnel."""
|
||||
data = self._request(
|
||||
"POST",
|
||||
f"/accounts/{self.account_id}/cfd_tunnel",
|
||||
json={
|
||||
"name": name,
|
||||
"tunnel_secret": secret,
|
||||
}
|
||||
)
|
||||
return data.get("result", {})
|
||||
|
||||
def delete_tunnel(self, tunnel_id: str) -> bool:
|
||||
"""Delete a tunnel."""
|
||||
try:
|
||||
self._request("DELETE", f"/accounts/{self.account_id}/cfd_tunnel/{tunnel_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to delete tunnel {tunnel_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_tunnel_routes(self, tunnel_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get DNS routes for a tunnel."""
|
||||
try:
|
||||
data = self._request(
|
||||
"GET",
|
||||
f"/accounts/{self.account_id}/cfd_tunnel/{tunnel_id}/configurations"
|
||||
)
|
||||
config = data.get("result", {}).get("config", {})
|
||||
return config.get("ingress", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def update_dns_route(self, zone_id: str, hostname: str, tunnel_id: str) -> bool:
|
||||
"""Update DNS CNAME to point to new tunnel."""
|
||||
tunnel_cname = f"{tunnel_id}.cfargotunnel.com"
|
||||
|
||||
# Find existing record
|
||||
records_data = self._request(
|
||||
"GET",
|
||||
f"/zones/{zone_id}/dns_records",
|
||||
params={"name": hostname, "type": "CNAME"}
|
||||
)
|
||||
records = records_data.get("result", [])
|
||||
|
||||
if records:
|
||||
# Update existing
|
||||
record_id = records[0]["id"]
|
||||
self._request(
|
||||
"PATCH",
|
||||
f"/zones/{zone_id}/dns_records/{record_id}",
|
||||
json={"content": tunnel_cname}
|
||||
)
|
||||
else:
|
||||
# Create new
|
||||
self._request(
|
||||
"POST",
|
||||
f"/zones/{zone_id}/dns_records",
|
||||
json={
|
||||
"type": "CNAME",
|
||||
"name": hostname,
|
||||
"content": tunnel_cname,
|
||||
"proxied": True
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
def rotate_tunnel(
|
||||
self,
|
||||
old_tunnel: Dict[str, Any],
|
||||
zone_id: Optional[str] = None,
|
||||
hostnames: Optional[List[str]] = None,
|
||||
dry_run: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Rotate a tunnel to fresh credentials."""
|
||||
old_id = old_tunnel["id"]
|
||||
old_name = old_tunnel["name"]
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||
new_name = f"{old_name.split('-')[0]}-{timestamp}"
|
||||
|
||||
print(f"Rotating tunnel: {old_name} -> {new_name}")
|
||||
|
||||
rotation_record = {
|
||||
"old_tunnel_id": old_id,
|
||||
"old_tunnel_name": old_name,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
print(" [DRY RUN] Would create new tunnel and update routes")
|
||||
rotation_record["status"] = "dry_run"
|
||||
rotation_record["actions"] = ["create_tunnel", "update_routes", "delete_old"]
|
||||
self.rotations.append(rotation_record)
|
||||
return rotation_record
|
||||
|
||||
try:
|
||||
# Generate new secret
|
||||
new_secret = self.generate_tunnel_secret()
|
||||
|
||||
# Create new tunnel
|
||||
new_tunnel = self.create_tunnel(new_name, new_secret)
|
||||
new_id = new_tunnel["id"]
|
||||
rotation_record["new_tunnel_id"] = new_id
|
||||
rotation_record["new_tunnel_name"] = new_name
|
||||
print(f" Created new tunnel: {new_id}")
|
||||
|
||||
# Update DNS routes if zone and hostnames provided
|
||||
if zone_id and hostnames:
|
||||
for hostname in hostnames:
|
||||
try:
|
||||
self.update_dns_route(zone_id, hostname, new_id)
|
||||
print(f" Updated DNS route: {hostname}")
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to update route {hostname}: {e}")
|
||||
|
||||
# Wait for propagation (in production, would verify connectivity)
|
||||
print(" Waiting for propagation...")
|
||||
|
||||
# Delete old tunnel
|
||||
if self.delete_tunnel(old_id):
|
||||
print(f" Deleted old tunnel: {old_id}")
|
||||
rotation_record["old_tunnel_deleted"] = True
|
||||
else:
|
||||
rotation_record["old_tunnel_deleted"] = False
|
||||
|
||||
rotation_record["status"] = "success"
|
||||
rotation_record["new_secret_hash"] = hashlib.sha256(new_secret.encode()).hexdigest()[:16]
|
||||
|
||||
except Exception as e:
|
||||
rotation_record["status"] = "failed"
|
||||
rotation_record["error"] = str(e)
|
||||
print(f" Error: {e}")
|
||||
|
||||
self.rotations.append(rotation_record)
|
||||
return rotation_record
|
||||
|
||||
def scan_and_rotate(
|
||||
self,
|
||||
zone_id: Optional[str] = None,
|
||||
hostname_map: Optional[Dict[str, List[str]]] = None,
|
||||
dry_run: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Scan all tunnels and rotate those exceeding max age."""
|
||||
print(f"Scanning tunnels (max age: {self.max_age_days} days)...")
|
||||
tunnels = self.get_tunnels()
|
||||
|
||||
for tunnel in tunnels:
|
||||
if tunnel.get("deleted_at"):
|
||||
continue
|
||||
|
||||
name = tunnel.get("name", "unknown")
|
||||
age_days, needs_rotation = self.check_tunnel_age(tunnel)
|
||||
|
||||
status = "NEEDS ROTATION" if needs_rotation else "OK"
|
||||
print(f" {name}: {age_days} days old [{status}]")
|
||||
|
||||
if needs_rotation:
|
||||
hostnames = hostname_map.get(name, []) if hostname_map else None
|
||||
self.rotate_tunnel(tunnel, zone_id, hostnames, dry_run)
|
||||
|
||||
return self.rotations
|
||||
|
||||
|
||||
def create_rotation_receipt(rotations: List[Dict[str, Any]], account_id: str) -> Dict[str, Any]:
|
||||
"""Create VaultMesh receipt for rotation cycle."""
|
||||
successful = [r for r in rotations if r.get("status") == "success"]
|
||||
failed = [r for r in rotations if r.get("status") == "failed"]
|
||||
|
||||
return {
|
||||
"receipt_type": "tunnel_rotation_cycle",
|
||||
"schema_version": "vm_tunnel_rotation_v1",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"account_id": account_id,
|
||||
"summary": {
|
||||
"total_rotated": len(successful),
|
||||
"failed": len(failed),
|
||||
"skipped": len(rotations) - len(successful) - len(failed),
|
||||
},
|
||||
"rotations": rotations,
|
||||
"cycle_hash": hashlib.sha256(
|
||||
json.dumps(rotations, sort_keys=True).encode()
|
||||
).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Tunnel Rotation Scheduler")
|
||||
parser.add_argument("--account-id", default=os.environ.get("CLOUDFLARE_ACCOUNT_ID"),
|
||||
help="Cloudflare Account ID")
|
||||
parser.add_argument("--zone-id", default=os.environ.get("CLOUDFLARE_ZONE_ID"),
|
||||
help="Zone ID for DNS route updates")
|
||||
parser.add_argument("--max-age", type=int,
|
||||
default=int(os.environ.get("TUNNEL_MAX_AGE_DAYS", DEFAULT_MAX_AGE_DAYS)),
|
||||
help=f"Maximum tunnel age in days (default: {DEFAULT_MAX_AGE_DAYS})")
|
||||
parser.add_argument("--tunnel-name", help="Rotate specific tunnel by name")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Simulate rotation without changes")
|
||||
parser.add_argument("--force", action="store_true", help="Force rotation regardless of age")
|
||||
parser.add_argument("--output-dir", default=RECEIPT_DIR, help="Output directory for receipts")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate inputs
|
||||
api_token = os.environ.get("CLOUDFLARE_API_TOKEN")
|
||||
if not api_token:
|
||||
print("Error: CLOUDFLARE_API_TOKEN environment variable required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not args.account_id:
|
||||
print("Error: Account ID required (--account-id or CLOUDFLARE_ACCOUNT_ID)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure output directory exists
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
|
||||
# Initialize rotator
|
||||
rotator = TunnelRotator(api_token, args.account_id, args.max_age)
|
||||
|
||||
print("=" * 50)
|
||||
print("Tunnel Rotation Scheduler")
|
||||
print("=" * 50)
|
||||
print(f"Account ID: {args.account_id}")
|
||||
print(f"Max Age: {args.max_age} days")
|
||||
print(f"Dry Run: {args.dry_run}")
|
||||
print(f"Force: {args.force}")
|
||||
print("")
|
||||
|
||||
if args.tunnel_name:
|
||||
# Rotate specific tunnel
|
||||
tunnel = rotator.get_tunnel_by_name(args.tunnel_name)
|
||||
if not tunnel:
|
||||
print(f"Error: Tunnel '{args.tunnel_name}' not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.force:
|
||||
rotator.rotate_tunnel(tunnel, args.zone_id, dry_run=args.dry_run)
|
||||
else:
|
||||
age_days, needs_rotation = rotator.check_tunnel_age(tunnel)
|
||||
if needs_rotation:
|
||||
rotator.rotate_tunnel(tunnel, args.zone_id, dry_run=args.dry_run)
|
||||
else:
|
||||
print(f"Tunnel '{args.tunnel_name}' is {age_days} days old, no rotation needed")
|
||||
else:
|
||||
# Scan and rotate all
|
||||
rotator.scan_and_rotate(args.zone_id, dry_run=args.dry_run)
|
||||
|
||||
# Generate receipt
|
||||
if rotator.rotations:
|
||||
receipt = create_rotation_receipt(rotator.rotations, args.account_id)
|
||||
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
|
||||
receipt_filename = f"tunnel-rotation-{timestamp}.json"
|
||||
receipt_path = os.path.join(args.output_dir, receipt_filename)
|
||||
|
||||
with open(receipt_path, "w") as f:
|
||||
json.dump(receipt, f, indent=2, sort_keys=True)
|
||||
|
||||
print("")
|
||||
print(f"Receipt written to: {receipt_path}")
|
||||
|
||||
# Summary
|
||||
print("")
|
||||
print("=" * 50)
|
||||
print("Rotation Summary")
|
||||
print("=" * 50)
|
||||
successful = [r for r in rotator.rotations if r.get("status") == "success"]
|
||||
failed = [r for r in rotator.rotations if r.get("status") == "failed"]
|
||||
dry_runs = [r for r in rotator.rotations if r.get("status") == "dry_run"]
|
||||
|
||||
print(f"Successful: {len(successful)}")
|
||||
print(f"Failed: {len(failed)}")
|
||||
print(f"Dry Run: {len(dry_runs)}")
|
||||
|
||||
if failed:
|
||||
print("")
|
||||
print("Failed rotations:")
|
||||
for r in failed:
|
||||
print(f" - {r.get('old_tunnel_name')}: {r.get('error')}")
|
||||
|
||||
# Exit code
|
||||
return 0 if len(failed) == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user