automate fortigate security policy review in detail — a critical topic for network security engineers managing enterprise FortiGate environments.
Manual FortiGate policy reviews don’t scale. In a 400-policy environment, a thorough manual audit takes a skilled engineer 6–8 hours to complete reliably. In a 1,200-policy environment with multiple VDOMs, that same review can consume an entire week — and that’s assuming nothing changes while you’re doing it. This post documents practical automation techniques that reduce policy review time from days to hours while improving consistency and coverage.
The goal isn’t to eliminate human judgment from firewall policy review. It’s to automate the data collection, pattern detection, and report generation that currently consume most of the review time, so engineers can focus on the decisions that actually require expertise.
What Automation Can and Cannot Replace
Before writing a single script, it’s worth being clear about boundaries. Automation is effective for:
- Identifying policies with zero hit counts over a defined period
- Flagging any/any rules, disabled logging, missing UTM profiles
- Detecting duplicate or shadow rules mathematically
- Generating consistent audit reports across review cycles
- Tracking configuration drift between snapshots
Automation cannot replace:
- Business context — understanding why a policy exists
- Risk assessment — deciding whether a flagged policy is acceptable
- Remediation decisions — choosing between fix, document, or remove
With this framing, let’s build a practical automation pipeline.
Step 1: Configuration Extraction
All analysis starts with a clean configuration snapshot. The most reliable extraction method is scheduled backup via TFTP or SCP.
# Scheduled backup via FortiGate automation stitch (FortiOS 7.0+)
config system automation-stitch
edit "weekly-backup"
set trigger "weekly-trigger"
set action "backup-config"
next
end
config system automation-trigger
edit "weekly-trigger"
set trigger-type scheduled
set trigger-weekday sunday
set trigger-hour 2
set trigger-minute 0
next
end
config system automation-action
edit "backup-config"
set action-type backup
set ftp-server 192.168.100.10
set ftp-filename "fortigate_$(date +%Y%m%d).conf"
next
end
# Manual extraction for immediate analysis
execute backup config tftp analysis_$(date +%Y%m%d_%H%M).conf 192.168.100.10
# Extract policy hit counts separately (not in config backup)
diagnose firewall iprope show 100004 > /tmp/hit_counts.txt
execute upload tftp /tmp/hit_counts.txt 192.168.100.10
Python-Based Extraction via SSH
import paramiko
import datetime
def extract_fortigate_config(host, port, username, password):
# Connect to FortiGate via SSH and extract policy data.
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(host, port=port, username=username, password=password, timeout=30)
commands = [
'show firewall policy',
'show firewall address',
'show firewall addrgrp',
'show firewall service custom',
'show firewall service group',
'diagnose firewall iprope show 100004',
]
results = {}
for cmd in commands:
_, stdout, _ = client.exec_command(cmd, timeout=60)
results[cmd] = stdout.read().decode('utf-8')
client.close()
return results
# Usage
config_data = extract_fortigate_config(
host='192.168.1.1',
port=22,
username='audit-reader',
password='read-only-pass'
)
# Save timestamped snapshot
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M')
with open(f'fortigate_snapshot_{timestamp}.txt', 'w') as f:
for cmd, output in config_data.items():
f.write(f'# Command: {cmd}
{output}
')
Step 2: Policy Parsing
FortiGate configuration files follow a consistent structure that parses reliably with Python.
import re
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class FirewallPolicy:
id: int
name: str = ''
srcintf: List[str] = field(default_factory=list)
dstintf: List[str] = field(default_factory=list)
srcaddr: List[str] = field(default_factory=list)
dstaddr: List[str] = field(default_factory=list)
action: str = 'deny'
service: List[str] = field(default_factory=list)
logtraffic: str = 'disable'
utm_status: str = 'disable'
status: str = 'enable'
comments: str = ''
av_profile: str = ''
ips_sensor: str = ''
hit_count: int = 0
def parse_policies(config_text: str) -> List[FirewallPolicy]:
# Parse FortiGate firewall policy block into structured objects.
policies = []
# Extract policy block
policy_block = re.search(
r'config firewall policy
(.*?)^end',
config_text, re.DOTALL | re.MULTILINE
)
if not policy_block:
return policies
# Split into individual policy edits
policy_texts = re.split(r'
edit ', policy_block.group(1))
for ptext in policy_texts[1:]: # Skip empty first element
lines = ptext.strip().split('
')
try:
policy_id = int(lines[0].strip())
except (ValueError, IndexError):
continue
policy = FirewallPolicy(id=policy_id)
for line in lines:
line = line.strip()
if line.startswith('set name '):
policy.name = line.split('"')[1] if '"' in line else line[9:]
elif line.startswith('set action '):
policy.action = line.split()[-1]
elif line.startswith('set logtraffic '):
policy.logtraffic = line.split()[-1]
elif line.startswith('set utm-status '):
policy.utm_status = line.split()[-1]
elif line.startswith('set status '):
policy.status = line.split()[-1]
elif line.startswith('set srcaddr '):
policy.srcaddr = re.findall(r'"([^"]+)"', line)
elif line.startswith('set dstaddr '):
policy.dstaddr = re.findall(r'"([^"]+)"', line)
elif line.startswith('set service '):
policy.service = re.findall(r'"([^"]+)"', line)
elif line.startswith('set av-profile '):
policy.av_profile = re.findall(r'"([^"]+)"', line)[0] if '"' in line else ''
elif line.startswith('set ips-sensor '):
policy.ips_sensor = re.findall(r'"([^"]+)"', line)[0] if '"' in line else ''
policies.append(policy)
return policies
Step 3: Automated Issue Detection
from enum import Enum
class RiskLevel(Enum):
CRITICAL = 'CRITICAL'
HIGH = 'HIGH'
MEDIUM = 'MEDIUM'
INFO = 'INFO'
def analyze_policies(policies):
# Run automated checks against parsed policies.
findings = []
for p in policies:
if p.status == 'disable':
continue # Skip disabled policies
# Check: Any/Any source or destination
if 'all' in [a.lower() for a in p.srcaddr] or 'all' in [a.lower() for a in p.dstaddr]:
if p.action == 'accept':
findings.append({
'policy_id': p.id,
'policy_name': p.name,
'risk': RiskLevel.CRITICAL,
'issue': 'Any/Any accept rule',
'detail': f'srcaddr={p.srcaddr} dstaddr={p.dstaddr}'
})
# Check: Logging disabled on accept rules
if p.action == 'accept' and p.logtraffic == 'disable':
findings.append({
'policy_id': p.id,
'policy_name': p.name,
'risk': RiskLevel.HIGH,
'issue': 'Logging disabled on accept policy',
'detail': f'logtraffic={p.logtraffic}'
})
# Check: Accept rule without UTM
if p.action == 'accept' and p.utm_status == 'disable':
findings.append({
'policy_id': p.id,
'policy_name': p.name,
'risk': RiskLevel.MEDIUM,
'issue': 'Accept policy without UTM profile',
'detail': 'No AV, IPS, or web filtering applied'
})
# Check: Zero hit count (shadow/unused rule candidate)
if p.hit_count == 0:
findings.append({
'policy_id': p.id,
'policy_name': p.name,
'risk': RiskLevel.MEDIUM,
'issue': 'Zero hit count — possible shadow or unused rule',
'detail': f'Policy has matched 0 packets since last reset'
})
# Check: Missing name
if not p.name:
findings.append({
'policy_id': p.id,
'policy_name': '(unnamed)',
'risk': RiskLevel.INFO,
'issue': 'Unnamed policy',
'detail': 'Policy has no descriptive name'
})
return findings
# Run analysis
policies = parse_policies(config_text)
findings = analyze_policies(policies)
# Summary
from collections import Counter
risk_counts = Counter(f['risk'].value for f in findings)
print(f"Analysis complete: {len(policies)} policies, {len(findings)} findings")
for risk, count in risk_counts.items():
print(f" {risk}: {count}")
Step 4: Automated Report Generation
import csv
import datetime
def generate_csv_report(findings, output_file):
# Export findings to CSV for review.
fieldnames = ['policy_id', 'policy_name', 'risk', 'issue', 'detail',
'reviewed', 'disposition', 'reviewer_notes']
with open(output_file, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for finding in sorted(findings, key=lambda x: list(RiskLevel).index(x['risk'])):
row = {k: finding.get(k, '') for k in fieldnames[:5]}
row['reviewed'] = 'No'
row['disposition'] = ''
row['reviewer_notes'] = ''
writer.writerow(row)
print(f"Report saved: {output_file}")
def generate_html_report(policies, findings, output_file):
# Generate an HTML audit report.
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
html = "FortiGate Policy Audit Report
"
for f in sorted(findings, key=lambda x: list(RiskLevel).index(x['risk'])):
risk_class = f['risk'].value
html += f"{f['policy_id']} {f['policy_name']} "
html += f"{f['risk'].value} "
html += f"{f['issue']} {f['detail']}
"
html += ""
with open(output_file, 'w', encoding='utf-8') as out:
out.write(html)
print(f"HTML report saved: {output_file}")
# Generate both report formats
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M')
generate_csv_report(findings, f'audit_{timestamp}.csv')
generate_html_report(policies, findings, f'audit_{timestamp}.html')
Step 5: Configuration Drift Detection
One of the highest-value automation capabilities is detecting when the running configuration diverges from the approved baseline. This catches unauthorized changes before they become security incidents.
import hashlib
import json
def snapshot_policy_hashes(policies):
# Create a fingerprint of each policy for drift detection.
hashes = {}
for p in policies:
# Create a canonical representation
canonical = json.dumps({
'action': p.action,
'srcaddr': sorted(p.srcaddr),
'dstaddr': sorted(p.dstaddr),
'service': sorted(p.service),
'logtraffic': p.logtraffic,
'utm_status': p.utm_status,
}, sort_keys=True)
hashes[p.id] = hashlib.sha256(canonical.encode()).hexdigest()[:12]
return hashes
def compare_snapshots(baseline_hashes, current_hashes):
# Identify added, removed, and modified policies.
baseline_ids = set(baseline_hashes.keys())
current_ids = set(current_hashes.keys())
added = current_ids - baseline_ids
removed = baseline_ids - current_ids
modified = {pid for pid in baseline_ids & current_ids
if baseline_hashes[pid] != current_hashes[pid]}
return {
'added': sorted(added),
'removed': sorted(removed),
'modified': sorted(modified)
}
# Weekly comparison workflow
baseline = snapshot_policy_hashes(parse_policies(last_week_config))
current = snapshot_policy_hashes(parse_policies(this_week_config))
drift = compare_snapshots(baseline, current)
print(f"Configuration drift detected:")
print(f" Added policies: {drift['added']}")
print(f" Removed policies: {drift['removed']}")
print(f" Modified policies: {drift['modified']}")
# Any drift triggers a ticket
if any(drift.values()):
create_change_ticket(drift, priority='medium')
Scheduling and Integration
# Windows Task Scheduler (for environments running review scripts on Windows)
# Run weekly audit every Sunday at 3 AM
# scheduled_audit.ps1
$timestamp = Get-Date -Format "yyyyMMdd_HHmm"
$logfile = "C:udit_logsudit_${timestamp}.log"
# Extract config
& python fortigate_extract.py --host 192.168.1.1 --output "configs\${timestamp}.conf" 2>&1 | Tee-Object $logfile
# Run analysis
& python analyze_policies.py --input "configs\${timestamp}.conf" --report "reportsudit_${timestamp}" 2>&1 | Tee-Object -Append $logfile
# Email results if critical findings
& python email_report.py --report "reportsudit_${timestamp}.html" --threshold CRITICAL 2>&1 | Tee-Object -Append $logfile
# Linux cron equivalent
# 0 3 * * 0 /opt/fortigate-audit/run_weekly_audit.sh >> /var/log/fortigate-audit/weekly.log 2>&1
For teams that prefer a ready-made analysis tool rather than building from scratch, the APO Tool provides policy analysis from a FortiGate config file without requiring API access or network connectivity to the firewall. It’s particularly useful for air-gapped environments where scripted SSH access isn’t possible.
Measuring ROI of Policy Review Automation
| Metric | Manual Review | Automated Pipeline | Improvement |
|---|---|---|---|
| Time per review cycle | 6–8 hours | 45 minutes | 87% reduction |
| Policy coverage | Sample-based (~60%) | 100% of policies | Complete coverage |
| Detection consistency | Varies by reviewer | Deterministic | Eliminates variation |
| Drift detection latency | Next review cycle | Within 24 hours | Near real-time |
| Audit trail quality | Manual notes | Structured CSV + HTML | Significantly improved |
These numbers come from a 2023 implementation at a 650-employee logistics company with 3 FortiGate clusters. The automation pipeline took approximately 40 hours to build and has saved an estimated 300+ engineer-hours per year since deployment.
Conclusion
Automating FortiGate policy review isn’t about replacing engineers — it’s about making review cycles fast enough to happen frequently. A review that takes 8 hours happens quarterly at best. A review that takes 45 minutes happens weekly. The frequency change alone dramatically improves the security posture of the environment.
Start with the extraction and parsing steps described here, build the specific checks your environment needs, and layer in drift detection once the baseline is stable. The investment pays back within the first review cycle.
Related Articles
- FortiGate Policy Optimization: A Complete Guide for Network Engineers
- APO Tool: FortiGate Firewall Policy Analyzer for Network Engineers
- Automated Compliance Checking: NIST 800-53 Controls on Network Devices
- FortiGate vs Palo Alto Policy Management: Key Differences Explained

