Security · python · 24 copies
Config Compliance & Golden-Config Drift Checker
Audits running configuration against a declarative policy and reports what is non-compliant, why it matters, and the exact remediation. Ships with 38 CIS-aligned rules across all eight platforms covering AAA, management plane, insecure services, SNMP, logging, NTP, and routing protocol authentication. Also diffs against per-role golden templates to catch drift. Generates ready-to-push remediation fragments that feed straight into config-validator and config-rollback.
- Arista EOS
- FortiOS
- Cisco IOS
- Cisco IOS-XE
- Cisco IOS-XR
- Junos
- Cisco NX-OS
- PAN-OS
pip install "netmiko>=4.3" "PyYAML>=6.0"#!/usr/bin/env python3
"""
Config Compliance & Golden-Config Drift Checker
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/config-compliance-checker
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "netmiko>=4.3" "PyYAML>=6.0"
"""
from __future__ import annotations
import argparse
import difflib
import json
import logging
import os
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError:
yaml = None
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException
LOG = logging.getLogger("compliance")
NETMIKO_DRIVER = {
"ios": "cisco_ios", "iosxe": "cisco_ios", "iosxr": "cisco_xr",
"nxos": "cisco_nxos", "junos": "juniper_junos", "eos": "arista_eos",
"panos": "paloalto_panos", "fortios": "fortinet",
}
SHOW_RUN = {
"ios": "show running-config", "iosxe": "show running-config",
"iosxr": "show running-config", "nxos": "show running-config",
"junos": "show configuration | display set", "eos": "show running-config",
"panos": "show config running", "fortios": "show full-configuration",
}
SEV_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
# --------------------------------------------------------------------------
# Built-in CIS-aligned baseline
# --------------------------------------------------------------------------
# Rule keys:
# id, title, severity, reference, os (list), remediation (list[str])
# exactly one of:
# must_match regex that MUST appear
# must_not_match regex that MUST NOT appear
# capture regex with one group + `expect` (exact) or `min_value`
BUILTIN_RULES: list[dict[str, Any]] = [
# ---- Authentication & access -----------------------------------------
{"id": "AAA-001", "title": "Enable secret uses a strong hash",
"severity": "critical", "reference": "CIS 1.1.1",
"os": ["ios", "iosxe"], "must_match": r"enable secret (9|8|5) ",
"remediation": ["enable algorithm-type sha256 secret <SECRET>"]},
{"id": "AAA-002", "title": "No plaintext passwords in config",
"severity": "critical", "reference": "CIS 1.1.2",
"os": ["ios", "iosxe", "nxos", "eos"],
"must_not_match": r"^\s*(enable password|username \S+ password 0 )",
"remediation": ["service password-encryption",
"! Re-create affected users with 'secret' instead of 'password'"]},
{"id": "AAA-003", "title": "Password encryption service enabled",
"severity": "high", "reference": "CIS 1.1.3",
"os": ["ios", "iosxe"], "must_match": r"^service password-encryption",
"remediation": ["service password-encryption"]},
{"id": "AAA-004", "title": "AAA new-model enabled",
"severity": "high", "reference": "CIS 1.2.1",
"os": ["ios", "iosxe"], "must_match": r"^aaa new-model",
"remediation": ["aaa new-model",
"aaa authentication login default group tacacs+ local",
"aaa authorization exec default group tacacs+ local"]},
{"id": "AAA-005", "title": "Local fallback account exists",
"severity": "medium", "reference": "CIS 1.2.4",
"os": ["ios", "iosxe", "nxos", "eos"], "must_match": r"^username \S+",
"remediation": ["username breakglass privilege 15 algorithm-type sha256 secret <SECRET>"]},
# ---- Management plane -------------------------------------------------
{"id": "MGMT-001", "title": "Telnet disabled on VTY lines",
"severity": "critical", "reference": "CIS 2.1.1",
"os": ["ios", "iosxe"], "must_not_match": r"transport input (all|telnet)",
"remediation": ["line vty 0 15", " transport input ssh"]},
{"id": "MGMT-002", "title": "SSH version 2 enforced",
"severity": "high", "reference": "CIS 2.1.2",
"os": ["ios", "iosxe"], "must_match": r"^ip ssh version 2",
"remediation": ["ip ssh version 2", "ip ssh dh min size 2048"]},
{"id": "MGMT-003", "title": "HTTP server disabled",
"severity": "high", "reference": "CIS 2.1.4",
"os": ["ios", "iosxe"], "must_match": r"^no ip http server",
"remediation": ["no ip http server", "ip http secure-server"]},
{"id": "MGMT-004", "title": "VTY exec-timeout configured",
"severity": "medium", "reference": "CIS 2.1.5",
"os": ["ios", "iosxe", "nxos", "eos"], "must_match": r"exec-timeout \d+",
"remediation": ["line vty 0 15", " exec-timeout 10 0"]},
{"id": "MGMT-005", "title": "VTY access-class restricts management sources",
"severity": "high", "reference": "CIS 2.1.6",
"os": ["ios", "iosxe"], "must_match": r"access-class \S+ in",
"remediation": ["ip access-list standard MGMT_HOSTS",
" permit 10.0.0.0 0.0.0.255",
"line vty 0 15", " access-class MGMT_HOSTS in"]},
{"id": "MGMT-006", "title": "Login banner present",
"severity": "low", "reference": "CIS 1.3.1",
"os": ["ios", "iosxe", "nxos", "eos"], "must_match": r"banner (login|motd)",
"remediation": ["banner login ^Authorized access only. Activity is monitored.^"]},
# ---- Insecure services ------------------------------------------------
{"id": "SVC-001", "title": "CDP disabled globally or per-interface",
"severity": "medium", "reference": "CIS 3.1.1",
"os": ["ios", "iosxe"], "must_match": r"^no cdp run",
"remediation": ["no cdp run"]},
{"id": "SVC-002", "title": "Source routing disabled",
"severity": "medium", "reference": "CIS 3.1.2",
"os": ["ios", "iosxe"], "must_match": r"^no ip source-route",
"remediation": ["no ip source-route"]},
{"id": "SVC-003", "title": "Small servers disabled",
"severity": "low", "reference": "CIS 3.1.3",
"os": ["ios", "iosxe"], "must_not_match": r"^service (tcp|udp)-small-servers",
"remediation": ["no service tcp-small-servers", "no service udp-small-servers"]},
{"id": "SVC-004", "title": "Proxy ARP disabled on interfaces",
"severity": "medium", "reference": "CIS 3.2.1",
"os": ["ios", "iosxe"], "must_match": r"no ip proxy-arp",
"remediation": ["interface <INTF>", " no ip proxy-arp"]},
# ---- SNMP -------------------------------------------------------------
{"id": "SNMP-001", "title": "No default SNMP community strings",
"severity": "critical", "reference": "CIS 4.1.1",
"os": ["ios", "iosxe", "nxos", "eos"],
"must_not_match": r"snmp-server community (public|private)\b",
"remediation": ["no snmp-server community public",
"no snmp-server community private",
"! Migrate to SNMPv3 with authPriv"]},
{"id": "SNMP-002", "title": "SNMPv3 in use",
"severity": "high", "reference": "CIS 4.1.2",
"os": ["ios", "iosxe", "nxos", "eos"], "must_match": r"snmp-server (group|user) \S+ v3",
"remediation": ["snmp-server group NETMON v3 priv",
"snmp-server user monitor NETMON v3 auth sha <AUTH> priv aes 128 <PRIV>"]},
{"id": "SNMP-003", "title": "SNMP communities are read-only",
"severity": "high", "reference": "CIS 4.1.3",
"os": ["ios", "iosxe"], "must_not_match": r"snmp-server community \S+ RW",
"remediation": ["! Replace RW communities with SNMPv3 users"]},
# ---- Logging & time ---------------------------------------------------
{"id": "LOG-001", "title": "Syslog server configured",
"severity": "high", "reference": "CIS 5.1.1",
"os": ["ios", "iosxe", "nxos", "eos"], "must_match": r"logging (host )?\d+\.\d+\.\d+\.\d+",
"remediation": ["logging host 10.50.1.20", "logging trap informational"]},
{"id": "LOG-002", "title": "Timestamps include milliseconds",
"severity": "medium", "reference": "CIS 5.1.2",
"os": ["ios", "iosxe"], "must_match": r"service timestamps log datetime msec",
"remediation": ["service timestamps log datetime msec localtime show-timezone",
"service timestamps debug datetime msec localtime show-timezone"]},
{"id": "LOG-003", "title": "NTP configured",
"severity": "high", "reference": "CIS 5.2.1",
"os": ["ios", "iosxe", "nxos", "eos"], "must_match": r"ntp server \d+\.\d+\.\d+\.\d+",
"remediation": ["ntp server 10.50.1.1", "ntp server 10.50.1.2"]},
{"id": "LOG-004", "title": "NTP authentication enabled",
"severity": "medium", "reference": "CIS 5.2.2",
"os": ["ios", "iosxe"], "must_match": r"ntp authenticate",
"remediation": ["ntp authenticate",
"ntp authentication-key 1 md5 <KEY>",
"ntp trusted-key 1"]},
# ---- Routing protocol security ----------------------------------------
{"id": "RTG-001", "title": "OSPF authentication enabled",
"severity": "high", "reference": "CIS 6.1.1",
"os": ["ios", "iosxe", "nxos", "eos"],
"must_match": r"(ip ospf authentication|area \d+ authentication)",
"remediation": ["interface <INTF>", " ip ospf authentication message-digest",
" ip ospf message-digest-key 1 md5 <KEY>"]},
{"id": "RTG-002", "title": "BGP neighbors authenticated",
"severity": "high", "reference": "CIS 6.2.1",
"os": ["ios", "iosxe", "nxos", "eos", "iosxr"],
"must_match": r"neighbor \S+ password",
"remediation": ["router bgp <ASN>", " neighbor <PEER> password <KEY>"]},
# ---- JunOS ------------------------------------------------------------
{"id": "JUN-001", "title": "Telnet service disabled",
"severity": "critical", "reference": "CIS JunOS 1.1",
"os": ["junos"], "must_not_match": r"set system services telnet",
"remediation": ["delete system services telnet"]},
{"id": "JUN-002", "title": "SSH root login disabled",
"severity": "critical", "reference": "CIS JunOS 1.2",
"os": ["junos"], "must_not_match": r"set system services ssh root-login allow",
"remediation": ["set system services ssh root-login deny"]},
{"id": "JUN-003", "title": "Syslog host configured",
"severity": "high", "reference": "CIS JunOS 2.1",
"os": ["junos"], "must_match": r"set system syslog host \d+\.\d+\.\d+\.\d+",
"remediation": ["set system syslog host 10.50.1.20 any notice"]},
{"id": "JUN-004", "title": "NTP configured",
"severity": "high", "reference": "CIS JunOS 2.2",
"os": ["junos"], "must_match": r"set system ntp server \d+\.\d+\.\d+\.\d+",
"remediation": ["set system ntp server 10.50.1.1"]},
# ---- IOS-XR -----------------------------------------------------------
{"id": "XR-001", "title": "Telnet disabled",
"severity": "critical", "reference": "CIS XR 1.1",
"os": ["iosxr"], "must_not_match": r"telnet ipv4 server",
"remediation": ["no telnet ipv4 server max-servers"]},
{"id": "XR-002", "title": "SSH server enabled with v2",
"severity": "high", "reference": "CIS XR 1.2",
"os": ["iosxr"], "must_match": r"ssh server v2",
"remediation": ["ssh server v2", "ssh server vrf default"]},
{"id": "XR-003", "title": "Logging host configured",
"severity": "high", "reference": "CIS XR 2.1",
"os": ["iosxr"], "must_match": r"logging \d+\.\d+\.\d+\.\d+",
"remediation": ["logging 10.50.1.20 vrf default severity info"]},
# ---- NX-OS ------------------------------------------------------------
{"id": "NX-001", "title": "Telnet feature disabled",
"severity": "critical", "reference": "CIS NX-OS 1.1",
"os": ["nxos"], "must_not_match": r"^feature telnet",
"remediation": ["no feature telnet"]},
{"id": "NX-002", "title": "SSH key of at least 2048 bits",
"severity": "high", "reference": "CIS NX-OS 1.2",
"os": ["nxos"], "must_match": r"ssh key rsa (2048|4096)",
"remediation": ["ssh key rsa 2048 force"]},
# ---- FortiOS ----------------------------------------------------------
{"id": "FG-001", "title": "Admin timeout configured",
"severity": "medium", "reference": "CIS FortiOS 1.1",
"os": ["fortios"], "must_match": r"set admintimeout \d+",
"remediation": ["config system global", " set admintimeout 10", "end"]},
{"id": "FG-002", "title": "HTTP admin access disabled",
"severity": "high", "reference": "CIS FortiOS 1.2",
"os": ["fortios"], "must_not_match": r"set allowaccess.*\bhttp\b",
"remediation": ["config system interface", " edit <INTF>",
" set allowaccess ping https ssh", " next", "end"]},
{"id": "FG-003", "title": "Strong password policy enforced",
"severity": "high", "reference": "CIS FortiOS 1.3",
"os": ["fortios"], "must_match": r"set status enable",
"remediation": ["config system password-policy", " set status enable",
" set minimum-length 12", "end"]},
# ---- PAN-OS -----------------------------------------------------------
{"id": "PAN-001", "title": "Idle timeout configured",
"severity": "medium", "reference": "CIS PAN-OS 1.1",
"os": ["panos"], "must_match": r"set deviceconfig setting management idle-timeout",
"remediation": ["set deviceconfig setting management idle-timeout 10"]},
{"id": "PAN-002", "title": "Telnet management disabled",
"severity": "critical", "reference": "CIS PAN-OS 1.2",
"os": ["panos"], "must_not_match": r"telnet yes",
"remediation": ["set deviceconfig system service disable-telnet yes"]},
# ---- Arista -----------------------------------------------------------
{"id": "EOS-001", "title": "Management API over HTTP disabled",
"severity": "high", "reference": "CIS EOS 1.1",
"os": ["eos"], "must_not_match": r"protocol http\b(?!s)",
"remediation": ["management api http-commands", " no protocol http",
" protocol https"]},
{"id": "EOS-002", "title": "AAA authentication configured",
"severity": "high", "reference": "CIS EOS 1.2",
"os": ["eos"], "must_match": r"aaa authentication login",
"remediation": ["aaa authentication login default group tacacs+ local"]},
]
# --------------------------------------------------------------------------
# Data model
# --------------------------------------------------------------------------
@dataclass
class Violation:
rule_id: str
title: str
severity: str
reference: str
detail: str
remediation: list[str] = field(default_factory=list)
@dataclass
class DeviceReport:
host: str
os: str
mode: str = "policy"
checked: int = 0
passed: int = 0
violations: list[Violation] = field(default_factory=list)
drift_lines: list[str] = field(default_factory=list)
error: str = ""
elapsed_s: float = 0.0
@property
def score(self) -> float:
return round(100.0 * self.passed / self.checked, 1) if self.checked else 0.0
def worst(self) -> str:
if not self.violations:
return "none"
return min((v.severity for v in self.violations), key=lambda s: SEV_RANK.get(s, 9))
# --------------------------------------------------------------------------
# Policy evaluation
# --------------------------------------------------------------------------
def applicable(rule: dict, os_key: str) -> bool:
return os_key in rule.get("os", [])
def evaluate_rule(rule: dict, config: str) -> tuple[bool, str]:
"""Returns (compliant, detail)."""
flags = re.MULTILINE | re.IGNORECASE
if "must_match" in rule:
if re.search(rule["must_match"], config, flags):
return True, ""
return False, f"required pattern not found: {rule['must_match']}"
if "must_not_match" in rule:
hits = re.findall(rule["must_not_match"], config, flags)
if not hits:
return True, ""
sample = hits[0] if isinstance(hits[0], str) else " ".join(hits[0])
return False, f"forbidden pattern present ({len(hits)}x), e.g. '{str(sample)[:70]}'"
if "capture" in rule:
m = re.search(rule["capture"], config, flags)
if not m:
return False, f"value not found: {rule['capture']}"
val = m.group(1)
if "expect" in rule:
if val == str(rule["expect"]):
return True, ""
return False, f"expected '{rule['expect']}', found '{val}'"
if "min_value" in rule:
try:
if int(val) >= int(rule["min_value"]):
return True, ""
return False, f"value {val} is below the minimum {rule['min_value']}"
except ValueError:
return False, f"non-numeric value '{val}'"
if "max_value" in rule:
try:
if int(val) <= int(rule["max_value"]):
return True, ""
return False, f"value {val} exceeds the maximum {rule['max_value']}"
except ValueError:
return False, f"non-numeric value '{val}'"
return True, "rule has no assertion"
def run_policy(config: str, os_key: str, rules: list[dict], min_sev: str) -> tuple[int, int, list[Violation]]:
limit = SEV_RANK.get(min_sev, 4)
checked = passed = 0
viols: list[Violation] = []
for rule in rules:
if not applicable(rule, os_key):
continue
if SEV_RANK.get(rule.get("severity", "info"), 9) > limit:
continue
checked += 1
ok, detail = evaluate_rule(rule, config)
if ok:
passed += 1
else:
viols.append(Violation(
rule_id=rule["id"], title=rule["title"],
severity=rule.get("severity", "info"),
reference=rule.get("reference", ""),
detail=detail, remediation=rule.get("remediation", []),
))
return checked, passed, viols
# --------------------------------------------------------------------------
# Golden-config drift
# --------------------------------------------------------------------------
DEFAULT_IGNORE = [
r"^Building configuration",
r"^Current configuration",
r"^! Last configuration change",
r"^ntp clock-period",
r"^!Time:",
r"^## Last commit",
r"certificate",
r"^\s*$",
]
def normalise(text: str, ignore: list[str]) -> list[str]:
pats = [re.compile(p, re.IGNORECASE) for p in ignore]
out = []
for ln in text.splitlines():
if any(p.search(ln) for p in pats):
continue
s = ln.rstrip()
if s:
out.append(s)
return out
def run_golden(config: str, golden_path: Path, ignore: list[str]) -> list[str]:
live = normalise(config, ignore)
gold = normalise(golden_path.read_text(encoding="utf-8"), ignore)
diff = difflib.unified_diff(gold, live, fromfile="golden", tofile="running",
lineterm="", n=1)
return [d for d in diff if d.startswith(("+", "-")) and not d.startswith(("---", "+++"))]
# --------------------------------------------------------------------------
# Device workflow
# --------------------------------------------------------------------------
def connect(dev: dict):
params = {
"device_type": NETMIKO_DRIVER[dev["os"]],
"host": dev["host"],
"username": os.environ.get("NET_USER", ""),
"password": os.environ.get("NET_PASS", ""),
"port": dev.get("port", 22),
"conn_timeout": 20,
"fast_cli": False,
}
if os.environ.get("NET_ENABLE"):
params["secret"] = os.environ["NET_ENABLE"]
conn = ConnectHandler(**params)
if params.get("secret") and dev["os"] in ("ios", "iosxe", "nxos"):
conn.enable()
return conn
def process(dev: dict, rules: list[dict], args) -> DeviceReport:
rep = DeviceReport(host=dev["host"], os=dev["os"], mode=args.mode)
t0 = time.time()
conn = None
try:
conn = connect(dev)
config = conn.send_command(SHOW_RUN[dev["os"]], read_timeout=180)
if args.mode in ("policy", "both"):
c, p, v = run_policy(config, dev["os"], rules, args.min_severity)
rep.checked, rep.passed, rep.violations = c, p, v
if args.mode in ("golden", "both") and args.golden:
role = dev.get("role", dev["os"])
gp = Path(args.golden) / f"{role}.cfg"
if gp.exists():
rep.drift_lines = run_golden(config, gp, args.ignore_patterns)
else:
rep.error = f"no golden template at {gp}"
except NetmikoAuthenticationException as exc:
rep.error = f"authentication failed: {exc}"
except NetmikoTimeoutException as exc:
rep.error = f"timeout: {exc}"
except Exception as exc: # noqa: BLE001
rep.error = f"{type(exc).__name__}: {exc}"
finally:
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
rep.elapsed_s = round(time.time() - t0, 1)
return rep
def write_remediation(rep: DeviceReport, out_dir: Path) -> Path | None:
if not rep.violations:
return None
out_dir.mkdir(parents=True, exist_ok=True)
p = out_dir / f"{rep.host}.cfg"
lines = [
f"! Remediation for {rep.host} ({rep.os})",
f"! Generated {datetime.now(timezone.utc).isoformat(timespec='seconds')}",
f"! {len(rep.violations)} violation(s). REVIEW BEFORE PUSHING.",
"! Placeholders in <ANGLE_BRACKETS> must be filled in.",
"!",
]
for v in sorted(rep.violations, key=lambda x: SEV_RANK.get(x.severity, 9)):
if not v.remediation:
continue
lines.append(f"! [{v.severity.upper()}] {v.rule_id} {v.title} ({v.reference})")
lines.extend(v.remediation)
lines.append("!")
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
return p
# --------------------------------------------------------------------------
# Reporting
# --------------------------------------------------------------------------
def render(reports: list[DeviceReport], args) -> int:
tag = {"critical": "CRIT", "high": "HIGH", "medium": "MED ", "low": "LOW ", "info": "INFO"}
totals = {k: 0 for k in SEV_RANK}
failed_hosts = 0
for rep in sorted(reports, key=lambda r: (-len(r.violations), r.host)):
print(f"\n{'=' * 78}")
if rep.error:
print(f"[ERROR] {rep.host} ({rep.os}) {rep.error}")
failed_hosts += 1
continue
head = f"{rep.host} ({rep.os})"
if rep.mode in ("policy", "both"):
state = "PASS" if not rep.violations else "FAIL"
print(f"[{state}] {head} score {rep.score}% "
f"({rep.passed}/{rep.checked} rules) {rep.elapsed_s}s")
else:
print(f"[DRIFT] {head} {len(rep.drift_lines)} line(s) differ {rep.elapsed_s}s")
for v in sorted(rep.violations, key=lambda x: SEV_RANK.get(x.severity, 9)):
totals[v.severity] = totals.get(v.severity, 0) + 1
print(f" {tag[v.severity]} {v.rule_id:<9} {v.title}")
print(f" {v.detail}")
if v.reference:
print(f" ref: {v.reference}")
if rep.drift_lines:
print(f" --- golden drift ({len(rep.drift_lines)} lines) ---")
for d in rep.drift_lines[: args.max_drift]:
print(f" {d[:94]}")
if len(rep.drift_lines) > args.max_drift:
print(f" ... {len(rep.drift_lines) - args.max_drift} more")
print(f"\n{'=' * 78}")
scored = [r for r in reports if r.checked]
avg = round(sum(r.score for r in scored) / len(scored), 1) if scored else 0.0
print(f" {len(reports)} device(s) fleet score {avg}% unreachable={failed_hosts}")
print(" violations: " + " ".join(
f"{k}={totals[k]}" for k in ("critical", "high", "medium", "low") if totals.get(k)))
if totals.get("critical"):
print("\n CRITICAL findings present. Remediate immediately.")
return 2
if totals.get("high"):
print("\n High-severity findings present.")
return 1
return 0
def load_rules(path: str | None) -> list[dict]:
if not path:
return list(BUILTIN_RULES)
raw = Path(path).read_text(encoding="utf-8")
data = yaml.safe_load(raw) if (yaml and path.endswith((".yaml", ".yml"))) else json.loads(raw)
if isinstance(data, dict):
custom = data.get("rules", [])
replace = bool(data.get("replace_builtin"))
else:
custom, replace = data, False
rules = [] if replace else list(BUILTIN_RULES)
rules.extend(custom)
LOG.info("Loaded %d rule(s): %d custom, builtin %s",
len(rules), len(custom), "replaced" if replace else "included")
return rules
def main() -> int:
ap = argparse.ArgumentParser(description="Audit config against policy and golden templates.")
ap.add_argument("--inventory", required=True)
ap.add_argument("--policy", help="YAML/JSON rule file. Merged with the built-in baseline "
"unless the file sets replace_builtin: true")
ap.add_argument("--golden", help="Directory of <role>.cfg golden templates")
ap.add_argument("--mode", choices=["policy", "golden", "both"], default="policy")
ap.add_argument("--min-severity", choices=list(SEV_RANK), default="low",
help="Only evaluate rules at or above this severity. Default: low")
ap.add_argument("--remediate-dir", help="Write ready-to-push remediation fragments here")
ap.add_argument("--max-drift", type=int, default=25, help="Drift lines to print per device")
ap.add_argument("--ignore-patterns", nargs="*", default=DEFAULT_IGNORE,
help="Regexes excluded from golden drift")
ap.add_argument("--workers", type=int, default=10)
ap.add_argument("--json-out")
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s", datefmt="%H:%M:%S")
if not os.environ.get("NET_USER") or not os.environ.get("NET_PASS"):
LOG.error("Set NET_USER and NET_PASS in the environment.")
return 3
if args.mode in ("golden", "both") and not args.golden:
LOG.error("--golden is required for mode '%s'.", args.mode)
return 3
raw = Path(args.inventory).read_text(encoding="utf-8")
data = (yaml.safe_load(raw) if yaml and args.inventory.endswith((".yaml", ".yml"))
else json.loads(raw))
devices = data["devices"] if isinstance(data, dict) else data
rules = load_rules(args.policy)
LOG.info("Auditing %d device(s) | mode=%s | min-severity=%s",
len(devices), args.mode, args.min_severity)
reports: list[DeviceReport] = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futs = {pool.submit(process, d, rules, args): d for d in devices}
for fut in as_completed(futs):
d = futs[fut]
try:
reports.append(fut.result())
except Exception as exc: # noqa: BLE001
LOG.exception("%s: auditor crashed", d["host"])
reports.append(DeviceReport(host=d["host"], os=d.get("os", "?"),
error=f"crashed: {exc}"))
if args.remediate_dir:
out = Path(args.remediate_dir)
written = [p for p in (write_remediation(r, out) for r in reports) if p]
if written:
LOG.info("Wrote %d remediation fragment(s) to %s", len(written), out)
LOG.info("Next: config_validator.py --config-dir %s --offline-only", out)
if args.json_out:
Path(args.json_out).write_text(
json.dumps([asdict(r) for r in reports], indent=2), encoding="utf-8")
LOG.info("Results written to %s", args.json_out)
return render(reports, args)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--inventorytextrequiredYAML/JSON inventory. Each device needs host and os; add role to select a golden template.
--policytextCustom rule file. Merged with the 38 built-in CIS rules unless the file sets replace_builtin: true.
--goldentextDirectory of <role>.cfg golden templates for drift detection. Required when --mode is golden or both.
--modetextpolicy (rule checks), golden (template drift), or both. Default: policy.
--min-severitytextOnly evaluate rules at or above this severity: critical, high, medium, low, info. Default: low.
--remediate-dirtextWrite per-host remediation .cfg fragments here, ordered by severity, with placeholders marked.
--max-driftnumberDrift lines printed per device before truncating. Default: 25.
--ignore-patternslist[text]Regexes excluded from golden drift — timestamps, certificates, counters.
--workersnumberConcurrent devices. Default: 10.
--json-outtextMachine-readable results for dashboards or audit evidence.
What This Solves
Two questions get asked about configuration, and they are not the same question: "Is this device configured safely?" A standards question. Telnet off, SNMP not using `public`, a syslog server, AAA, NTP, routing protocol authentication. The answer is the same for every device of that platform. "Is this device configured the way we decided?" A consistency question. The answer is whatever your golden template says, and it is different per role. This answers both. **Policy mode** checks 38 CIS-aligned rules across all eight platforms. **Golden mode** diffs the live configuration against a per-role template and reports the drift. You can run either, or both. Every finding carries the rule, the CIS or NIST reference, and the exact remediation commands - and with `--remediate-dir` those commands are written out as ready-to-push fragments, ordered by severity. Use it before an audit, after inheriting an estate, and on a schedule so drift does not accumulate quietly for a year.
Is It Safe to Run
Yes. Strictly read-only on the device. It downloads the running configuration and evaluates it locally. It never enters configuration mode and never writes anything to a device. `--remediate-dir` writes **fragments on your own machine**. It does not push them. They are text for you to read, and then push deliberately - `config-validator` will lint them and `config-rollback` will push them with a snapshot and automatic rollback. That separation is intentional. A tool that both finds a compliance gap and fixes it unattended will, sooner or later, "fix" a deliberate exception at 3am. Reading the running configuration needs privileged exec on most platforms.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries: pip install "netmiko>=4.3" "PyYAML>=6.0" 3. SSH access with enough privilege to read the running configuration. 4. For golden mode only: a directory of `<role>.cfg` templates, and a `role` on each device in the inventory. Policy mode needs neither.
Your First Run
Policy mode needs nothing but an inventory: devices: - host: 192.168.1.1 os: ios Then: export NET_USER='admin' export NET_PASS='your-password' python config_compliance.py --inventory devices.yaml That runs the 38 built-in rules. Read the output before changing anything - some findings will be deliberate exceptions in your network, and you want to know which before you act on a list. Then narrow to what matters and collect the fixes: python config_compliance.py --inventory devices.yaml \ --min-severity high --remediate-dir ./fixes Read the fragments under ./fixes. Then lint and push them properly: python config_validator.py --config-dir ./fixes --offline-only python config_rollback.py --inventory devices.yaml --config-dir ./fixes
What the Output Means
[FAIL] sw-access-12 (ios) score 78.3% (18/23 rules) 2.1s CRIT MGMT-001 Telnet disabled on VTY lines forbidden pattern present (2x), e.g. 'transport input all' ref: CIS 2.1.1 CRIT SNMP-001 No default SNMP community strings forbidden pattern present (1x), e.g. 'snmp-server community public' ref: CIS 4.1.1 HIGH LOG-001 Syslog server configured required pattern not found: logging (host )?\d+\.\d+\.\d+\.\d+ ref: CIS 5.1.1 Reading it: score 78.3% (18/23 rules) Of the rules that apply to **this platform**. A rule for IOS does not count against a Junos box, so the denominator differs per device. The fleet score at the bottom is the average across devices that were actually checked. CRIT / HIGH / MEDIUM / LOW Severity of the rule, not of your network. A CRITICAL here means the control is one you do not skip, not that you are currently compromised. The evidence line For a forbidden pattern, the count and an example of the actual offending line - `transport input all`, `snmp-server community public`. For a required pattern, the regex that did not match, so you can see exactly what it was looking for. ref: CIS 2.1.1 The reference, so the finding maps straight into an audit document instead of needing translation. unreachable=N in the summary Devices that were not checked. **A fleet score calculated over the devices that answered is not a fleet score** - the three that timed out are unknown, not compliant. Golden mode output is a diff instead: lines present on the device and not in the template, and lines in the template and not on the device. `--max-drift` truncates it per device so one badly drifted box does not bury the rest.
Exit Codes for Cron and CI
**These are not the usual codes.** 0 no critical and no high findings 1 high-severity findings 2 critical findings 3 usage problem - NET_USER/NET_PASS not set, or --golden missing for a mode that needs it 130 you pressed Ctrl-C So `3` is the "you used it wrong" code, and 1 and 2 describe the network. A pipeline can fail on 2, warn on 1, and treat 3 as a broken job rather than a broken network. 0 5 * * 0 cd /opt/netops && python config_compliance.py \ --inventory devices.yaml --min-severity high \ --json-out /var/log/compliance.json The two modes --mode policy (default) The 38 built-in CIS-aligned rules, plus anything in --policy. Answers "is this configured safely". Needs no templates. --mode golden Diffs each device against <golden-dir>/<role>.cfg. Answers "is this configured the way we decided". Requires --golden and a `role` on each device. --mode both Runs both. Also requires --golden. They find different things and a device can pass one and fail the other. A switch that matches your golden template perfectly is still non-compliant if the template itself has Telnet enabled - which is a finding about the template, and exactly the kind of thing running both modes surfaces. The built-in rules 38 rules across all eight platforms, covering: AAA enable secret hashing, no plaintext passwords, password encryption, aaa new-model, authentication and authorisation management plane Telnet disabled on VTY, SSH version 2, session timeouts, login banners insecure services the small servers, HTTP server, CDP where it does not belong, source routing SNMP no default communities, v3 where available, ACLs on the communities that remain logging syslog server configured, timestamps, buffer size NTP servers configured, authentication routing protocols OSPF, EIGRP and BGP neighbour authentication Each rule declares which platforms it applies to, so the same policy file runs across a mixed estate without producing nonsense findings on the platforms it does not fit. A rule is one of three shapes: must_match a regex that must appear must_not_match a regex that must not appear capture a regex with one group, plus `expect` for an exact value or `min_value` for a threshold Writing your own rules `--policy` takes a YAML or JSON file. By default your rules are **merged with** the built-ins; set `replace_builtin: true` at the top of the file to use only yours. replace_builtin: false rules: - id: LOCAL-001 title: Management ACL applied to VTY severity: high reference: Internal STD-14 os: [ios, iosxe] must_match: '^line vty.*\n(.*\n)*?\s*access-class MGMT-IN in' remediation: - line vty 0 15 - " access-class MGMT-IN in" - id: LOCAL-002 title: No legacy NTP server severity: medium reference: Internal STD-22 os: [ios, iosxe, nxos] must_not_match: 'ntp server 10\.99\.' remediation: - no ntp server 10.99.0.1 - ntp server 10.1.0.1 `remediation` is what ends up in the --remediate-dir fragments, so write it as the commands you would actually paste. Start with `replace_builtin: false`. Replacing the baseline means losing 38 checks you did not have to write. Golden templates AND drift Golden mode needs a `role` on each device and a matching template: devices: - host: 192.168.1.11 os: ios role: access-switch # -> golden/access-switch.cfg Lines that change every time you read a configuration are excluded by default - "Building configuration", "Current configuration", "Last configuration change", "ntp clock-period", "Time:", "Last commit", and anything matching `certificate`. Without that exclusion every device would report drift on every run. Add your own with `--ignore-patterns`, which **replaces** the default list, so include the defaults you still want: --ignore-patterns '^Building configuration' '^Current configuration' \ 'certificate' '^snmp-server location' Drift is reported in both directions - what the device has and the template does not, and what the template has and the device does not. The second direction is the one people forget, and it is usually where a missing security control shows up.
The Inventory File
devices: - host: 192.168.1.11 # REQUIRED: IP address or DNS name os: ios # REQUIRED: which OS it runs role: access-switch # required for golden mode only port: 2222 # optional, default 22 A bare list without the `devices:` key works too. Credentials come from the environment, never from this file. YAML rules that trip people up: * Indentation must be SPACES, never tabs. * The "- " before host marks a new list item. Keep it aligned. * A space after the colon is required. host: 192.168.1.11 correct host:192.168.1.11 WRONG - YAML parse error
All Valid OS Values
ios Cisco IOS older routers, older Catalyst switches iosxe Cisco IOS-XE Catalyst 9000, ISR/ASR, Catalyst 8000 iosxr Cisco IOS-XR ASR 9000, NCS, service provider gear nxos Cisco NX-OS Nexus data center switches junos Juniper Junos MX, EX, QFX, SRX eos Arista EOS all Arista switches panos Palo Alto PAN-OS PA firewalls, VM-Series fortios Fortinet FortiOS FortiGate firewalls "cisco" is a vendor, not an OS. Use ios, iosxe, iosxr or nxos. Rule coverage is not identical across platforms - the built-in set is deepest on the Cisco family. A device on a less-covered platform scores against fewer rules, which is why the score is printed as a fraction rather than a bare percentage.
Credentials
From the environment - never from a file, never from an argument. An argument is visible to every user on the machine in `ps auxww` while the script runs, and it lands in your shell history. NET_USER username required NET_PASS password required NET_ENABLE enable secret optional, falls back to NET_PASS Linux or macOS: export NET_USER='admin' export NET_PASS='your-password' export NET_ENABLE='your-enable-secret' Windows PowerShell: $env:NET_USER = 'admin' $env:NET_PASS = 'your-password' Windows Command Prompt: set NET_USER=admin set NET_PASS=your-password
Every Flag
--inventory PATH REQUIRED. The devices. --policy PATH Your own rules. Merged with the built-ins unless the file sets replace_builtin: true. --golden PATH Directory of <role>.cfg templates. Required for golden and both modes. --mode MODE policy (default), golden, or both. --min-severity LEVEL critical, high, medium or low. Only evaluates rules at or above it. --remediate-dir PATH Write per-host remediation fragments here, ordered by severity. Nothing is pushed. --max-drift N Drift lines printed per device before truncating. Default 25. --ignore-patterns ... Regexes excluded from golden drift. This REPLACES the default list - include the defaults you still want. --workers N Devices at once. Default 10. --json-out PATH Machine-readable results, for dashboards or audit evidence.
When Something Fails
"Set NET_USER and NET_PASS in the environment." (exit 3) Not set in this terminal, or not in the cron job's environment. "--golden is required for mode 'golden'." (exit 3) Golden and both modes need the template directory. "authentication failed" Wrong credentials, or the account lacks access. Reading the running configuration needs privileged exec - set NET_ENABLE. A device scores 100% and you know it is not compliant Check how many rules applied: `(N/M rules)`. On a platform with thin built-in coverage, M is small and a perfect score means little. Write local rules for what matters on that platform. Golden mode reports drift on every device every run A volatile line that is not in the ignore list. Remember that --ignore-patterns REPLACES the defaults - if you passed your own list without including "^Building configuration" and the rest, they came back. A finding that is a deliberate exception Raise --min-severity so it stops appearing, or write a local policy file. Do not delete the built-in rule: the next person needs to know the control exists and that you decided against it. The remediation fragment looks wrong for your network It is generic by design - a rule cannot know your TACACS server's address. Read every fragment before pushing. That is why they are written to a directory instead of applied. The fleet score moved and nothing changed Check `unreachable=N`. The score is an average over the devices that answered, so three timeouts can move it without a single configuration change.