Security · python · 41 copies
ACL / Policy Security Analyzer
Finds overly permissive and unreachable rules in ACLs and firewall policies across a mixed-vendor fleet. Checks whether each ACL is actually bound to an interface or VTY line, so a permissive rule that is live is rated HIGH while the same rule in an unused ACL is MEDIUM — the difference between an open door and latent risk. Also detects rules that can never match because an earlier rule already covers them, which is where stale permissions hide. Covers Cisco IOS/IOS-XE/IOS-XR/NX-OS, Juniper Junos filters, Arista EOS, FortiOS policies, and PAN-OS security rules.
- 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
"""
ACL / Policy Security Analyzer
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/acl-analyzer
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 json
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException
import re
from collections import defaultdict
from dataclasses import dataclass, asdict
from typing import Any
# ---------------------------------------------------------------------------
# Inventory loading and connection
# ---------------------------------------------------------------------------
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",
}
def load_inventory(path: str) -> list[dict]:
"""
Read devices.yaml (or .json) and return a list of device dicts.
Applies the `defaults:` block to every device, validates the `os` field,
and fails with a readable message rather than a stack trace.
"""
p = Path(path)
if not p.exists():
raise SystemExit(
f"Inventory file not found: {path}\n"
f"Create it with:\n\n"
f" devices:\n"
f" - host: 192.168.1.1\n"
f" os: ios\n"
)
raw = p.read_text(encoding="utf-8")
try:
if p.suffix.lower() in (".yaml", ".yml"):
if yaml is None:
raise SystemExit("PyYAML is not installed. Run: pip install PyYAML")
data = yaml.safe_load(raw)
else:
data = json.loads(raw)
except SystemExit:
raise
except Exception as exc:
raise SystemExit(f"Could not parse {path}: {exc}\n"
f"Check for tabs (use spaces) and missing spaces after colons.")
if isinstance(data, dict):
defaults = data.get("defaults", {}) or {}
devices = data.get("devices", []) or []
else:
defaults, devices = {}, data
if not devices:
raise SystemExit(f"No devices found in {path}. The file needs a `devices:` list.")
out = []
for i, d in enumerate(devices, 1):
if not isinstance(d, dict):
raise SystemExit(f"Device #{i} in {path} is not a mapping. "
f"Each entry needs at least `host:` and `os:`.")
merged = {**defaults, **d}
if "host" not in merged:
raise SystemExit(f"Device #{i} in {path} has no `host:` field.")
os_key = merged.get("os")
if not os_key:
raise SystemExit(f"Device '{merged['host']}' has no `os:` field. "
f"Valid values: {', '.join(sorted(NETMIKO_DRIVER))}")
if os_key not in NETMIKO_DRIVER:
raise SystemExit(
f"Device '{merged['host']}' has os '{os_key}', which is not supported.\n"
f"Valid values: {', '.join(sorted(NETMIKO_DRIVER))}\n"
f"Note: 'cisco' is a vendor, not an OS - use ios, iosxe, iosxr, or nxos."
)
merged.setdefault("vrfs", [])
out.append(merged)
return out
def credentials() -> tuple[str, str, str]:
"""Read credentials from the environment and fail clearly if missing."""
user = os.environ.get("NET_USER", "")
pwd = os.environ.get("NET_PASS", "")
enable = os.environ.get("NET_ENABLE", "")
if not user or not pwd:
raise SystemExit(
"Set NET_USER and NET_PASS in your environment before running.\n\n"
" Linux/macOS: export NET_USER='admin'\n"
" export NET_PASS='your-password'\n\n"
" PowerShell: $env:NET_USER = 'admin'\n"
" $env:NET_PASS = 'your-password'\n"
)
return user, pwd, enable
def connect(dev: dict):
"""Open an SSH session to one device using netmiko."""
user, pwd, enable = credentials()
params = {
"device_type": NETMIKO_DRIVER[dev["os"]],
"host": dev["host"],
"username": dev.get("username") or user,
"password": dev.get("password") or pwd,
"port": dev.get("port", 22),
"conn_timeout": 20,
"fast_cli": False,
}
secret = dev.get("secret") or enable
if secret:
params["secret"] = secret
conn = ConnectHandler(**params)
if secret and dev["os"] in ("ios", "iosxe", "nxos"):
conn.enable()
return conn
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
ACL_CMD = {
"ios": "show ip access-lists",
"iosxe": "show ip access-lists",
"iosxr": "show access-lists",
"nxos": "show ip access-lists",
"junos": "show configuration firewall | display set",
"eos": "show ip access-lists",
"fortios": "show firewall policy",
"panos": "show running security-policy",
}
# Where the ACL is actually applied. A permissive ACL bound to nothing is a
# latent risk; the same ACL applied inbound on the internet edge is an open
# door. The two deserve different severities, so both are checked.
APPLIED_CMD = {
"ios": "show running-config | include ip access-group|access-class",
"iosxe": "show running-config | include ip access-group|access-class",
"nxos": "show running-config | include ip access-group|access-class",
"eos": "show running-config | include ip access-group|access-class",
"iosxr": "show running-config | include ipv4 access-group",
"junos": "show configuration | display set | match filter",
}
@dataclass
class Finding:
severity: str # HIGH | MEDIUM | LOW
acl: str
rule: str # line number or rule name
message: str
applied: bool = False
# ---------------------------------------------------------------------------
# Parsers
# ---------------------------------------------------------------------------
def parse_applied(output: str) -> set[str]:
"""Return the set of ACL names bound to an interface or VTY line."""
names: set[str] = set()
for line in output.splitlines():
for pat in (r"ip access-group (\S+)", r"access-class (\S+)",
r"ipv4 access-group (\S+)", r"filter input (\S+)",
r"filter output (\S+)"):
m = re.search(pat, line)
if m:
names.add(m.group(1))
return names
def parse_cisco_acls(output: str, applied: set[str]) -> list[Finding]:
"""
Parse IOS / IOS-XE / IOS-XR / NX-OS / EOS access lists.
Beyond spotting permit-any, this tracks whether an earlier rule already
covers a later one. A rule that can never match is dead config, and dead
config is where stale permissions hide.
"""
findings: list[Finding] = []
acl: str | None = None
seq = 0
broad_permit_at: int | None = None
for line in output.splitlines():
m = re.match(r"^(?:Extended|Standard|IPv4)\s+(?:IP\s+)?access list (\S+)",
line, re.IGNORECASE)
if m:
acl = m.group(1)
seq = 0
broad_permit_at = None
continue
if not acl or not line.strip():
continue
seq += 1
s = line.strip()
is_applied = acl in applied
if re.search(r"permit\s+ip\s+any\s+any", s, re.IGNORECASE):
findings.append(Finding(
"HIGH" if is_applied else "MEDIUM", acl, str(seq),
"permit ip any any - every protocol, every source, every destination",
is_applied))
if broad_permit_at is None:
broad_permit_at = seq
elif re.search(r"permit\s+\S+\s+any\s+any", s, re.IGNORECASE):
findings.append(Finding(
"MEDIUM" if is_applied else "LOW", acl, str(seq),
"permit <proto> any any - any source to any destination", is_applied))
if broad_permit_at is None:
broad_permit_at = seq
elif re.search(r"permit\s+.*\bany\b", s, re.IGNORECASE):
findings.append(Finding(
"LOW", acl, str(seq),
"permit with an 'any' in source or destination", is_applied))
# Anything after an unconditional permit can never be reached.
if broad_permit_at is not None and seq > broad_permit_at:
findings.append(Finding(
"LOW", acl, str(seq),
f"unreachable - line {broad_permit_at} already permits this traffic",
is_applied))
return findings
def parse_junos_filters(output: str, applied: set[str]) -> list[Finding]:
"""Flag Junos terms that accept without a from clause (match-all)."""
findings: list[Finding] = []
terms: dict[tuple[str, str], dict] = defaultdict(lambda: {"from": False, "accept": False})
for line in output.splitlines():
m = re.search(r"firewall family \S+ filter (\S+) term (\S+) (from|then) (.+)", line)
if not m:
continue
key = (m.group(1), m.group(2))
if m.group(3) == "from":
terms[key]["from"] = True
elif "accept" in m.group(4):
terms[key]["accept"] = True
for (filt, term), v in terms.items():
if v["accept"] and not v["from"]:
findings.append(Finding(
"HIGH" if filt in applied else "MEDIUM", filt, term,
"term accepts with no from clause - matches all traffic",
filt in applied))
return findings
def parse_fortios_policy(output: str) -> list[Finding]:
"""Flag FortiOS policies that are any/any/accept, and note disabled ones."""
findings: list[Finding] = []
pid: str | None = None
state: dict[str, Any] = {}
for line in output.splitlines():
m = re.match(r"\s*edit (\d+)", line)
if m:
pid = m.group(1)
state = {"src_all": False, "dst_all": False, "accept": False,
"svc_all": False, "disabled": False, "name": ""}
continue
if pid is None:
continue
if re.search(r'set name "([^"]*)"', line):
state["name"] = re.search(r'set name "([^"]*)"', line).group(1)
if re.search(r'set srcaddr "all"', line):
state["src_all"] = True
if re.search(r'set dstaddr "all"', line):
state["dst_all"] = True
if re.search(r'set service "ALL"', line):
state["svc_all"] = True
if re.search(r"set action accept", line):
state["accept"] = True
if re.search(r"set status disable", line):
state["disabled"] = True
if re.match(r"\s*next\s*$", line):
label = f"policy {pid}" + (f" ({state['name']})" if state["name"] else "")
if state["src_all"] and state["dst_all"] and state["accept"]:
sev = "LOW" if state["disabled"] else "HIGH"
extra = " [policy is disabled]" if state["disabled"] else ""
svc = " and service ALL" if state["svc_all"] else ""
findings.append(Finding(
sev, label, pid,
f"srcaddr all + dstaddr all{svc} + action accept{extra}",
not state["disabled"]))
elif state["svc_all"] and state["accept"] and not state["disabled"]:
findings.append(Finding(
"LOW", label, pid,
"service ALL with action accept - every port permitted", True))
pid = None
return findings
def parse_panos_policy(output: str) -> list[Finding]:
"""
Flag PAN-OS rules that are any/any.
`show running security-policy` prints each rule as a block of
'key: value;' pairs, so the rule is accumulated until the next header.
"""
findings: list[Finding] = []
rule: str | None = None
attrs: dict[str, str] = {}
def flush() -> None:
if not rule:
return
src = attrs.get("source", "")
dst = attrs.get("destination", "")
app = attrs.get("application", "")
svc = attrs.get("service", "")
action = attrs.get("action", "")
if "allow" not in action.lower():
return
if "any" in src.lower() and "any" in dst.lower():
detail = "source any + destination any"
if "any" in app.lower():
detail += " + application any"
if "any" in svc.lower():
detail += " + service any"
findings.append(Finding("HIGH", rule, "-", detail + " with action allow", True))
elif "any" in app.lower() and "any" in svc.lower():
findings.append(Finding(
"MEDIUM", rule, "-",
"application any + service any - App-ID gives no protection here", True))
for line in output.splitlines():
m = re.match(r'^"?([^"\s:]+)"?\s*\{?\s*$', line.strip())
if m and not re.search(r"[:;]", line):
flush()
rule = m.group(1)
attrs = {}
continue
for k in ("source", "destination", "application", "service", "action"):
g = re.search(rf"{k}:\s*([^;]+);", line, re.IGNORECASE)
if g:
attrs[k] = g.group(1).strip()
flush()
return findings
# ---------------------------------------------------------------------------
# Per-device analysis
# ---------------------------------------------------------------------------
def analyse_device(dev: dict, args) -> dict:
host = dev["host"]
os_key = dev["os"]
conn = None
try:
conn = connect(dev)
applied: set[str] = set()
if APPLIED_CMD.get(os_key) and not args.skip_applied_check:
try:
applied = parse_applied(
conn.send_command(APPLIED_CMD[os_key], read_timeout=90))
except Exception: # noqa: BLE001 - binding check is an enrichment
applied = set()
raw = conn.send_command(ACL_CMD[os_key], read_timeout=args.read_timeout)
if os_key in ("ios", "iosxe", "iosxr", "nxos", "eos"):
findings = parse_cisco_acls(raw, applied)
elif os_key == "junos":
findings = parse_junos_filters(raw, applied)
elif os_key == "fortios":
findings = parse_fortios_policy(raw)
elif os_key == "panos":
findings = parse_panos_policy(raw)
else:
findings = []
if args.applied_only:
findings = [f for f in findings if f.applied]
rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
findings.sort(key=lambda f: (rank[f.severity], f.acl, f.rule))
return {"host": host, "os": os_key, "status": "ok",
"findings": findings, "applied_acls": sorted(applied)}
except NetmikoAuthenticationException as exc:
return {"host": host, "os": os_key, "status": "error", "findings": [],
"error": f"authentication failed - check NET_USER/NET_PASS ({exc})"}
except NetmikoTimeoutException as exc:
return {"host": host, "os": os_key, "status": "error", "findings": [],
"error": f"unreachable or SSH timed out ({exc})"}
except Exception as exc: # noqa: BLE001 - one device must not abort the run
return {"host": host, "os": os_key, "status": "error", "findings": [],
"error": f"{type(exc).__name__}: {exc}"}
finally:
if conn:
try:
conn.disconnect()
except Exception: # noqa: BLE001
pass
def main() -> int:
ap = argparse.ArgumentParser(
description="Find overly permissive and unreachable rules in ACLs and "
"firewall policies.",
epilog="Credentials come from NET_USER and NET_PASS. See the docstring at "
"the top of this file for the full inventory format.",
)
ap.add_argument("--inventory", required=True,
help="Path to devices.yaml listing which devices to analyse")
ap.add_argument("--limit", help="Only analyse hosts whose name contains this text")
ap.add_argument("--applied-only", action="store_true",
help="Report only rules in an ACL that is actually bound to an "
"interface or VTY line. Cuts the noise from unused ACLs")
ap.add_argument("--skip-applied-check", action="store_true",
help="Do not read the running config to see where ACLs are "
"applied. One fewer command, but severity is less accurate")
ap.add_argument("--min-severity", choices=["HIGH", "MEDIUM", "LOW"], default="LOW",
help="Only report findings at or above this severity. Default: LOW")
ap.add_argument("--read-timeout", type=int, default=120,
help="Seconds to wait per command. Default: 120")
ap.add_argument("--workers", type=int, default=10,
help="How many devices to contact at once. Default: 10")
ap.add_argument("--json-out", help="Write machine-readable findings here")
args = ap.parse_args()
devices = load_inventory(args.inventory)
if args.limit:
devices = [d for d in devices if args.limit in d["host"]]
if not devices:
raise SystemExit(f"No device name contains '{args.limit}'.")
credentials() # fail fast before opening any connection
print(f"Analysing ACLs and firewall policies on {len(devices)} device(s)...")
print("=" * 84)
results = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(analyse_device, d, args): d["host"] for d in devices}
for fut in as_completed(futures):
host = futures[fut]
try:
results.append(fut.result())
except Exception as exc: # noqa: BLE001
results.append({"host": host, "os": "?", "status": "error",
"findings": [], "error": f"worker crashed: {exc}"})
rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
floor = rank[args.min_severity]
tally = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
for r in sorted(results, key=lambda x: x["host"]):
label = f"{r['host']} ({r['os']})"
if r["status"] == "error":
print(f"[ERROR] {label:<26} {r['error']}")
continue
shown = [f for f in r["findings"] if rank[f.severity] <= floor]
if not shown:
n_applied = len(r.get("applied_acls", []))
print(f"[OK] {label:<26} no permissive rules "
f"({n_applied} ACL(s) applied to interfaces)")
continue
print(f"\n{label} - {len(shown)} finding(s)")
for f in shown:
tally[f.severity] += 1
mark = "applied" if f.applied else "NOT applied anywhere"
print(f" [{f.severity:<6}] {f.acl} line {f.rule} ({mark})")
print(f" {f.message}")
print("\n" + "=" * 84)
print(f"{tally['HIGH']} HIGH, {tally['MEDIUM']} MEDIUM, {tally['LOW']} LOW "
f"across {len(devices)} device(s).")
if tally["HIGH"]:
print("\nHIGH means the rule is permissive AND the ACL is bound to an "
"interface - it is live.")
if args.json_out:
payload = [{"host": r["host"], "os": r["os"], "status": r["status"],
"error": r.get("error"),
"applied_acls": r.get("applied_acls", []),
"findings": [asdict(f) for f in r["findings"]]}
for r in results]
Path(args.json_out).write_text(json.dumps(payload, indent=2), encoding="utf-8")
err = sum(1 for r in results if r["status"] == "error")
return 1 if (tally["HIGH"] or err) else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--inventorytextrequiredPath to your devices.yaml file. Lists which devices to analyse, each with a host and an os field. See the output example below for the full format and every valid os value.
--limittextOnly analyse hosts whose name contains this text.
--applied-onlyboolReport only rules in an ACL that is bound to an interface or VTY line. Cuts the noise from ACLs that exist but are not referenced anywhere.
--skip-applied-checkboolDo not read the running config to find where ACLs are applied. One fewer command per device, but severity becomes less accurate.
--min-severitytextOnly report findings at or above this severity: HIGH, MEDIUM, or LOW. Default: LOW
--read-timeoutnumberSeconds to wait per command. A device with hundreds of ACLs is slow. Default: 120
--workersnumberHow many devices to contact at once. Default: 10
--json-outtextWrite machine-readable findings to this file, useful as audit evidence.
What This Solves
You have ACLs and firewall policies spread across routers, switches and firewalls from different vendors. Some of them permit far more than anyone intended. Nobody knows which ones, because checking means logging into every device by hand and reading output that looks different on each platform. This script does that walk for you, and answers two questions that a grep for "permit any" cannot: Is the permissive rule LIVE? "permit ip any any" in an ACL bound to your internet edge is an open door. The same line in an ACL that nothing references is latent risk - worth cleaning up, but not passing traffic today. The script reads the running config to tell them apart, and reports them as HIGH and MEDIUM respectively. Is the rule even reachable? Once a line permits everything, every line below it is dead config. Dead config is where permissions nobody remembers granting survive for years, and where an audit finds them before you do. Use it before an audit, after inheriting a network you did not build, or on a weekly cron so drift never accumulates for more than seven days.
Is It Safe to Run
Yes. It is strictly read-only. It never enters configuration mode and it never writes anything to a device. These are the only commands it sends - one per device, two if the binding check runs: Cisco IOS / IOS-XE / NX-OS / Arista EOS show ip access-lists show running-config | include ip access-group|access-class Cisco IOS-XR show access-lists show running-config | include ipv4 access-group Juniper Junos show configuration firewall | display set show configuration | display set | match filter Fortinet FortiOS show firewall policy Palo Alto PAN-OS show running security-policy It does send "enable" on platforms that need privileged exec, because reading the running config requires it. If your account cannot, the script still runs - see When Something Fails. The only thing it ever writes is a file on your own machine, and only if you ask for it with --json-out.
Before You Start
Three things, once. 1. Python 3.9 or newer. Check what you have: python --version 2. The two libraries this script imports: pip install "netmiko>=4.3" "PyYAML>=6.0" If pip is not found, use "python -m pip install" instead. 3. SSH access to the devices from the machine you are running this on, with an account that can read the running config. Prove it by hand first - the script cannot do anything your own SSH session cannot: ssh admin@192.168.1.1 show ip access-lists If that last command works for you manually, the script will work.
Your First Run
Start with ONE device. Not your whole fleet, and not your internet edge. Pick an access switch you are comfortable with, confirm the output looks sane, then widen. Step 1 - create a file named devices.yaml next to the script, with a single device in it: devices: - host: 192.168.1.1 os: iosxe Replace the address, and set os to match the platform. The full list is in All Valid OS Values below. Getting it wrong produces an error rather than a wrong answer - the script will not guess. Step 2 - put your credentials in the environment, not in the file: export NET_USER='admin' export NET_PASS='your-password' On Windows PowerShell: $env:NET_USER = 'admin' $env:NET_PASS = 'your-password' Step 3 - run it: python acl_analyzer.py --inventory devices.yaml That is the whole first run. It connects, sends the show commands listed above, prints what it found, and exits. Nothing changed on the device. Once that looks right, add the rest of your devices to devices.yaml. On a large fleet, widen gradually with --limit, which matches on the host name: python acl_analyzer.py --inventory devices.yaml --limit sw-access
What the Output Means
A run against five devices looks like this: $ export NET_USER='admin' && export NET_PASS='' $ python acl_analyzer.py --inventory devices.yaml Analysing ACLs and firewall policies on 5 device(s)... ==================================================================================== [OK] sw-access-11 (ios) no permissive rules (4 ACL(s) applied to interfaces) rtr-edge-01 (iosxe) - 3 finding(s) [HIGH ] OUTSIDE_IN line 12 (applied) permit ip any any - every protocol, every source, every destination [MEDIUM] LEGACY_TEST line 3 (NOT applied anywhere) permit <proto> any any - any source to any destination [LOW ] OUTSIDE_IN line 13 (applied) unreachable - line 12 already permits this traffic fw-edge-02 (fortios) - 1 finding(s) [HIGH ] policy 17 (TEMP-ALLOW) line 17 (applied) srcaddr all + dstaddr all and service ALL + action accept ==================================================================================== 2 HIGH, 1 MEDIUM, 1 LOW across 5 device(s). Reading it line by line: [OK] sw-access-11 Nothing permissive found. The count in brackets confirms the binding check ran and saw 4 ACLs actually in use - useful proof that your account had enough privilege to look. [HIGH] OUTSIDE_IN line 12 (applied) A rule that permits everything, in an ACL bound to an interface. This one is live. Fix it first. [MEDIUM] LEGACY_TEST line 3 (NOT applied anywhere) Equally permissive, but nothing references this ACL, so no traffic is passing through it today. It becomes a HIGH the moment somebody binds it to an interface, which is why it is reported rather than ignored. [LOW] OUTSIDE_IN line 13 (unreachable) Dead config. Line 12 above it already permits this traffic, so line 13 can never match. Harmless in itself, but it means the ACL has not been reviewed in a long time - and removing line 12 would suddenly make line 13 live. What the severities mean, in one table: HIGH permissive AND the ACL is bound to an interface or VTY line MEDIUM permissive, but nothing references the ACL MEDIUM permit with "any" in only one of source or destination LOW unreachable rule, or a permissive rule that is administratively disabled One honest limitation: on FortiOS and PAN-OS there is no binding check, because a firewall policy rule is part of the active policy by definition - there is no equivalent of an unbound ACL. Every finding on those two platforms is reported as applied. The "NOT applied anywhere" distinction appears only on the Cisco platforms, Arista and Junos. To cut the list down while you work through it: python acl_analyzer.py --inventory devices.yaml --min-severity HIGH python acl_analyzer.py --inventory devices.yaml --applied-only
Exit Codes for Cron and CI
The script returns a code, so it can gate a pipeline or fire an alert without anyone reading the output: 0 clean - no HIGH findings, and every device answered 1 at least one HIGH finding, OR a device could not be reached 130 you pressed Ctrl-C Note that 1 covers both "found something serious" and "could not check". That is deliberate - a device that did not answer is not a pass. To tell them apart, use --json-out and read the per-device status field. A weekly audit that keeps its own evidence: 0 6 * * 1 cd /opt/netaudit && python acl_analyzer.py \ --inventory devices.yaml --json-out /var/log/acl-audit.json
The Inventory File
devices.yaml tells the script WHICH devices to connect to. It is plain text in YAML format, and it lives wherever you point --inventory at it. Credentials do NOT go in it. See Credentials below. devices: - host: 192.168.1.1 # REQUIRED: IP address or DNS name os: iosxe # REQUIRED: which OS the device runs - host: 192.168.1.2 os: nxos - host: fw-edge.example.com os: panos The os field selects both the command to send and the parser to use - ACL output has a completely different shape on every platform, so this cannot be auto-detected reliably. Optional fields on any device: port: 2222 SSH port, if not the default 22 device_name: Edge A label for the output, instead of the host username: admin Overrides NET_USER for this one device password: secret Overrides NET_PASS - avoid, use the environment secret: enablepw Enable secret, for devices that require one A defaults block applies to every device below it, unless that device overrides the value: defaults: os: iosxe port: 22 devices: - host: 192.168.1.1 # inherits os: iosxe - host: 192.168.1.2 # inherits os: iosxe - host: 192.168.1.3 os: nxos # except this one 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.1 correct host:192.168.1.1 WRONG - YAML parse error * Values with special characters need quotes. password: "p@ss: word"
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 - firewall filters eos Arista EOS all Arista switches panos Palo Alto PAN-OS PA firewalls, VM-Series - security rules fortios Fortinet FortiOS FortiGate firewalls - firewall policies "cisco" is a vendor, not an OS. Use ios, iosxe, iosxr or nxos.
Credentials
Read from the environment. Never from the inventory file, and never from a command-line argument. Both of those are deliberate. Inventory files get committed to git, and a password in git is a security incident. A command-line argument is visible to every user on the machine in "ps auxww" while the script runs, and it lands in your shell history afterwards. 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 These last only for the current terminal session. Open a new terminal and you must set them again. That is the point - the password never lands on disk.
Every Flag
--inventory PATH REQUIRED. Your devices.yaml. --limit TEXT Only devices whose host name contains TEXT. The safe way to widen from one device to many. --applied-only Report only rules in an ACL that is bound to an interface or VTY line. Removes the noise from ACLs that exist but are referenced by nothing. --skip-applied-check Do not read the running config to find the bindings. One fewer command per device, but every finding falls back to the conservative severity. Use it when your account cannot read the running config. --min-severity LEVEL HIGH, MEDIUM or LOW. Default LOW, which shows everything. --read-timeout SECONDS Per command. A device with hundreds of ACLs is slow to answer. Default 120. --workers N How many devices to contact at once. Default 10. --json-out PATH Write machine-readable findings to a file. This is your audit evidence, and the only way to see the per-device status.
When Something Fails
"Set NET_USER and NET_PASS" The credentials are not in this terminal's environment. Usually you opened a new terminal. Set them again - see Credentials. ModuleNotFoundError: No module named 'netmiko' The pip install step was skipped. See Before You Start. "authentication failed" Wrong username or password, or the account lacks access. Verify by connecting manually first: ssh admin@192.168.1.1 "timeout" / "unreachable" The device is not answering on the SSH port. Check with: ping 192.168.1.1 ssh -p 22 admin@192.168.1.1 A firewall between you and the device is the usual cause. If only some devices time out, raise --read-timeout; if all of them do, it is the path, not the timeout. "unsupported os 'cisco'" The os value in your inventory is not in the list above. Cisco is a vendor. Use ios, iosxe, iosxr or nxos. "mapping values are not allowed here" A YAML syntax error in devices.yaml. Almost always a tab character instead of spaces, or a missing space after a colon. Everything shows as "NOT applied anywhere" The script could not read the running config, so it does not know which ACLs are bound. This is normally a privilege problem - the account can run "show ip access-lists" but not "show running-config". Severity falls back to the conservative rating, so nothing is missed; it is only rated lower than it may deserve. Either raise the account's privilege level, or pass --skip-applied-check to stop trying. A rule is flagged unreachable but you know it matches The check is positional: once an earlier line permits all the traffic in question, nothing below it can be reached. If that earlier line has since been removed, re-run - the analysis is always based on the live ACL, never on a cached copy. Nothing is reported but you expect findings Check that --min-severity is not filtering, and that --applied-only is not hiding unbound ACLs. Run with no flags at all to see everything.