Loading_
Loading_
Connects to FortiGate over REST, pulls the policy table, and reports shadowed, unused, overly-permissive and undocumented rules.
#!/usr/bin/env python3"""FortiGate policy hygiene audit. Read-only. Produces a findings CSV covering shadowed, dormant, permissiveand undocumented policies. Usage: export FORTIGATE_TOKEN="..." python fortigate_policy_audit.py --host fw01.corp.local --vdom root Author : AIInfraEngineVersion: 2.4.0"""from __future__ import annotations import argparseimport csvimport osimport sysfrom dataclasses import dataclass, fieldfrom datetime import datetimefrom typing import Any import requestsimport urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) ANY = {"all", "any", "ALL"} @dataclassclass Finding: policy_id: int name: str severity: str category: str detail: str @dataclassclass Policy: id: int name: str srcaddr: set[str] dstaddr: set[str] service: set[str] action: str status: str comments: str hit_count: int last_used: str | None = None findings: list[Finding] = field(default_factory=list) @property def is_any_any(self) -> bool: return bool(self.srcaddr & ANY) and bool(self.dstaddr & ANY) def covers(self, other: "Policy") -> bool: """True when this policy fully shadows *other*.""" def superset(a: set[str], b: set[str]) -> bool: return bool(a & ANY) or b <= a return ( self.action == other.action and superset(self.srcaddr, other.srcaddr) and superset(self.dstaddr, other.dstaddr) and superset(self.service, other.service) ) class FortiGateClient: def __init__(self, host: str, token: str, vdom: str = "root", verify: bool = False): self.base = f"https://{host}/api/v2" self.vdom = vdom self.session = requests.Session() self.session.headers["Authorization"] = f"Bearer {token}" self.session.verify = verify def _get(self, path: str, **params: Any) -> list[dict]: params.setdefault("vdom", self.vdom) resp = self.session.get(f"{self.base}{path}", params=params, timeout=30) resp.raise_for_status() return resp.json().get("results", []) def policies(self) -> list[dict]: return self._get("/cmdb/firewall/policy") def policy_stats(self) -> dict[int, dict]: rows = self._get("/monitor/firewall/policy") return {int(r["policyid"]): r for r in rows} def to_names(entries: list[dict] | None) -> set[str]: return {e.get("name", "") for e in (entries or [])} or {"all"} def load_policies(client: FortiGateClient) -> list[Policy]: stats = client.policy_stats() out: list[Policy] = [] for raw in client.policies(): pid = int(raw["policyid"]) stat = stats.get(pid, {}) out.append( Policy( id=pid, name=raw.get("name") or f"policy-{pid}", srcaddr=to_names(raw.get("srcaddr")), dstaddr=to_names(raw.get("dstaddr")), service=to_names(raw.get("service")), action=raw.get("action", "deny"), status=raw.get("status", "enable"), comments=(raw.get("comments") or "").strip(), hit_count=int(stat.get("hit_count", 0)), last_used=stat.get("last_used"), ) ) return out def audit(policies: list[Policy], dormant_threshold: int = 0) -> list[Finding]: findings: list[Finding] = [] for index, policy in enumerate(policies): # 1. Shadowed — an earlier enabled rule fully covers this one. for earlier in policies[:index]: if earlier.status == "enable" and earlier.covers(policy): findings.append( Finding( policy.id, policy.name, "high", "shadowed", f"Fully covered by policy {earlier.id} ({earlier.name}); can never match.", ) ) break # 2. Permissive — any/any accept is almost always a leftover. if policy.action == "accept" and policy.is_any_any: findings.append( Finding(policy.id, policy.name, "critical", "permissive", "Accepts any source to any destination.") ) elif policy.action == "accept" and policy.service & ANY: findings.append( Finding(policy.id, policy.name, "medium", "permissive", "Accepts all services.") ) # 3. Dormant — enabled but never matched. if policy.status == "enable" and policy.hit_count <= dormant_threshold: findings.append( Finding(policy.id, policy.name, "medium", "dormant", f"Hit count {policy.hit_count} since last counter reset.") ) # 4. Undocumented. if not policy.comments: findings.append( Finding(policy.id, policy.name, "low", "undocumented", "No comment; owner and purpose unknown.") ) return findings def write_report(findings: list[Finding], path: str) -> None: with open(path, "w", newline="", encoding="utf-8") as fh: writer = csv.writer(fh) writer.writerow(["policy_id", "name", "severity", "category", "detail"]) for f in sorted(findings, key=lambda x: ("critical", "high", "medium", "low").index(x.severity)): writer.writerow([f.policy_id, f.name, f.severity, f.category, f.detail]) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host", required=True) parser.add_argument("--vdom", default="root") parser.add_argument("--verify-tls", action="store_true") parser.add_argument("--output", default=f"fortigate-audit-{datetime.now():%Y%m%d-%H%M%S}.csv") args = parser.parse_args() token = os.environ.get("FORTIGATE_TOKEN") if not token: print("error: FORTIGATE_TOKEN is not set", file=sys.stderr) return 2 client = FortiGateClient(args.host, token, args.vdom, verify=args.verify_tls) try: policies = load_policies(client) except requests.HTTPError as exc: print(f"error: FortiGate API returned {exc.response.status_code}", file=sys.stderr) return 1 findings = audit(policies) write_report(findings, args.output) by_severity: dict[str, int] = {} for f in findings: by_severity[f.severity] = by_severity.get(f.severity, 0) + 1 print(f"\n Policies analysed : {len(policies)}") for sev in ("critical", "high", "medium", "low"): print(f" {sev.capitalize():<17} : {by_severity.get(sev, 0)}") print(f" Report : {args.output}\n") return 1 if by_severity.get("critical") else 0 if __name__ == "__main__": raise SystemExit(main())Firewall policy tables accumulate. Rules are added under pressure and almost never removed, so after a few years the table contains rules that can never match, rules that have not matched in a year, and rules that permit any-to-any because someone was debugging at 2am.
This script pulls the policy table plus hit counters and produces four findings: shadowed rules (an earlier rule fully covers them), dormant rules (zero hits in the observation window), permissive rules (any source, destination or service), and undocumented rules (no comment).
It is read-only. Output is a CSV plus a summary, intended as the input to a cleanup change rather than an automatic deletion.
| Name | Type | Required | Description |
|---|---|---|---|
--host | string | Required | FortiGate management hostname or IP. |
--vdom | string | Optional | Target VDOM. Default root. |
--output | string | Optional | Findings CSV path. |
The platform turns any script into a governed automation — versioned, gated, audited and reversible.