Script to import Office 365 IP list to Cisco ASA – Improved via REST API

Due to replacement of the Office 365 IP list with new web service, I wrote a new, improved script that returns all IPs needed for all services of O365 in format for Cisco ASA:

# CHANGE PATH
$Path = 'E:'
 
$Report = "$Path\Report.txt"
$date = Get-Date -format "yyyyMMdd"
$dateReport = Get-Date -format "dd.MM.yyyy"
$o365SavedGUID = "$Env:TEMP\o365_Guid.txt"
if (Test-Path $o365SavedGUID ) { $GUID = Get-Content $o365SavedGUID }
else { $GUID = [GUID]::NewGuid().Guid; $GUID | Out-File $o365SavedGUID }
[bool]$NoIpv6 = 1 # 1 eq True; 0 eq False
$Link = "https://endpoints.office.com/endpoints/Worldwide?NoIPv6=$NoIpv6&ClientRequestId=$GUID"
 
try { $AllIPs = Invoke-RestMethod -Method Get -Uri $Link }
catch  {
    $_ | Out-File "$Path\$date - Errors.txt"
    Add-Content $Report "$dateReport Error!!!"
}
 
if (!(Test-Path "$Path\$date - Errors.txt")) {
    $Result = @{}
    $IPGroupsTcp = $AllIPs | ? {($PSItem.Ips) -and ($PSItem.tcpPorts)} | select Ips, tcpPorts | Group-Object tcpPorts
    $IPGroupsUdp = $AllIPs | ? {($PSItem.Ips) -and ($PSItem.udpPorts)} | select Ips, udpPorts | Group-Object udpPorts
 
    foreach ($IPgrp in $IPGroupsTcp) {
        $ErrorList = @()
        $Result += @{"PortsTcp_$($IPgrp.Name)" = @()}
        $List = $IPgrp.Group.Ips | sort | select -Unique
        foreach ($Address in $List) {
            try {
                [string]$Parse = $Address
                [string]$IPAddress = $Parse.Substring(0,$Parse.IndexOf("/"))
                [string]$Cidr = $Parse.Substring($Parse.IndexOf("/")+1,$Parse.Length - $Parse.IndexOf("/")-1)
                [int]$Prefix = $Cidr
                if ($Prefix -eq 32) { [string]$Value = "network-object host $IPAddress" }
                else {
                    $mask = ([Math]::Pow(2,$Prefix)-1) * [Math]::Pow(2,(32-$Prefix))
                    $bytes = [BitConverter]::GetBytes([UInt32] $mask)
                    $IPMask = (($bytes.Count-1)..0 | ForEach-Object {[String]$bytes[$_]}) -join "."
                    [string]$Value = "network-object $IPAddress $IPMask"
                } # End if & else
                $Result."PortsTcp_$($IPgrp.Name)" += $Value.Trim()
            } # End Try
            catch { $ErrorList += "$Address --> $_" } # End Try & catch
        } # End foreach Address
    }
 
    foreach ($IPgrp in $IPGroupsUdp) {
        $ErrorList = @()
        $Result += @{"PortsUdp_$($IPgrp.Name)" = @()}
        $List = $IPgrp.Group.Ips | sort | select -Unique
        foreach ($Address in $List) {
            try {
                [string]$Parse = $Address
                [string]$IPAddress = $Parse.Substring(0,$Parse.IndexOf("/"))
                [string]$Cidr = $Parse.Substring($Parse.IndexOf("/")+1,$Parse.Length - $Parse.IndexOf("/")-1)
                [int]$Prefix = $Cidr
                if ($Prefix -eq 32) { [string]$Value = "network-object host $IPAddress" }
                else {
                    $mask = ([Math]::Pow(2,$Prefix)-1) * [Math]::Pow(2,(32-$Prefix))
                    $bytes = [BitConverter]::GetBytes([UInt32] $mask)
                    $IPMask = (($bytes.Count-1)..0 | ForEach-Object {[String]$bytes[$_]}) -join "."
                    [string]$Value = "network-object $IPAddress $IPMask"
                } # End if & else
                $Result."PortsUdp_$($IPgrp.Name)" += $Value.Trim()
            } # End Try
            catch { $ErrorList += "$Address --> $_" } # End Try & catch
        } # End foreach Address
    }
 
    if ($ErrorList) { 
        $ErrorList | Out-File "$Path\$date - Errors.txt"
        Add-Content $Report "$dateReport Error!!!"
    }   
    else { 
        $Result.Keys | % { $Result.Item($PSItem) | Out-File "$Path\$date - $_.txt" }
        Add-Content $Report "$dateReport OK"
        $Result.Keys | % {
            $Missing = @()
            $LatestRules = Get-Content "$Path\$date - $_.txt"
            if (Test-Path "$Path\$_.txt") {
                $LastFile = Get-Content "$Path\$_.txt"
                foreach ($line in $LatestRules) { if ($LastFile -notcontains $line)    {$Missing += $line} }
                foreach ($line in $LastFile)    { if ($LatestRules -notcontains $line) {$Missing += "no $line"} }
                if ($Missing) {$Missing | Out-File "$Path\$date - $_ Changes.txt"}
            }
            Copy-Item "$Path\$date - $_.txt" "$Path\$_.txt"
        }
        $ChangesToday = Get-ChildItem -Path $Path -Filter "*$date*Changes.txt"
        if ($ChangesToday) {
            foreach ($chg in $ChangesToday) { Add-Content -Value $chg.Name -Path "$Path\Report - Changes.txt" }
        }
    }
}

