Skip to content

Commit 1cbc617

Browse files
authored
Merge pull request #1041 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents 11d9679 + 739d378 commit 1cbc617

14 files changed

Lines changed: 372 additions & 155 deletions

Modules/CIPPCore/Public/AuditLogs/New-CIPPAuditLogSearchResultsCache.ps1

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ function New-CIPPAuditLogSearchResultsCache {
7272
Add-CIPPAzDataTableEntity @CacheWebhooksTable -Entity $cacheEntity -Force
7373
}
7474
Write-Information "Successfully cached search ID: $($SearchId) for tenant: $TenantFilter"
75+
76+
try {
77+
$PrefetchIPs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
78+
foreach ($sr in $searchResults) {
79+
$cip = $sr.auditData.clientip
80+
if (![string]::IsNullOrWhiteSpace($cip)) { $null = $PrefetchIPs.Add(([string]$cip).Trim()) }
81+
}
82+
if ($PrefetchIPs.Count -gt 0) {
83+
$null = Get-CIPPGeoIPLocationBatch -IPs @($PrefetchIPs)
84+
Write-Information "Geo prefetch: warmed cache for $($PrefetchIPs.Count) distinct IP(s) (search $SearchId)"
85+
}
86+
} catch {
87+
Write-Information "Geo prefetch during ingestion failed for search ${SearchId}: $($_.Exception.Message)"
88+
}
89+
7590
try {
7691
$FailedDownloadsTable = Get-CippTable -TableName 'FailedAuditLogDownloads'
7792
$failedEntities = Get-CIPPAzDataTableEntity @FailedDownloadsTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
function Get-CIPPManagedIdentityResourceId {
2+
<#
3+
.SYNOPSIS
4+
Get the Azure resource ID that the Function App's managed identity belongs to.
5+
.DESCRIPTION
6+
Reads the 'xms_mirid' claim from a managed identity access token. For a system-assigned
7+
identity (which CIPP uses), this claim is the ARM resource ID of the host resource itself
8+
- i.e. the Function App site, including its resource group:
9+
10+
/subscriptions/{sub}/resourcegroups/{rg}/providers/Microsoft.Web/sites/{site}
11+
12+
This is the most reliable in-process source for the site's resource group because it is
13+
present in every managed identity token, requires no extra ARM/Graph call, and - unlike
14+
parsing WEBSITE_OWNER_NAME - always names the site's RG rather than the App Service Plan's
15+
webspace RG.
16+
17+
Note: for a user-assigned identity, xms_mirid points at the userAssignedIdentities resource
18+
instead, which may live in a different RG. Callers that need the site's RG should validate
19+
the returned ID against the expected site (see Get-CIPPFunctionAppResourceGroup).
20+
.PARAMETER ResourceUrl
21+
The Azure resource URL to request the token for. Defaults to Azure Resource Manager.
22+
.EXAMPLE
23+
Get-CIPPManagedIdentityResourceId
24+
Returns e.g. /subscriptions/.../resourcegroups/CIPP-myinstance/providers/Microsoft.Web/sites/cippabcde
25+
#>
26+
[CmdletBinding()]
27+
param(
28+
[Parameter(Mandatory = $false)]
29+
[string]$ResourceUrl = 'https://management.azure.com/'
30+
)
31+
32+
$Token = Get-CIPPAzIdentityToken -ResourceUrl $ResourceUrl
33+
if (-not $Token) {
34+
throw 'Could not acquire a managed identity token to read the xms_mirid claim.'
35+
}
36+
37+
# JWT payload is the second dot-delimited segment, base64url-encoded.
38+
$Parts = $Token.Split('.')
39+
if ($Parts.Count -lt 2) {
40+
throw 'Managed identity token is not a well-formed JWT.'
41+
}
42+
43+
$Payload = $Parts[1].Replace('-', '+').Replace('_', '/')
44+
switch ($Payload.Length % 4) {
45+
2 { $Payload += '==' }
46+
3 { $Payload += '=' }
47+
}
48+
49+
$Claims = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload)) | ConvertFrom-Json
50+
return $Claims.xms_mirid
51+
}

Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-ContainerUpdateCheck.ps1

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,11 @@ function Start-ContainerUpdateCheck {
8686
# Resolve ARM site details
8787
$Subscription = Get-CIPPAzFunctionAppSubId
8888
$SiteName = $env:WEBSITE_SITE_NAME
89-
$RGName = $env:WEBSITE_RESOURCE_GROUP
90-
if (-not $RGName) {
91-
$Owner = $env:WEBSITE_OWNER_NAME
92-
if ($Owner -match '^(?<SubscriptionId>[^+]+)\+(?<RGName>[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') {
93-
$RGName = $Matches.RGName
94-
}
89+
try {
90+
$RGName = Get-CIPPFunctionAppResourceGroup -SiteName $SiteName
91+
} catch {
92+
Write-Information "Could not determine resource group: $($_.Exception.Message)"
93+
$RGName = $null
9594
}
9695

9796
$ImageTag = $env:IMAGE_TAG ?? 'unknown'

Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ function Start-TableCleanup {
105105
Property = @('PartitionKey', 'RowKey', 'ETag')
106106
}
107107
}
108+
@{
109+
FunctionName = 'TableCleanupTask'
110+
Type = 'CleanupRule'
111+
TableName = 'knownlocationdbv2'
112+
DataTableProps = @{
113+
Filter = "PartitionKey eq 'ip' and Timestamp lt datetime'$((Get-Date).AddDays(-90).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))'"
114+
First = 10000
115+
Property = @('PartitionKey', 'RowKey', 'ETag')
116+
}
117+
}
108118
@{
109119
FunctionName = 'TableCleanupTask'
110120
Type = 'DeleteTable'

Modules/CIPPCore/Public/Functions/Request-CIPPRestart.ps1

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,7 @@ function Request-CIPPRestart {
1919
try {
2020
$Subscription = Get-CIPPAzFunctionAppSubId
2121
$SiteName = $env:WEBSITE_SITE_NAME
22-
$RGName = $env:WEBSITE_RESOURCE_GROUP
23-
if (-not $RGName) {
24-
$Owner = $env:WEBSITE_OWNER_NAME
25-
if ($Owner -match '^(?<SubscriptionId>[^+]+)\+(?<RGName>[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') {
26-
$RGName = $Matches.RGName
27-
}
28-
}
22+
$RGName = Get-CIPPFunctionAppResourceGroup -SiteName $SiteName
2923
if (-not ($Subscription -and $RGName -and $SiteName)) {
3024
throw 'Azure App Service details could not be determined from environment'
3125
}

Modules/CIPPCore/Public/Get-ApplicationInsightsQuery.ps1

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,7 @@ function Get-ApplicationInsightsQuery {
1010
}
1111

1212
$SubscriptionId = Get-CIPPAzFunctionAppSubId
13-
if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?<SubscriptionId>[^+]+)\+(?<RGName>[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') {
14-
$RGName = $Matches.RGName
15-
} else {
16-
$RGName = $env:WEBSITE_RESOURCE_GROUP
17-
}
13+
$RGName = Get-CIPPFunctionAppResourceGroup
1814
$AppInsightsName = $env:WEBSITE_SITE_NAME
1915

2016
$Body = @{
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
function Get-CIPPGeoIPLocationBatch {
2+
<#
3+
.SYNOPSIS
4+
Resolve many IPs to geo-location in one pass, warming the knownlocationdbv2 cache.
5+
.DESCRIPTION
6+
Normalizes + de-dupes the input IPs and drops redacted / reserved / private / link-local
7+
addresses (never geolocatable). Remaining IPs are seeded from knownlocationdbv2 (fresh
8+
entries only); cache misses are resolved in bulk via the geoipdb /GetIPInfoBatch endpoint
9+
(which proxies ip-api's batch API, 100 IPs per upstream request). Successful results are
10+
written back to knownlocationdbv2 and cachegeoip so later processing is a cache hit.
11+
12+
Returns a hashtable keyed by normalized IP -> flattened location object
13+
@{ CountryOrRegion; City; Proxy; Hosting; ASName }. Failed/unknown lookups are NOT cached
14+
(no poisoning) and are absent from the returned hashtable.
15+
16+
Used both at ingestion (warm the cache up front) and as a per-batch prefetch in the audit
17+
log processor (so the per-record loop is a pure in-memory lookup).
18+
.PARAMETER IPs
19+
IP addresses to resolve. Duplicates, reserved IPs and ports are handled automatically.
20+
#>
21+
[CmdletBinding()]
22+
[OutputType([hashtable])]
23+
param(
24+
[string[]]$IPs
25+
)
26+
27+
# 20s timeout, up to 3 attempts for the geoip HTTP calls. The short timeout stops a single
28+
# hung IP from stalling the whole batch; the retries ride out transient blips before we give up.
29+
function Invoke-GeoRetry {
30+
param([string]$Uri, [string]$Method = 'GET', $Body, [string]$ContentType, [int]$Retries = 3, [int]$TimeoutSec = 20)
31+
$lastErr = $null
32+
for ($attempt = 1; $attempt -le $Retries; $attempt++) {
33+
try {
34+
if ($PSBoundParameters.ContainsKey('Body')) {
35+
return Invoke-CIPPRestMethod -Uri $Uri -Method $Method -Body $Body -ContentType $ContentType -TimeoutSec $TimeoutSec
36+
} else {
37+
return Invoke-CIPPRestMethod -Uri $Uri -Method $Method -TimeoutSec $TimeoutSec
38+
}
39+
} catch {
40+
$lastErr = $_
41+
if ($attempt -lt $Retries) { Start-Sleep -Milliseconds (300 * $attempt) }
42+
}
43+
}
44+
throw $lastErr
45+
}
46+
47+
$ClientIpRegex = [regex]'^(?<IP>(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$'
48+
$ReservedIpRegex = [regex]::new(
49+
'^(?:10\.|127\.|0\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2[0-9]|3[01])\.|100\.(?:6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|(?:22[4-9]|23[0-9]|24[0-9]|25[0-5])\.|::1?$|fe[89ab]|f[cd]|ff)',
50+
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
51+
52+
$Result = @{}
53+
54+
# Normalize (strip :port / brackets), drop redacted + reserved, de-dupe
55+
$Distinct = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
56+
foreach ($ip in $IPs) {
57+
if ([string]::IsNullOrWhiteSpace($ip)) { continue }
58+
$clean = $ClientIpRegex.Replace(([string]$ip).Trim(), '$1') -replace '[\[\]]', ''
59+
if ([string]::IsNullOrWhiteSpace($clean) -or $clean -match '[X]+') { continue }
60+
if ($ReservedIpRegex.IsMatch($clean)) { continue }
61+
$null = $Distinct.Add($clean)
62+
}
63+
if ($Distinct.Count -eq 0) { return $Result }
64+
65+
$LocationTable = Get-CIPPTable -TableName 'knownlocationdbv2'
66+
$ValidAfter = (Get-Date).AddDays(-90).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
67+
68+
# 1) Seed from knownlocationdbv2 (fresh, non-Unknown entries); collect the misses
69+
$ToResolve = [System.Collections.Generic.List[string]]::new()
70+
foreach ($ip in $Distinct) {
71+
$cached = Get-CIPPAzDataTableEntity @LocationTable -Filter "PartitionKey eq 'ip' and RowKey eq '$ip' and Timestamp ge datetime'$ValidAfter'"
72+
if ($cached -and $cached.CountryOrRegion -and $cached.CountryOrRegion -ne 'Unknown') {
73+
$Result[$ip] = [pscustomobject]@{
74+
CountryOrRegion = $cached.CountryOrRegion
75+
City = $cached.City
76+
Proxy = $cached.Proxy
77+
Hosting = $cached.Hosting
78+
ASName = $cached.ASName
79+
}
80+
} else {
81+
$ToResolve.Add($ip)
82+
}
83+
}
84+
if ($ToResolve.Count -eq 0) { return $Result }
85+
86+
# 2) Bulk-resolve the misses via geoipdb /GetIPInfoBatch (chunk to 100 to bound payloads)
87+
$CacheGeoIPTable = Get-CippTable -TableName 'cachegeoip'
88+
$KnownEntities = [System.Collections.Generic.List[object]]::new()
89+
$CacheGeoEntities = [System.Collections.Generic.List[object]]::new()
90+
91+
for ($i = 0; $i -lt $ToResolve.Count; $i += 100) {
92+
$chunk = @($ToResolve[$i..([Math]::Min($i + 99, $ToResolve.Count - 1))])
93+
$payload = '[' + (($chunk | ForEach-Object { $_ | ConvertTo-Json }) -join ',') + ']'
94+
$resp = $null
95+
try {
96+
$resp = Invoke-GeoRetry -Uri 'https://geoipdb.azurewebsites.net/api/GetIPInfoBatch' -Method POST -Body $payload -ContentType 'application/json'
97+
} catch {
98+
#Write-LogMessage -API GeoIPLocation -message "Bulk geoip lookup failed, falling back to single lookups for $($chunk.Count) IP(s): $($_.Exception.Message)" -sev Warning
99+
$fb = [System.Collections.Generic.List[object]]::new()
100+
foreach ($ip in $chunk) {
101+
try {
102+
$s = Invoke-GeoRetry -Uri "https://geoipdb.azurewebsites.net/api/GetIPInfo?IP=$ip"
103+
if ($s -and $s.status -ne 'fail') { $fb.Add([pscustomobject]@{ query = $ip; status = 'success'; countryCode = $s.countryCode; city = $s.city; proxy = $s.proxy; hosting = $s.hosting; asname = $s.asname }) }
104+
} catch { }
105+
}
106+
$resp = $fb
107+
}
108+
foreach ($r in $resp) {
109+
$ip = [string]$r.query
110+
if ([string]::IsNullOrWhiteSpace($ip) -or $r.status -ne 'success') { continue }
111+
$loc = [pscustomobject]@{
112+
CountryOrRegion = if ($r.countryCode) { $r.countryCode } else { 'Unknown' }
113+
City = if ($r.city) { $r.city } else { 'Unknown' }
114+
Proxy = if ($null -ne $r.proxy) { $r.proxy } else { 'Unknown' }
115+
Hosting = if ($null -ne $r.hosting) { $r.hosting } else { 'Unknown' }
116+
ASName = if ($r.asname) { $r.asname } else { 'Unknown' }
117+
}
118+
$Result[$ip] = $loc
119+
# Only cache real results - never persist Unknown (no poisoning, matches single path)
120+
if ($loc.CountryOrRegion -ne 'Unknown') {
121+
$KnownEntities.Add(@{
122+
PartitionKey = 'ip'
123+
RowKey = $ip
124+
CountryOrRegion = "$($loc.CountryOrRegion)"
125+
City = "$($loc.City)"
126+
Proxy = "$($loc.Proxy)"
127+
Hosting = "$($loc.Hosting)"
128+
ASName = "$($loc.ASName)"
129+
})
130+
$CacheGeoEntities.Add(@{
131+
PartitionKey = 'IP'
132+
RowKey = $ip
133+
Data = [string]($r | ConvertTo-Json -Compress)
134+
})
135+
}
136+
}
137+
}
138+
139+
# 3) Batch-write the caches
140+
if ($KnownEntities.Count -gt 0) {
141+
try { $null = Add-CIPPAzDataTableEntity @LocationTable -Entity @($KnownEntities) -Force }
142+
catch { Write-LogMessage -API GeoIPLocation -message "Failed to cache $($KnownEntities.Count) bulk geo results: $($_.Exception.Message)" -sev Warning }
143+
}
144+
if ($CacheGeoEntities.Count -gt 0) {
145+
try { $null = Add-AzDataTableEntity @CacheGeoIPTable -Entity @($CacheGeoEntities) -Force } catch {}
146+
}
147+
148+
return $Result
149+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
function Get-CIPPFunctionAppResourceGroup {
2+
<#
3+
.SYNOPSIS
4+
Resolve the resource group that the CIPP Function App site lives in.
5+
.DESCRIPTION
6+
Returns the resource group of the running Function App, using authoritative sources only:
7+
8+
1. WEBSITE_RESOURCE_GROUP - platform-injected, the site's actual RG. Free, no decode.
9+
2. xms_mirid claim from the managed identity token - the site's own ARM resource ID,
10+
present even when WEBSITE_RESOURCE_GROUP is empty, needs no extra call or permission.
11+
12+
The legacy approach of parsing WEBSITE_OWNER_NAME is intentionally NOT used: that string
13+
encodes the App Service Plan's webspace RG, which is frequently different from the site's RG
14+
(e.g. it returns 'DefaultResourceGroup-WEU' or '<rg>-m01' for sites whose plan was created
15+
in an auto-generated/other resource group). Writing auth settings, restarting, or querying
16+
the wrong RG is worse than failing, so this throws when no reliable source is available.
17+
.PARAMETER SiteName
18+
The Function App site name to resolve. Defaults to WEBSITE_SITE_NAME.
19+
.EXAMPLE
20+
Get-CIPPFunctionAppResourceGroup
21+
Returns e.g. 'CIPP-myinstance'
22+
#>
23+
[CmdletBinding()]
24+
param(
25+
[Parameter(Mandatory = $false)]
26+
[string]$SiteName = $env:WEBSITE_SITE_NAME
27+
)
28+
29+
# 1. Platform-injected site resource group - authoritative, zero cost.
30+
if ($env:WEBSITE_RESOURCE_GROUP) {
31+
return $env:WEBSITE_RESOURCE_GROUP
32+
}
33+
34+
# 2. The managed identity's own token names this site's resource ID (incl. RG). Only trust it
35+
# when it actually points at this Microsoft.Web/sites resource, so a user-assigned identity
36+
# (whose xms_mirid is a userAssignedIdentities resource) falls through rather than returning
37+
# the identity's RG.
38+
try {
39+
$MiRid = Get-CIPPManagedIdentityResourceId
40+
if ($SiteName -and $MiRid -match "(?i)/resourcegroups/(?<RG>[^/]+)/providers/Microsoft\.Web/sites/$([regex]::Escape($SiteName))(/|$)") {
41+
return $Matches.RG
42+
}
43+
Write-Information "xms_mirid did not match site '$SiteName': $MiRid"
44+
} catch {
45+
Write-Warning "Could not read resource group from managed identity token: $($_.Exception.Message)"
46+
}
47+
48+
# 3. No reliable source - fail loudly rather than guess from WEBSITE_OWNER_NAME.
49+
throw "Could not determine the function app resource group for site '$SiteName'. WEBSITE_RESOURCE_GROUP is empty and the managed identity resource ID was unavailable."
50+
}

Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,7 @@ function Set-CIPPOffloadFunctionTriggers {
3838
}
3939

4040
# Determine resource group
41-
if ($env:WEBSITE_RESOURCE_GROUP) {
42-
$ResourceGroupName = $env:WEBSITE_RESOURCE_GROUP
43-
} else {
44-
$Owner = $env:WEBSITE_OWNER_NAME
45-
if ($env:WEBSITE_SKU -ne 'FlexConsumption' -and $Owner -match '^(?<SubscriptionId>[^+]+)\+(?<RGName>[^-]+(?:-[^-]+)*?)(?:-[^-]+webspace(?:-Linux)?)?$') {
46-
$ResourceGroupName = $Matches.RGName
47-
} else {
48-
throw 'Could not determine resource group. Please provide ResourceGroupName parameter.'
49-
}
50-
}
41+
$ResourceGroupName = Get-CIPPFunctionAppResourceGroup -SiteName $FunctionAppName
5142

5243
# Define the triggers to disable when offloading is enabled
5344
$TargetedTriggers = @(

0 commit comments

Comments
 (0)