AzureFwGitOps.psm1

# Formats JSON in a nicer format than the built-in ConvertTo-Json does.
# Thanks to Kody for providing this excellent function (https://stackoverflow.com/users/1754995/kody)
# https://stackoverflow.com/questions/57329639/powershell-convert-to-json-is-bad-format/57329852#57329852
function Format-Json([Parameter(Mandatory, ValueFromPipeline)][String] $json) {
    $indent = 0;
    ($json -Split '\n' | foreach-object {
        if ($_ -match '[\}\]]') {
        # This line contains ] or }, decrement the indentation level
        $indent--
        }
        $line = (' ' * $indent * 2) + $_.TrimStart().Replace(': ', ': ')
        if ($_ -match '[\{\[]') {
        # This line contains [ or {, increment the indentation level
        $indent++
        }
        $line
    }) -Join "`n"
}

Function ConvertFrom-ArmFw {
[cmdletbinding()]
Param(
    $ArmFolder,
    $PolicyFolder,
    [switch]$Merge,
    $Delimiter = ',',
    $Changes
)
    #Extract all resources from ARM files
    $resources = @()
    Get-ChildItem -LiteralPath $ArmFolder -filter *.json | Foreach-Object{
        $resources += [array](Get-Content $_.FullName | ConvertFrom-Json).resources
    }
    
    #Process all firewall policies and store in policies object (stored in policySettings.json)
    $policies = @()
    $resources | Where-Object {$_.type -eq 'Microsoft.Network/firewallPolicies'} | Foreach-object{
        $thisPolicy = $_
        $policies += [pscustomobject]@{
            "name" = $thisPolicy.name
            "childPolicies" = @($thisPolicy.properties.childpolicies.id)
            "linkedRuleCollectionGroups" = @($thisPolicy.properties.ruleCollectionGroups)
            "ruleCollectionGroups" = @()
        }
    }
    
    #Process all rule collection groups
    $ruleCollectionGroups = @()
    $resources | Where-Object {$_.type -eq 'Microsoft.Network/firewallPolicies/ruleCollectionGroups'} | Foreach-object{
        $thisRuleCollGroup = $_
        $object = [PSCustomObject]@{
            "name" = $thisRuleCollGroup.name
            "priority" = $thisRuleCollGroup.properties.priority
            "ruleCollections" = @()
        }
        $thisRuleCollGroup.properties.ruleCollections | Foreach-object {
            $object.ruleCollections += [pscustomobject]@{
                "name" = $_.name
                "priority" = $_.priority
                "action" = $_.action
            }
        }
        $ruleCollectionGroups += $object
    }
    
    #join ruleCollectionGroups and policies (in order to generate a policySettings.json)
    $policies | ForEach-Object{
        $thisPolicy = $_
        $thisRuleCollGroup = $ruleCollectionGroups | Where-Object{$_.name.split('/')[0] -eq $thisPolicy.name}
        If($thisPolicy.linkedRuleCollectionGroups.id.count -gt 0){
            $thisRuleCollGroupId = $thisPolicy.linkedRuleCollectionGroups.id | Where-Object{$_.split('/')[-1] -eq $thisRuleCollGroup.name.Split('/')[-1]}
            $object = [PSCustomObject]@{
                "id" = $thisRuleCollGroupId
                "name" = $thisRuleCollGroup.name
                "priority" = $thisRuleCollGroup.priority
                "ruleCollections" = $thisRuleCollGroup.ruleCollections
            }
            $thisPolicy.ruleCollectionGroups = $object
        }
    }

    $policies = $policies | Select-object -Property * -ExcludeProperty linkedRuleCollectionGroups

    #Compare if changes in policySettings.json to avoid overwriting settings
    #If($changes | where-object{$_.type -eq 'settings'}){
    # Compare-Object (($policies | ConvertTo-Json -depth 10) -split '\r?\n') (($changes.innerData | ConvertTo-Json -depth 10) -split '\r?\n')
    #
    # Compare-Object
    #
    #}

    #Save everything, except linkedRuleCollectionGroups as they are also part of the member ruleCollectionGroups
    $policies | Select-object -Property * -ExcludeProperty linkedRuleCollectionGroups | ConvertTo-Json -Depth 100 | Format-Json | Tee-Object $PolicyFolder\policySettings.json -Encoding utf8

    #Assert folders for firewall and ruleCollGroups
    $policies | Foreach-Object {
        $policyName = $_.name
        New-Item "$PolicyFolder\$policyName" -ItemType Directory -Force
        Try{
            $_.ruleCollectionGroups | Foreach-object{
                $ruleCollGroup = $_.name.split('/')[-1]
                New-Item "$PolicyFolder\$policyName\$ruleCollGroup" -ItemType Directory -Force
                $_.ruleCollections | Foreach-Object{
                    New-Item "$PolicyFolder\$policyName\$ruleCollGroup\$($_.name)" -ItemType Directory -Force
                }
            }
        }
        Catch{}
    }
    
    #Create CSV files, one per folder.
    $resources | Where-Object {$_.type -eq 'Microsoft.Network/firewallPolicies/ruleCollectionGroups'} | Foreach-object{
        $thisRuleCollGroup = $_
        $thisRuleCollGroup.properties.ruleCollections | Foreach-Object{
            $ruleColl = $_
            If($ruleColl.rules.count -ge 1){
                $thisCsvFile = "$PolicyFolder\$($thisRuleCollGroup.name)\$($ruleColl.name)\$($ruleColl.rules[0].ruleType).csv"
                
                # We sort the output for readability. This might cause issues if apiVersion breaks it. Just use default then.
                # https://docs.microsoft.com/en-us/azure/templates/microsoft.network/firewallpolicies/rulecollectiongroups?pivots=deployment-language-arm-template#firewallpolicyrule-objects-1
                Switch($ruleColl.rules[0].ruleType){
                    "ApplicationRule"{
                        $headers = 'name','ruleType','destinationAddresses','fqdnTags','protocols','sourceAddresses','sourceIpGroups','targetFqdns','targetUrls','terminateTLS','webCategories'
                    }
                    "NatRule"{
                        $headers = 'name','ruleType','destinationAddresses','destinationPorts','ipProtocols','sourceAddresses','sourceIpGroups','translatedAddress','translatedFqdn','translatedPort'
                    }
                    "NetworkRule"{
                        $headers = 'name','ruleType','destinationAddresses','destinationFqdns','destinationIpGroups','destinationPorts','ipProtocols','sourceAddresses','sourceIpGroups'
                    }
                    Default{ #Auto sorting
                        Write-Warning "No sorting found for type:'$($ruleColl.rules[0].ruleType)'. Applying auto sorting"
                        $headers = 0..($ruleColl.rules.count-1) | Foreach-object{$ruleColl.rules[$_] | get-member -membertype NoteProperty | Select-Object -ExpandProperty Name} | Select-Object -unique | Sort-Object
                    }
                }
                #Create headers if merge is false or if the file is new
                If($Merge -eq $false -or (-not (test-path -Path $thisCsvFile -PathType leaf))){
                    $headers -join $Delimiter | Tee-Object $thisCsvFile Encoding utf8
                }
                $propertiesExpression = "`"$(($headers | Foreach-object{'$($_.{0})' -f $_}) -join $Delimiter)`""
                $ruleColl.rules | Foreach-object{(Invoke-Expression $propertiesExpression)} | Tee-Object $thisCsvFile -append -Encoding utf8
                If($Merge -eq $true){
                    $mergedContent = Get-Content $thisCsvFile | Select-Object -unique
                    $mergedContent | Where-object{$_.Trim()} | Tee-Object $thisCsvFile -Encoding utf8
                }
                If($Changes){
                    $theseChanges = $Changes | Where-object {$_.type -eq 'rule' -and $_.file -eq "$(Split-Path -Path $policyFolder -leaf)\$($thisRuleCollGroup.name)\$($ruleColl.name)\$($ruleColl.rules[0].ruleType).csv".Replace('\','/')}
                    If($theseChanges){
                        $headers -join $Delimiter | Tee-Object "removed.csv" -Encoding utf8
                        $theseChanges.removedRows | Tee-Object "removed.csv" -Append -Encoding utf8
                        $removedRules = Import-csv "removed.csv" -Delimiter $Delimiter -Encoding utf8
                        $modifiedContent = Import-csv $thisCsvFile -Delimiter $Delimiter -Encoding utf8 | Where-object{$removedRules.name -notcontains $_.name}
                        Remove-Item "removed.csv" -Force
                        $modifiedContent  | ConvertTo-Csv -NoTypeInformation -Delimiter $Delimiter | Foreach-object {$_ -replace '"',''} | Tee-Object $thisCsvFile -Encoding utf8
                    }
                }
            }
        }
    }
}

Function ConvertTo-ArmFw {
[cmdletbinding()]
Param(
    $ArmFolder,
    $PolicyFolder,
    $fwPolicyFileFormat = 'microsoft.network_firewallpolicies-{0}.json',
    $fwRuleCollGroupFileFormat = 'microsoft.network_firewallpolicies_rulecollectiongroups-{0}_{1}.json',
    $Changes,
    $Delimiter = ','
)
    #Read all policy files
    $settings = Get-Item -LiteralPath "$PolicyFolder\policySettings.json" | Get-Content | ConvertFrom-Json
            
    #Get all files in firewall folder
    $rgFiles = Get-ChildItem -LiteralPath $ArmFolder

    #Write settings to ARM Templates
    $settings | ForEach-Object{
        $thisFwPolicy = $_
        $fwPolicyFile = $fwPolicyFileFormat -f $thisFwPolicy.name
        
        #Write fwPolicy files
        #Create new files if none exist
        If(-not($rgFiles.Name -contains $fwPolicyFile)){
            Throw "$($thisFwPolicy.name) does not match any ARM template.`r`nThis script can't handle creation of new ARM files for Azure Firewall policies (yet).`r`nPlease create a PR on https://github.com/Freakling/AzureFW-GitOps to fix this."
        }
        

        #Write settings and rules files
        $thisFwPolicy.ruleCollectionGroups | Foreach-Object{
            $thisRuleCollGroup = $_
            $ruleCollGroupFile = $fwRuleCollGroupFileFormat -f $thisFwPolicy.name,$($thisruleCollGroup.name.split('/')[-1])
            
            #Create new files if none exist
            If(-not($rgFiles.Name -contains $ruleCollGroupFile)){
                Throw "$($thisRuleCollGroup.name) does not match any ARM template.`r`nThis script can't handle creation of new ARM files for Azure FirewallPolicy RuleCollections (yet).`r`nPlease create a PR on https://github.com/Freakling/AzureFW-GitOps to fix this."
            }
            
            $ruleCollGroupData = Get-content "$ArmFolder\$ruleCollGroupFile" | ConvertFrom-Json
            $thisArmResource = $ruleCollGroupData.resources | Where-object {$_.name -eq $thisRuleCollGroup.name}
            
            #set rule coll group priority
            If($changes | Where-Object {$_.type -eq 'settings'}){
                $thisArmResource.properties.priority = (($changes.innerData | Where-Object{$_.name -eq $thisFwPolicy.name}).ruleCollectionGroups | Where-object{$_.name -eq $thisRuleCollGroup.name}).priority
            }
            else {
                $thisArmResource.properties.priority = $thisRuleCollGroup.priority
            }
            
            
            #process each rule coll and write them to arm code
            # https://learn.microsoft.com/en-us/azure/templates/microsoft.network/firewallpolicies/rulecollectiongroups?pivots=deployment-language-arm-template
            $thisRuleCollGroup.ruleCollections | Foreach-Object{
                $thisRuleColl = $_
                $thisArmRuleColl = $thisArmResource.properties.ruleCollections | Where-Object{$_.name -eq $thisRuleColl.name}
                
                #If no ruleCollection exists then
                If(-not($thisArmRuleColl)){
                    Throw "This script can't create ruleCollections yet. Create a blank rulecoll until this is fixed. Please create a PR on https://github.com/Freakling/AzureFW-GitOps to fix this."
                }

                #write settings to rulecoll
                If($changes | Where-Object {$_.type -eq 'settings'}){
                    $thisArmRuleColl.priority = ((($changes.innerData | Where-Object{$_.name -eq $thisFwPolicy.name}).ruleCollectionGroups | Where-object{$_.name -eq $thisRuleCollGroup.name}).ruleCollections | Where-object{$_.name -eq $thisRuleColl.name}).priority
                    $thisArmRuleColl.action = ((($changes.innerData | Where-Object{$_.name -eq $thisFwPolicy.name}).ruleCollectionGroups | Where-object{$_.name -eq $thisRuleCollGroup.name}).ruleCollections | Where-object{$_.name -eq $thisRuleColl.name}).action
                }
                else {
                    $thisArmRuleColl.priority = $thisRuleColl.priority
                    $thisArmRuleColl.action = $thisRuleColl.action    
                }

                #read rules from csv
                $csvFiles = Get-ChildItem "$policyFolder/$($thisRulecollGroup.name)/$($thisRuleColl.name)"

                $theseRules = @()
                #set all rules
                $csvFiles | Foreach-object{
                    $rules = Import-csv -LiteralPath $_.FullName -Delimiter $Delimiter -Encoding utf8
                    #Need to make sure these are correct datatype
                    # https://learn.microsoft.com/en-us/azure/templates/microsoft.network/firewallpolicies/rulecollectiongroups?pivots=deployment-language-arm-template
                    $rules | ForEach-Object {
                        $rule = $_
                        switch($rule.ruleType){
                            "ApplicationRule"{
                                [array]$rule.destinationAddresses = $rule.destinationAddresses
                                [array]$rule.fqdnTags = $rule.fqdnTags
                                [array]$rule.protocols = $rule.protocols
                                [array]$rule.sourceAddresses = $rule.sourceAddresses
                                [array]$rule.sourceIpGroups = $rule.sourceIpGroups
                                [array]$rule.targetFqdns = $rule.targetFqdns
                                [array]$rule.targetUrls = $rule.targetUrls
                                [array]$rule.webCategories = $rule.webCategories
                                
                                #transform this object to a true array of objects
                                $protocols = @()
                                $rule.protocols | Foreach-object {
                                    $thisRow = [pscustomobject]@{}
                                    
                                    $data = $_.split(';')
                                    $data | Foreach-Object {
                                        $cleaned = $_.TrimStart('@{').TrimEnd('}').Trim()
                                        $thisRow | Add-Member -Type NoteProperty -Name $cleaned.split('=')[0] -Value $cleaned.split('=')[1]
                                    }
                                    $protocols += $thisRow
                                }
                                $rule.protocols = $protocols
                            }
                            "NatRule"{
                                [array]$rule.destinationAddresses = $rule.destinationAddresses.split(' ')
                                [array]$rule.destinationPorts = $rule.destinationPorts.split(' ')
                                [array]$rule.ipProtocols = $rule.ipProtocols.split(' ')
                                [array]$rule.sourceAddresses = $rule.sourceAddresses.split(' ')
                                [array]$rule.sourceIpGroups = $rule.sourceIpGroups.split(' ')
                            }
                            "NetworkRule"{
                                [array]$rule.destinationAddresses = $rule.destinationAddresses.split(' ')
                                [array]$rule.destinationFqdns = $rule.destinationFqdns.split(' ')
                                [array]$rule.destinationIpGroups = $rule.destinationIpGroups.split(' ')
                                [array]$rule.destinationPorts = $rule.destinationPorts.split(' ')
                                [array]$rule.ipProtocols = $rule.ipProtocols.split(' ')
                                [array]$rule.sourceAddresses = $rule.sourceAddresses.split(' ')
                                [array]$rule.sourceIpGroups = $rule.sourceIpGroups.split(' ')
                            }
                            default{}
                        }
                    }
                    $theseRules += $rules
                }
                $thisArmRuleColl.rules = $theseRules
            }

            #write new settings to arm template
            ($ruleCollGroupData | ConvertTo-Json -Depth 100).replace('""','') | Format-Json | Tee-Object "$ArmFolder\$ruleCollGroupFile" -Encoding utf8
        }
    }
}