You need to change path in script (first line) to your custom folder.
You can run this script via Task Scheduler once per day.

Script will create multiple files (date is in format YYYYMMDD):
Report – Script logging (when was script run & result)
Report – Changes – List of changes (which list has changed)

It will also create file for each port group – currently there are 9 port groups (script will search for all port groups (TCP & UDP) and will create new files if needed). “- Changes” file will generate only if there are any changes)
– PortsTcp_25 (& [DATE] – PortsTcp_25, [DATE] – PortsTcp_25 – Changes)
– PortsTcp_80,443 (& [DATE] – PortsTcp_80,443, [DATE] – PortsTcp_80,443 – Changes)
– PortsTcp_143,993 (& [DATE] – PortsTcp_143,993, [DATE] – PortsTcp_143,993 – Changes)
– PortsTcp_443 (& [DATE] – PortsTcp_443, [DATE] – PortsTcp_443 – Changes)
– PortsTcp_587 (& [DATE] – PortsTcp_587, [DATE] – PortsTcp_587 – Changes)
– PortsTcp_995 (& [DATE] – PortsTcp_995, [DATE] – PortsTcp_995 – Changes)
– PortsTcp_50000-59999 (& [DATE] – PortsTcp_50000-59999, [DATE] – PortsTcp_50000-59999 – Changes)
– PortsUdp_3478,3479,3480,3481 (& [DATE] – PortsUdp_3478,3479,3480,3481, [DATE] – PortsUdp_3478,3479,3480,3481 – Changes)
– PortsUdp_50000-59999 (& [DATE] – PortsUdp_50000-59999, [DATE] – PortsUdp_50000-59999 – Changes)

Example of files generated (changes only in TCP ports 143,993 & 587 & UDP ports 50000-59999):
Reports

