Automating FortiGate Security Policy Review: Tools and Techniques

Automating FortiGate Security Policy Review: Tools and Techniques

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


External References