A weekly Logic App that publishes Microsoft's public IP feeds as one CSV per source into a private storage container: Azure service tags (Azure itself, Azure DevOps, Azure Virtual Desktop, Defender for Endpoint by default, any tag works) and the Microsoft 365 endpoint sets per service area.
This repo used to be a Python Azure Function App (a fork of
groovy-sky/azure-office-ip; that whole world is
preserved in the legacy tag). It is now a single Terraform stack, built from
the Libre DevOps registry modules, deploying one consumption Logic App workflow with a
system-assigned managed identity and no secrets anywhere:
- Azure service tags come from the ARM Service Tag Discovery API, called with the workflow's
identity. Every tag in
var.service_tagsgets its own CSV of address prefixes (IPv4 and IPv6) atazure-service-tags/<tag>.csv. Any tag the discovery API knows is fair game; the defaults coverAzureCloud,AzureDevOps,WindowsVirtualDesktop, andMicrosoftDefenderForEndpoint. - Microsoft 365 endpoint sets come from the endpoints.office.com web service (anonymous by
design). Every service area in
var.m365_service_areasgets its own CSV atm365/<area>.csv, one row per endpoint set with the ips and urls collections semicolon-joined so Microsoft's grouping (and the ports and required flags) survive the flattening. - GitHub IP ranges come from
api.github.com/meta(anonymous, mandatory User-Agent). Every group invar.github_ip_groupsgets its own CSV atgithub/<group>.csv;actions(the hosted runner set, roughly seven thousand CIDRs) is the default, andhooks,web,api,git,packages,pages,codespaces,copilot, and friends all work. - Any other public JSON feed via
var.custom_feeds, the out-of-band extension point: each entry names a url, the payload property holding the array, and (for object arrays) which field is the CIDR plus an optional filter. Every entry lands atcustom/<key>.csvand.json.
Every flat feed comes in three variants: the combined file (<name>.csv/.json), plus
<name>-ipv4 and <name>-ipv6 splits (the family test is the colon only IPv6 notation contains).
The M365 service areas additionally keep their full-fidelity endpoint-set files
(m365/<area>.csv/.json, ports and urls intact) alongside flat m365/<area>-ips[-ipv4|-ipv6]
lists for firewall consumption, and object custom feeds keep a -source.json with the unplucked
rows.
Every file is written twice per run: the stable latest path (overwritten weekly, stable URLs to
point tooling at) and a dated history copy under history/<yyyy-MM-dd>/... that is never
overwritten. One date stamp is composed per run, so a run crossing midnight stays consistent.
For the long term, the whole container is object-replicated to a second archive storage account
(enable_archive_replication, on by default): every write lands there minutes later without the
workflow knowing, and the archive account's lifecycle policy tiers the history down (cool at 30
days, archive at 180) while the latest files stay hot. Versioning and change feed are enabled on
both accounts, as object replication requires. Azure Table storage is deliberately not offered:
tables have no bulk write, so a weekly run would mean tens of thousands of sequential entity
inserts (AzureCloud alone is ~15,000 prefixes) for a row-query capability IP-feed consumers rarely
want; if row-level querying ever matters, ingest the blobs into Log Analytics or ADX instead.
The workflow runs every Monday at 06:00 UTC and overwrites last week's feeds. The cadence matches the sources: Azure service tags publish weekly, the M365 endpoint sets version monthly (start of month, occasional out-of-band changes), and GitHub's ranges change with no fixed cadence, so a weekly floor keeps everything at most a few days stale. CSVs land in blob storage; an Azure Table variant would slot in beside the blob writes if row-level querying ever earns its keep.
No storage keys, no SAS, anywhere, enforced: every Graph-of-storage byte moves under Entra ID.
The workflow authenticates its blob writes with its managed identity (ManagedServiceIdentity
authentication on the HTTP actions; there is no connection string or SAS token in any action, and
nothing to rotate or leak), and the account itself has shared_access_key_enabled = false, so
key-based access is impossible rather than merely unused. Humans read the feeds through RBAC (the
deployer gets Storage Blob Data Reader): az storage blob list --auth-mode login ....
The identity plumbing dogfoods the estate deliberately:
- a custom role definition carrying only
Microsoft.Network/locations/serviceTags/read, defined and assigned in one call by therole-assignmentmodule (no Reader over-grant); - Storage Blob Data Contributor scoped to the one account;
- the account firewall attached by the
storage-account-network-rulesmodule: deny by default, AzureServices bypass, and a resource instance rule admitting exactly this workflow, so the account never opens to the world for the sake of the writer.
cd terraform
az login
export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
terraform init
terraform applyThe stack includes the role definition and role assignments, so the applier needs to be able to write those at subscription scope (Owner). State is local and gitignored; this is a personal-tenant stack, not a shared pipeline.
Run it immediately instead of waiting for Monday with the command in the run_now_command output,
then browse the container from the container_url output.
-
service_tags: add any service tag, including regional variants (AzureCloud.uksouth,Storage.UKSouth,Sql,AzureFrontDoor.Backend); each becomes its own CSV and JSON. Matching is case-insensitive on purpose: Microsoft's regional casing is inconsistent across families (AzureCloud.uksouthbutStorage.UKSouth), and exact matching would silently miss. A tag the API does not know produces an empty file rather than a failed run. -
A correctness note from validating the feeds: the well-known
13.107.6.0/24and13.107.9.0/24Azure DevOps blocks are NOT in theAzureDevOpsservice tag; dev.azure.com rides the Microsoft 365 edge, and those ranges arrive through the M365CommonandExchangefeeds this stack also publishes. Take the tag plus the M365 feeds together for complete DevOps coverage. -
m365_service_areas: any ofCommon,Exchange,SharePoint,Skype. -
m365_instance:Worldwideby default; the sovereign instances work too. -
github_ip_groups: any key ofapi.github.com/metathat holds CIDR ranges; an empty list disables the GitHub source. -
custom_feeds: the extension point, no code required. Ready-made entries, shapes verified against the live feeds:custom_feeds = { # AWS, everything (10,000+ prefixes) and a per-service slice aws-all = { url = "https://ip-ranges.amazonaws.com/ip-ranges.json" collection = "prefixes" value_property = "ip_prefix" } aws-ec2 = { url = "https://ip-ranges.amazonaws.com/ip-ranges.json" collection = "prefixes" value_property = "ip_prefix" filter_property = "service" filter_equals = "EC2" } # AWS publishes IPv6 in a separate collection, so it is its own entry (the -ipv6 variants of # the entries above are correctly empty) aws-all-v6 = { url = "https://ip-ranges.amazonaws.com/ip-ranges.json" collection = "ipv6_prefixes" value_property = "ipv6_prefix" } # Zscaler recommended hub ranges (swap zscaler.net for your cloud: zscalertwo.net, zscloud.net ...) zscaler-hub = { url = "https://config.zscaler.com/api/zscaler.net/hubs/cidr/json/recommended" collection = "hubPrefixes" } }