We're going to switch gears from the previous couple posts and dive into more of a compliance topic. It is quite common to have to provide network firewall Access Control List (ACL) reports to auditors, or even on a recurring basis for compliance to regulatory requirements such as SOC or FedRAMP. Unfortunately, if you use a FortiGate firewall there is no method of easily exporting or reporting these ACLs. My solution was to create the PowerShell script below which parses an export of the firewall's configuration and outputs each ACL into a readable CSV file.
FortiGate firewalls are an excellent compromise between cost and functionality. In my experience, they meet the definition of a Next-Gen firewall, providing options such as VPN support (both IPSec and TLS), SD-WAN, load balancing, TLS offloading, built-in URL filtering lists for common applications, and an adequate Intrustion Detection System/Intrusion Prevention System (IDS/IPS). Most likely, the security features of the FortiGate are sufficient to meet regulatory compliance requirements for edge network device protection of your network, at a fraction of the cost of other Next-Gen firewalls. Would Fortigate be my first choice if money wasn't a factor? Probably not, but I'll admit they started to grow on me after working with them for the last few years. If you're new to FortiGate, I suggest you check out FortiGate Guru. He is very knowledgeable, and his tutorials are highly recommended.
This script will parse the firewall's configuration file and list out the following fields for each Firewall Policy you have configured in a comma separated value (CSV) file.
- Policy ID
- Policy Name
- Source Interface
- Destination Interface
- Source Address
- Destination Address
- Action (i.e., Allow or Deny)
- Service
- Comments
- Status (i.e., Enabled or Disabled)
Caveats:
- I was able to utilize this script for FortiGate version 6.4.X and 7.0.X firewalls. In the future it may need slight modifications if the vendor decides to modify the configuration file format.
- If somehow you are using features I hadn't run across you may also need to tweak the script to account for that functionality.
To run this script, you'll need to export your firewall's configuration. Elect to not encrypt the file, otherwise this script will not be able to read it. Instructions for exporting configuration can be found here.
Modern firewall management best practices make heavy use of aliases and groupings of address objects and this script attempts to translate those aliases and groups back to their IP address or CIDR address formats. This may require some deciphering in the Source Address and Destination Address columns, especially for those non-technical auditors and security analysts who may not fully understand networking concepts. But hey, we're parsing a config file and making it more readable, what do they expect? If you expect this reaction, maybe start by just providing them the raw config file. Perhaps they'll be more grateful to receive this report ;-)
Let's walk through an example to help break this down.
So, say I have an ACL allowing database access from several application servers to a network of database servers. The names of the servers, the IP addresses, and aliases are as follows.
Application Servers:
DevApp1 - 192.168.5.1 - Alias: DevApp1
TestApp1 - 192.168.6.1 - Alias: TestApp1
TestApp2 - 192.168.6.2 - Alias: TestApp2
Database Server Network:
ProdDBs - 192.168.7.0/29 - Alias: ProdDBs
Additionally, I have a Alias Group defined for the Application servers:
DevTestAppServers: DevApp1, TestApp1, TestApp2
In my Firewall Policy I only have to define the Source Address as DevTestAppServers and the Destination Address as ProdDBs. Within the FortiGate GUI, I can hover my cursor over each of these objects and the underlying addresses magically appears, allowing a better understanding of the policy. Not so much when looking directly at the config file. So reading through the script below, you'll note that I recursively translate any alias objects used.
Back to our example, the output of the Source and Destination columns in our report would be as follows.
Source Address:
DevTestAppServers (DevApp1 (192.168.5.1 255.255.255.255) TestApp1 (192.168.6.1 255.255.255.255) TestApp2 (192.168.6.2 255.255.255.255))
Destination Address:
ProdDBs (192.168.7.0 255.255.255.248)
Note that the DevTestAppServers object was resolved to three further objects, which were resolved down to their respective IP addresses. A subnet mask of all 111s (/32 or 255.255.255.255) means a single IP address. The ProdDBs object was resolved to a single CIDR network with a /29 subnet mask (255.255.255.248). If your auditor/security team is still confused, refer them to a good subnet mask cheat sheet.
IP range objects will be listed with the to and from IP range, without a subnet mask.
DevWebServers (192.168.8.1-192.168.8.5)
Below is an example of the report output for the entries above.
Note that the default deny policy is not included in this report as it is not included in the firewall config file. This may need to be communicated to the auditor/security team. In my experience, showing that the default deny policy exists in the firewall and cannot be removed is enough, though it depends on the auditor and how he or she is feeling that particular day. Worst case, you could add it to the report either manually or via PowerShell.
On to the script. Hopefully I've peppered it with enough comments to make it understandable. To use it, download the unencrypted config file from your firewall and update the script with the path to your config at line 6, setting the $InputFile variable. Then run the script, which will output the report to your Downloads folder.
A downloadable .ps1 version of the script is available here (right-click link and Save As...).
Happy Reporting!
# Title: Fortigate ACL Report Generator
# Description: Takes the Firewall Config file as input and parses out the ACL rules to facilitate Firewall Reviews and Reporting.
# This verbose version also includes the underlying IP addresses from Address groups and aliases.
# Specify the Fortigate configuration file to parse
$InputFile = "$home\Downloads\MY_FIREWALL_CONFIG_CHANGE_ME.conf"
# Define recursive function for determining the address from an Alias
function TranslateAddressAlias($ParameterList)
{
$AddressList = $ParameterList[0]
$AddressAlias = $ParameterList[1]
$DepthCount = $ParameterList[2]
$TranslatedAddress = ""
# Ensure recursion doesn't go indefinitely
$DepthCount += 1
if ($DepthCount -gt 50)
{
Return "Error: Recursion too deep"
}
# Look in the Address list for the Address Alias
$LookupValues = $AddressList | Where-Object {$_.AddressAlias -eq $AddressAlias}
# Check if we found something
$LookupMeasure = $LookupValues | measure
if ($LookupMeasure.Count -ne 0)
{
$TranslatedAddress += $AddressAlias + " ("
# We found the translated address. Loop since there may be multiple values
foreach ($value in $LookupValues)
{
if ($value.AddressAlias -eq $value.Address)
{
# If the values are the same, return the value to prevent infinite recusion
$ReturnArray = $value.Address
}
else
{
# Recursively look to see if this is an Alias
$ReturnArray = TranslateAddressAlias($AddressList, $Value.Address, $DepthCount)
}
if ($LookupMeasure.Count -eq 1)
{
# If this is an Address Alias just append the value (avoids extra paranthesis)
$TranslatedAddress += $ReturnArray
}
else
{
# There are multiple values so enclose them in an extra set of paranthesis
$TranslatedAddress += "(" + $ReturnArray + ") "
}
}
$TranslatedAddress = $TranslatedAddress.TrimEnd()
$TranslatedAddress += ")"
}
else
{
# There is no alias for this value, so return the passed in value. Trim tailing whitespace
$TranslatedAddress = $AddressAlias.TrimEnd()
}
$ReturnValue = $TranslatedAddress.TrimEnd()
Return $ReturnValue
}
# Main Report Generation Logic
# Initialize the reporting array and variables
$ACLInfo = "" | select PolicyID, PolicyName, SrcInt, DstInt, SrcAddr, DstAddr, Action, Service, Comments, Status
$AddressInfo = "" | select AddressAlias, Address
$ACLReport = @()
$AddressLookup = @()
$HostName = "UNKNOWN"
$AddressName = ""
# Setup StreamReader for the Fortigate Config File
[System.IO.StreamReader] $ConfigStreamReader = New-Object `
-TypeName 'System.IO.StreamReader' `
-ArgumentList ($InputFile, $true)
# Read through each line of the config file
try
{
$FoundAddressGroups = $false
$FoundAddresses = $false
$FoundACLs = $false
$ReadAllACLs = $false
# Assumes config is in this order: Addresses, Address Groups, ACL config
while ($ReadAllACLs -eq $false)
{
$CurrentLine = $ConfigStreamReader.ReadLine()
# Look for the Fortgate HostName
if ($CurrentLine.StartsWith(' set hostname '))
{
$HostName = $CurrentLine.Substring(17, $CurrentLine.Length - 17)
}
# Block for Address parsing configuration
if ($FoundAddresses -eq $false)
{
if ($CurrentLine.Contains('config firewall address'))
{
# We found the Address Group configuration section
$FoundAddresses = $true
}
}
else
{
# Start parsing the address group section
if ($CurrentLine.Equals('end'))
{
# This is the end of the Adress Group configuration section.
$FoundAddresses = $false
}
else
{
# Here we are parsing through the address config section
$TrimmedLine = $CurrentLine.TrimStart()
# Check if this is the start of a new policy
if ($TrimmedLine.StartsWith('edit '))
{
$AddressInfo.AddressAlias = $TrimmedLine.Substring(5, $TrimmedLine.Length -5).Trim('"')
}
# Check if this is the subnet
if ($TrimmedLine.StartsWith('set subnet '))
{
$AddressInfo.Address = $TrimmedLine.Substring(11, $TrimmedLine.Length -11).Trim('"')
$AddressLookup += $AddressInfo.PSObject.Copy() # Use Copy() function otherwise only the reference is copied
}
# Check if this is the fqdn
if ($TrimmedLine.StartsWith('set fqdn '))
{
$AddressInfo.Address = $TrimmedLine.Substring(9, $TrimmedLine.Length -9).Trim('"')
$AddressLookup += $AddressInfo.PSObject.Copy() # Use Copy() function otherwise only the reference is copied
}
# Check if this is the start-ip
if ($TrimmedLine.StartsWith('set start-ip '))
{
$AddressInfo.Address = $TrimmedLine.Substring(13, $TrimmedLine.Length -13).Trim('"') + "-"
#$AddressLookup += $AddressInfo.PSObject.Copy() # Use Copy() function otherwise only the reference is copied
}
# Check if this is the end-ip
if ($TrimmedLine.StartsWith('set end-ip '))
{
$AddressInfo.Address += $TrimmedLine.Substring(11, $TrimmedLine.Length -11).Trim('"')
$AddressLookup += $AddressInfo.PSObject.Copy() # Use Copy() function otherwise only the reference is copied
}
}
}
# Block for Address Group parsing configuration
if ($FoundAddressGroups -eq $false)
{
if ($CurrentLine.Contains('config firewall addrgrp'))
{
# We found the Address Group configuration section
$FoundAddressGroups = $true
}
}
else
{
# Start parsing the address group section
if ($CurrentLine.Equals('end'))
{
# This is the end of the Adress Group configuration section.
$FoundAddressGroups = $false
}
else
{
# Here we are parsing through the address alias config section
$TrimmedLine = $CurrentLine.TrimStart()
# Check if this is the start of a new policy
if ($TrimmedLine.StartsWith('edit '))
{
$AddressName = $TrimmedLine.Substring(5, $TrimmedLine.Length -5)
}
# Check if this is one of the addresses in the address group (might be an alias)
if ($TrimmedLine.StartsWith('set member '))
{
$MemberLine = $TrimmedLine.Substring(11, $TrimmedLine.Length -11).Replace('" ',',').Replace('"', '')
# There may be multiple addresses here, separated by a space
$Members = $MemberLine.Split(',')
# Add a new row for each address
foreach ($Member in $Members)
{
$AddressInfo.AddressAlias = $AddressName.Replace('"', '')
$AddressInfo.Address = $Member.Trim()
$AddressLookup += $AddressInfo.PSObject.Copy() # Use Copy() function otherwise only the reference is copied
}
}
}
}
# Block for ACL parsing configuration
if ($FoundACLs -eq $false)
{
# We haven't yet found the ACL configuration
if ($CurrentLine.Contains('config firewall policy'))
{
# We found the ACL configuration section - Huzzah!
$FoundACLs = $true
}
}
else
{
# We are currently in the ACL configuration section. Look for the end of this section
if ($CurrentLine.Equals('end'))
{
# This is the end of the ACL configuration section. No need to look any further at this file
$ReadAllACLs = $true
}
else
{
# Here we are parsing through the config section
$TrimmedLine = $CurrentLine.TrimStart()
#$TrimmedLine = $TrimmedLine.Replace('"', '')
# Check if this is the start of a new policy
if ($TrimmedLine.StartsWith('edit '))
{
$ACLInfo.PolicyID = $TrimmedLine.Substring(5, $TrimmedLine.Length -5)
$ACLInfo.Action = "deny" # Unless action is specifically allowed, it is denied
$ACLInfo.Status = "enabled" # Unless status is specifically set, it is enabled
}
# check if this is the end of a policy
if ($TrimmedLine.Equals('next'))
{
# Write the data to our master report object
$ACLReport += $ACLInfo.PSObject.Copy() # Use Copy() function otherwise only the reference is copied
# Clean out of the temp object for the next policy
$ACLInfo.PolicyID = ''
$ACLInfo.PolicyName = ''
$ACLInfo.SrcInt = ''
$ACLInfo.DstInt = ''
$ACLInfo.SrcAddr = ''
$ACLInfo.DstAddr = ''
$ACLInfo.Action = ''
$ACLInfo.Service = ''
$ACLInfo.Comments = ''
$ACLInfo.Status = ''
}
# Check if this is the name
if ($TrimmedLine.StartsWith('set name '))
{
$ACLInfo.PolicyName = $TrimmedLine.Substring(9, $TrimmedLine.Length -9).Replace('" ', ', ').Replace('"','')
}
# Check if this is the Source Interface
if ($TrimmedLine.StartsWith('set srcintf '))
{
$ACLInfo.SrcInt = $TrimmedLine.Substring(12, $TrimmedLine.Length -12).Replace('" ', ', ').Replace('"','')
}
# Check if this is the Destination Interface
if ($TrimmedLine.StartsWith('set dstintf '))
{
$ACLInfo.DstInt = $TrimmedLine.Substring(12, $TrimmedLine.Length -12).Replace('" ', ', ').Replace('"','')
}
# Check if this is the Source Address
if ($TrimmedLine.StartsWith('set srcaddr '))
{
#$ACLInfo.SrcAddr = $TrimmedLine.Substring(12, $TrimmedLine.Length -12).Replace('" ', ', ').Replace('"','')
$TranslatedSources = ""
$ListOfSources = $TrimmedLine.Substring(12, $TrimmedLine.Length -12).Replace('" ', ',').Replace('"','')
$SourceArray = $ListOfSources.Split(',')
foreach ($source in $SourceArray)
{
$TranslatedSources += TranslateAddressAlias($AddressLookup, $source, 0)
$TranslatedSources += ", "
}
$AclInfo.SrcAddr = $TranslatedSources.TrimEnd(", ")
}
# Check if this is the Destination Address
if ($TrimmedLine.StartsWith('set dstaddr '))
{
#$ACLInfo.DstAddr = $TrimmedLine.Substring(12, $TrimmedLine.Length -12).Replace('" ', ', ').Replace('"','')
$TranslatedDest = ""
$ListOfDests = $TrimmedLine.Substring(12, $TrimmedLine.Length -12).Replace('" ', ',').Replace('"','')
$DestArray = $ListOfDests.Split(',')
foreach ($dest in $DestArray)
{
$TranslatedDest += TranslateAddressAlias($AddressLookup, $dest, 0)
$TranslatedDest += ", "
}
$ACLInfo.DstAddr = $TranslatedDest.TrimEnd(", ")
}
# If Internet-Service is defined, set as Destination Address
if ($TrimmedLine.StartsWith('set internet-service-name '))
{
$ACLInfo.DstAddr = $TrimmedLine.Substring(26, $TrimmedLine.Length -26).Replace('" ', ', ').Replace('"','')
$ACLInfo.Service = "Internet Services" # The service is specific to the "Internet Service" used
}
# Check if this is the Action
if ($TrimmedLine.StartsWith('set action '))
{
$ACLInfo.Action = $TrimmedLine.Substring(11, $TrimmedLine.Length -11).Replace('" ', ', ').Replace('"','')
}
# Check if this is the Service
if ($TrimmedLine.StartsWith('set service '))
{
$ACLInfo.Service = $TrimmedLine.Substring(12, $TrimmedLine.Length -12).Replace('" ', ', ').Replace('"','')
}
# Check if this is the Action
if ($TrimmedLine.StartsWith('set comments '))
{
$ACLInfo.Comments = $TrimmedLine.Substring(13, $TrimmedLine.Length -13).Replace('" ', ', ').Replace('"','')
}
if ($TrimmedLine.equals('set status disable'))
{
$ACLInfo.Status = "disabled"
}
}
}
}
}
Catch
{
$ErrorText = "Error occurred during file parsing: " + $_
Write-Output $ErrorText
Exit
}
finally
{
$ConfigStreamReader.Close();
}
# Build output file path, appending with current timestamp, in the current user's Downloads directory
$Now = Get-Date -Format "yyyyMMddHHmm"
$HostName = $HostName.Trim('"')
$ReportFileName = "$home\Downloads\Fortigate_ACL_Report_" + $HostName + "_" + $Now + ".csv"
# Don't do any Sort operations on the report
# The ACL rules will be sorted based on sequence within the Config File.
# Write the master report out to a file
$ACLReport | Export-CSV -Path $ReportFileName -NoTypeInformation
# Output a success message to the screen
$Message = "File generated successfully: " + $ReportFileName
Write-Output $Message