24 Comments

  1. Great to see this. Super useful. One minor ask I have is to use this to get the client identifier which retains the same GUID across calls. Since the web services are anonymous this is how we identify the number of machines that connect to it, it also may be required if there is a support request related to the web service.

    Simply replace this line, with the following two lines:
    $GUID = ([guid]::NewGuid()).Guid

    $d = $Env:TEMP + “\endpoints_guid.txt”
    if (Test-Path $d) { $GUID = Get-Content $d } else { $GUID = [GUID]::NewGuid().Guid; $GUID | Out-File $d }

    Paul Andrew

    Reply
  2. Thank you. great job

    Reply
  3. Hello Halis,

    Thank you for sharing your script.

    A minor point, but the “$NoIpv6” variable is never assigned a value. Based on some quick web browser tests, “NoIPv6=$NoIpv6” appears to be able to be shortened to just “NoIPv6”, i.e. the revised “$Link” assigned would be:

    $Link = “https://endpoints.office.com/endpoints/Worldwide?NoIPv6&ClientRequestId=$GUID”

    As Paul Andrew appears to be from Microsoft, I will add that if anyone from Microsoft happens to read this comment:

    It would be great for Microsoft to add the concept of “traffic direction” between on-premises systems and cloud to their REST API. It would be simpler to automatically update ASA rules knowing which IP blocks and ports needed to be opened in each direction. Some of the returned ranges will be for inbound traffic to on-prem (e.g. a hybrid Exchange set-up), some for outbound traffic, and some for traffic in both directions.

    Also beneficial would be a more clear definition of which ranges are for components such as “Exchange Online Protection”, which is only a subset of the “Exchange Online” service offerings. Relying on parsing for “urls” values of “*.mail.protection.outlook.com” and “*.protection.outlook.com” isn’t as clean as parsing on a dedicated “serviceAreaDisplayName” value such as “Exchange Online Protection” would be.

    Reply
  4. Thanks for this script, very helpful. There are a couple of entries in the Skype for Business/Teams section that use UDP ports 3478, 3479, 3480, 3481 so might need to generate one more file for these?

    Reply
  5. do you have the script to upload to CISCO ASA

    Reply
  6. Hello Halis,

    Thank you for the updated script, we had been using the old version previously. When I run the new script, I receive this error message:

    Invoke-RestMethod : Valid ClientRequestId Guid is required in the query string. Documentation is available at http://aka.ms/ipurlws
    At Drive:\Path\CreateASArules.ps1:13 char:17
    + try { $AllIPs = Invoke-RestMethod -Method Get -Uri $Link }
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-RestMethod], WebException
    + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand

    Any idea how we obtain a valid GUID?

    Reply
    • WordPress update changed how code is displayed on site (& became “&amp ;”). Now it’s fixed.

      Reply
  7. Halis,
    For someone who hasn’t used powershell and tips on how to use this script? Is it pretty straightforward?

    Reply
    • I was able to do the powershell by asking one of my friends but how do I upload into the Cisco ASA?

      Reply
  8. This still works great! Thanks for the script.

    Reply
  9. Hi this is amazing, I am not a script guy and we use Palo Altos.. My question is would it be hard to have it dump all the IP’s to one txt file with the CIDR in tact? Like x.x.x.x/32

    Reply
    • replace $IPMask with /$Cidr

      I did this aswell so I can import into my watchgaurd.

      heres a snip of the code
      foreach ($IPgrp in $IPGroupsTcp) {
      $ErrorList = @()
      $Result += @{“PortsTcp_$($IPgrp.Name)” = @()}
      $List = $IPgrp.Group.Ips | sort | select -Unique
      foreach ($Address in $List) {
      try {
      [string]$Parse = $Address
      [string]$IPAddress = $Parse.Substring(0,$Parse.IndexOf(“/”))
      [string]$Cidr = $Parse.Substring($Parse.IndexOf(“/”)+1,$Parse.Length – $Parse.IndexOf(“/”)-1)
      [int]$Prefix = $Cidr
      if ($Prefix -eq 32) { [string]$Value = “ipv4,$IPAddress” }
      else {
      $mask = ([Math]::Pow(2,$Prefix)-1) * [Math]::Pow(2,(32-$Prefix))
      $bytes = [BitConverter]::GetBytes([UInt32] $mask)
      $IPMask = (($bytes.Count-1)..0 | ForEach-Object {[String]$bytes[$_]}) -join “.”
      [string]$Value = “ipv4,$IPAddress/$Cidr”
      } # End if & else
      $Result.”PortsTcp_$($IPgrp.Name)” += $Value.Trim()
      } # End Try
      catch { $ErrorList += “$Address –> $_” } # End Try & catch
      } # End foreach Address

      Reply
  10. PortsUdp_50000-59999 file is not generated by the script anymore?

    Reply
    • Script is dynamically generating files (if Microsoft add/remove ports, script will produce more/less files)

      Reply
  11. Joseph Hillenburg26 November, 2019 at 15:45

    Hi, what is the licensing surrounding this? I think it should be posted on github.

    Reply
  12. Junaid Rahman11 January, 2020 at 00:48

    thank you

    Can someone modify to list with mask?

    Reply
  13. Just ran the script, it is great except that it is not outputting the port groups. it names the files for each port by group, but it retrieves only network objects

    Reply
  14. Rasmus Alenkær-Jørgensen16 December, 2020 at 17:06

    Hi All.
    I have used the script to get the ip addresses from Microsoft, and have written Python code to deliver it to the firewall.
    I ran into a problem with the generated files, not being formattet as utf8. If anyone else has problems, you can add “-Encoding utf8” to this line: “$Result.Keys | % { $Result.Item($PSItem) | Out-File “$Path\$date – $_.txt” }”

    Reply

Your email address will not be published.