Analyze/lib/AnalyzeCore.ps1

#Core of the analyzer: ingestion access, result helpers and test registration. Dot-sourced by Invoke-AzureAnalyze.ps1.

#region ingestion access

$script:Ingest = $null
$script:SupportsDateKind = (Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')

function Read-IngestJson {
    #parses a JSON file; date strings stay strings where PowerShell supports it (7.5+)
    param([Parameter(Mandatory = $true)][string]$Path)
    if (-not [System.IO.File]::Exists($Path)) { return $null }
    $text = [System.IO.File]::ReadAllText($Path)
    if ($script:SupportsDateKind) { return , (ConvertFrom-Json -InputObject $text -Depth 200 -NoEnumerate -DateKind String) }
    return , (ConvertFrom-Json -InputObject $text -Depth 200 -NoEnumerate)
}

function Initialize-Ingest {
    param([Parameter(Mandatory = $true)][string]$Path)
    $manifest = Read-IngestJson -Path (Join-Path $Path 'manifest.json')
    if ($null -eq $manifest) { throw "No manifest.json in $Path, not an ingestion folder" }
    $index = Read-IngestJson -Path (Join-Path $Path 'index.json')
    $script:Ingest = @{
        Root           = $Path
        Manifest       = $manifest
        Index          = @($index)
        Cache          = @{}
        Records        = @{}
        SubscriptionId = [string]$manifest.subscription.id
        ReferenceTime  = ConvertTo-UtcDate $manifest.startedAt
    }
}

function Get-IngestData {
    #content of an ingestion file, e.g. 'rbac/roleAssignments'; $null when the file does not exist
    param([Parameter(Mandatory = $true)][string]$Name)
    if (-not $script:Ingest.Cache.ContainsKey($Name)) {
        $script:Ingest.Cache[$Name] = Read-IngestJson -Path (Join-Path $script:Ingest.Root "$Name.json")
    }
    return $script:Ingest.Cache[$Name]
}

function Test-IngestSection {
    #true when a section was collected successfully (manifest status ok/partial, or the file exists for sections without status)
    param([Parameter(Mandatory = $true)][string]$Name)
    $section = $script:Ingest.Manifest.sections.$Name
    if ($null -ne $section) { return ($section.status -in 'ok', 'partial') }
    return [System.IO.File]::Exists((Join-Path $script:Ingest.Root "$Name.json"))
}

function Get-IngestSectionProblem {
    #why a section was not collected, from the ingestion itself: 'defender/pricings (HTTP 404 NotFound; resource provider
    #Microsoft.Security is NotRegistered)'. The provider state comes from subscription/providers, never from a guess.
    param([Parameter(Mandatory = $true)][string]$Name)
    $manifest = $script:Ingest.Manifest
    $section = $manifest.sections.$Name
    if ($null -eq $section) {
        if ($Name -like 'identity/*') {
            if ($manifest.parameters.skipGraph) { return "$Name (Entra ID collection was skipped)" }
            if ($manifest.sections.identity.status -eq 'failed') { return "$Name (Entra ID collection failed: $($manifest.sections.identity.message))" }
        }
        if ($Name -like 'resourceGraph/*' -and $manifest.parameters.skipResourceGraph) { return "$Name (Resource Graph collection was skipped)" }
        if ($Name -like 'activityLog/*' -and $manifest.parameters.activityLogDays -eq 0) { return "$Name (activity log collection was skipped)" }
        return "$Name (not in the ingestion)"
    }
    $parts = [System.Collections.Generic.List[string]]::new()
    if ($null -ne $section.statusCode) {
        $parts.Add($(if ([int]$section.statusCode -eq 0) { 'network error' } else { "HTTP $($section.statusCode)$(if ($section.errorCode) { " $($section.errorCode)" })" }))
    } else {
        $parts.Add([string]$section.status)
    }
    #the failed call names the resource provider; its registration state explains a 404 or 409 on an unused service
    $pattern = '(^|[\\/])' + (@($Name -split '/' | ForEach-Object { [regex]::Escape($_) }) -join '[\\/]') + '\.json$'
    $failure = @(Get-IngestData 'failures') | Where-Object { $_ -and [string]$_.context -match $pattern } | Select-Object -First 1
    if ($failure -and [string]$failure.uri -match '(?i)/providers/(?<namespace>[^/?]+)/') {
        $namespace = $Matches.namespace
        $provider = @(Get-IngestData 'subscription/providers') | Where-Object { $_ -and $_.namespace -eq $namespace } | Select-Object -First 1
        if ($provider -and $provider.registrationState -and $provider.registrationState -ne 'Registered') {
            $parts.Add("resource provider $($provider.namespace) is $($provider.registrationState)")
        }
    }
    return "$Name ($($parts -join '; '))"
}

function Get-AzResourceRecords {
    #resource files (id, type, resource, diagnosticSettings, children, textContent, failures) of the given types
    param([string[]]$Type)
    $typeSet = @($Type | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() })
    $records = [System.Collections.Generic.List[object]]::new()
    foreach ($entry in $script:Ingest.Index) {
        if ($entry.type -eq 'resourceGroup' -or $entry.status -ne 'ok' -or -not $entry.file) { continue }
        if ($typeSet.Count -and $entry.type.ToLowerInvariant() -notin $typeSet) { continue }
        $key = $entry.id.ToLowerInvariant()
        if (-not $script:Ingest.Records.ContainsKey($key)) {
            $script:Ingest.Records[$key] = Read-IngestJson -Path (Join-Path $script:Ingest.Root $entry.file)
        }
        if ($null -ne $script:Ingest.Records[$key]) { $records.Add($script:Ingest.Records[$key]) }
    }
    return $records
}

function Get-AzResourceRecord {
    #one resource file by resource id; $null when not collected
    param([Parameter(Mandatory = $true)][string]$Id)
    $key = $Id.ToLowerInvariant()
    if ($script:Ingest.Records.ContainsKey($key)) { return $script:Ingest.Records[$key] }
    $entry = $script:Ingest.Index | Where-Object { $_.id -and $_.id.ToLowerInvariant() -eq $key -and $_.file } | Select-Object -First 1
    if (-not $entry) { return $null }
    $script:Ingest.Records[$key] = Read-IngestJson -Path (Join-Path $script:Ingest.Root $entry.file)
    return $script:Ingest.Records[$key]
}

function Get-FailedResourceIds {
    #ids of resources whose collection failed, per type
    param([string[]]$Type)
    $typeSet = @($Type | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() })
    return @($script:Ingest.Index | Where-Object { $_.type -ne 'resourceGroup' -and $_.status -ne 'ok' -and ($typeSet.Count -eq 0 -or $_.type.ToLowerInvariant() -in $typeSet) } | ForEach-Object id)
}

function Get-ResourceGroupRecords {
    #resource group files (deployments, deploymentStacks, lighthouseRegistrationAssignments)
    $records = [System.Collections.Generic.List[object]]::new()
    foreach ($entry in ($script:Ingest.Index | Where-Object { $_.type -eq 'resourceGroup' -and $_.file })) {
        $record = Read-IngestJson -Path (Join-Path $script:Ingest.Root $entry.file)
        if ($null -ne $record) { $records.Add($record) }
    }
    return $records
}

#endregion

#region value helpers

function Get-Prop {
    #value at a dotted path, $null when any part is missing. Collections are unrolled like any PowerShell output; wrap in @() for a count.
    param($Object, [Parameter(Mandatory = $true)][string]$Path)
    foreach ($segment in $Path.Split('.')) {
        if ($null -eq $Object) { return $null }
        $Object = $Object.$segment
    }
    return $Object
}

function Get-Child {
    #child resource collected for a record, e.g. 'config/web' or 'blobServices/default/containers'.
    #Collections are unrolled (one pipeline item per child); use Test-ChildCollected to tell 'none' from 'not collected'.
    param([Parameter(Mandatory = $true)]$Record, [Parameter(Mandatory = $true)][string]$Path)
    if ($null -eq $Record.children) { return $null }
    return $Record.children.$Path
}

function Test-ChildCollected {
    #false when the child call failed or the child was not part of the ingestion
    param([Parameter(Mandatory = $true)]$Record, [Parameter(Mandatory = $true)][string]$Path)
    if ($null -eq $Record.children) { return $false }
    if ($Record.children.PSObject.Properties.Name -notcontains $Path) { return $false }
    return ($null -ne $Record.children.$Path)
}

function Get-ChildFailure {
    #status code of a failed child call, $null when not failed
    param([Parameter(Mandatory = $true)]$Record, [Parameter(Mandatory = $true)][string]$Path)
    $failure = @($Record.failures) | Where-Object { $_.path -and $_.path.ToLowerInvariant().EndsWith("/$($Path.ToLowerInvariant())") } | Select-Object -First 1
    if ($failure) { return [int]$failure.statusCode }
    return $null
}

function ConvertTo-UtcDate {
    #parses an ISO string, DateTime or unix seconds; $null when empty
    param($Value)
    if ($null -eq $Value -or ($Value -is [string] -and -not $Value)) { return $null }
    if ($Value -is [datetime]) { return $Value.ToUniversalTime() }
    if ($Value -is [long] -or $Value -is [int] -or $Value -is [double]) { return [DateTimeOffset]::FromUnixTimeSeconds([long]$Value).UtcDateTime }
    return [DateTimeOffset]::Parse([string]$Value, [System.Globalization.CultureInfo]::InvariantCulture).UtcDateTime
}

function Format-UtcDate {
    param($Value)
    $date = ConvertTo-UtcDate $Value
    if ($null -eq $date) { return $null }
    return $date.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
}

function Get-AgeInDays {
    #whole days between a date and the ingestion start, so re-analysing an ingestion is deterministic
    param($Value)
    $date = ConvertTo-UtcDate $Value
    if ($null -eq $date) { return $null }
    return [int][math]::Floor(($script:Ingest.ReferenceTime - $date).TotalDays)
}

function ConvertFrom-IsoDuration {
    #'PT8H' / 'P180D' -> TimeSpan
    param([string]$Duration)
    if (-not $Duration) { return $null }
    return [System.Xml.XmlConvert]::ToTimeSpan($Duration)
}

function Get-ResourceName { param([string]$Id) return ($Id.TrimEnd('/') -split '/')[-1] }

function Get-ResourceGroupName {
    param([string]$Id)
    if ($Id -match '/resourceGroups/([^/]+)') { return $Matches[1] }
    return $null
}

function Test-VersionAtLeast {
    #compares TLS style versions: '1.2', 'TLS1_2', 'Tls12', 'TLSv1.2', 'TLS1_3'
    param($Value, [string]$Minimum = '1.2')
    if (-not $Value) { return $false }
    $normalized = ([string]$Value) -replace '(?i)^tls\s*v?', '' -replace '_', '.'
    if ($normalized -match '^(\d)(\d)$') { $normalized = "$($Matches[1]).$($Matches[2])" }
    $parsed = $null
    if (-not [version]::TryParse($normalized, [ref]$parsed)) { return $false }
    return ($parsed -ge [version]$Minimum)
}

#endregion

#region result helpers

function New-Result {
    param(
        [Parameter(Mandatory = $true)][ValidateSet('Pass', 'Fail', 'Unknown', 'NotApplicable')][string]$Status,
        [string]$Detail,
        [System.Collections.IDictionary]$Evidence
    )
    return [pscustomobject]@{ Status = $Status; Detail = $Detail; Evidence = $Evidence }
}
function New-Pass { param([string]$Detail, [System.Collections.IDictionary]$Evidence) New-Result -Status Pass -Detail $Detail -Evidence $Evidence }
function New-Fail { param([string]$Detail, [System.Collections.IDictionary]$Evidence) New-Result -Status Fail -Detail $Detail -Evidence $Evidence }
function New-Unknown { param([string]$Detail, [System.Collections.IDictionary]$Evidence) New-Result -Status Unknown -Detail $Detail -Evidence $Evidence }
function New-NotApplicable { param([string]$Detail, [System.Collections.IDictionary]$Evidence) New-Result -Status NotApplicable -Detail $Detail -Evidence $Evidence }

function New-Finding {
    #a finding for any subject: pass -Record for a collected resource, or -ResourceId (+ -ResourceType) for anything else
    param(
        $Record,
        [string]$ResourceId,
        [string]$ResourceType,
        [string]$ResourceName,
        [Parameter(Mandatory = $true)]$Result
    )
    if ($Record) {
        if (-not $ResourceId) { $ResourceId = $Record.id }
        if (-not $ResourceType) { $ResourceType = $Record.type }
    }
    if (-not $ResourceName) { $ResourceName = Get-ResourceName $ResourceId }
    return [pscustomobject]@{
        ResourceId    = $ResourceId
        ResourceName  = $ResourceName
        ResourceType  = $ResourceType
        ResourceGroup = Get-ResourceGroupName $ResourceId
        Status        = $Result.Status
        Detail        = $Result.Detail
        Evidence      = $Result.Evidence
    }
}

function Get-SubscriptionScope { return "/subscriptions/$($script:Ingest.SubscriptionId)" }

#endregion

#region test registration

$script:Tests = [System.Collections.Generic.List[object]]::new()
$script:Catalog = $null
$script:SeverityWeights = [ordered]@{ Critical = 8; High = 4; Medium = 2; Low = 1; Informational = 0 }

function Add-AzTest {
    #registers a test; tags are validated against catalog/frameworks.json
    param([Parameter(Mandatory = $true)][hashtable]$Definition)
    foreach ($field in 'Id', 'Title', 'Category', 'Service', 'Severity', 'Description', 'Rationale', 'Remediation', 'Frameworks') {
        if (-not $Definition[$field]) { throw "Test $($Definition.Id): missing $field" }
    }
    if ($Definition.Id -notmatch '^AZ-[A-Z]+-\d{3}$') { throw "Test id $($Definition.Id) does not match AZ-<AREA>-<NNN>" }
    if ($script:Tests | Where-Object Id -eq $Definition.Id) { throw "Duplicate test id $($Definition.Id)" }
    if (-not $script:SeverityWeights.Contains($Definition.Severity)) { throw "Test $($Definition.Id): invalid severity $($Definition.Severity)" }
    if (-not ($Definition.Evaluate -or $Definition.Run)) { throw "Test $($Definition.Id): needs Evaluate (per resource) or Run" }
    if ($Definition.Evaluate -and -not $Definition.ResourceTypes) { throw "Test $($Definition.Id): Evaluate requires ResourceTypes" }
    foreach ($framework in $Definition.Frameworks.Keys) {
        if (-not $script:Catalog.Contains($framework) -or -not $script:Catalog[$framework].controls) { throw "Test $($Definition.Id): unknown or derived framework $framework" }
        foreach ($control in @($Definition.Frameworks[$framework])) {
            if (-not $script:Catalog[$framework].controls.Contains($control)) { throw "Test $($Definition.Id): $framework control '$control' not in catalog" }
        }
    }
    #MCSB is a security benchmark; a resilience check outside it carries a crosswalk tag (DORA) instead
    $crosswalkTags = @($Definition.Frameworks.Keys | Where-Object { $script:Catalog[$_].kind -eq 'crosswalk' })
    if (-not $Definition.Frameworks.MCSB -and -not $crosswalkTags) { throw "Test $($Definition.Id): at least one MCSB control is required" }
    if (-not $Definition.Version) { $Definition.Version = 1 }
    $script:Tests.Add([pscustomobject]$Definition)
}

#endregion

#region domain helpers

$script:BuiltInRoles = @{
    '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' = 'Owner'
    'b24988ac-6180-42a0-ab88-20f7382dd24c' = 'Contributor'
    '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9' = 'User Access Administrator'
    'f58310d9-a9f6-439a-9e8d-f62e7b41a168' = 'Role Based Access Control Administrator'
    'acdd72a7-3385-48ef-bd42-f606fba81ae7' = 'Reader'
}
#Azure's 'privileged administrator roles'
$script:PrivilegedRoleIds = @('8e3af657-a8ff-443c-a75c-2fe8c4bcb635', 'b24988ac-6180-42a0-ab88-20f7382dd24c', '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9', 'f58310d9-a9f6-439a-9e8d-f62e7b41a168')

function Get-RoleDefinitionGuid { param([string]$RoleDefinitionId) return ($RoleDefinitionId -split '/')[-1].ToLowerInvariant() }

function Get-RoleDefinitionMap {
    #role definition guid -> definition object
    if (-not $script:Ingest.Cache.ContainsKey('#roleMap')) {
        $map = @{}
        foreach ($definition in @(Get-IngestData 'rbac/roleDefinitions')) { if ($definition) { $map[$definition.name.ToLowerInvariant()] = $definition } }
        $script:Ingest.Cache['#roleMap'] = $map
    }
    return $script:Ingest.Cache['#roleMap']
}

function Get-RoleName {
    param([string]$RoleDefinitionId)
    $guid = Get-RoleDefinitionGuid $RoleDefinitionId
    $definition = (Get-RoleDefinitionMap)[$guid]
    if ($definition) { return $definition.properties.roleName }
    if ($script:BuiltInRoles.ContainsKey($guid)) { return $script:BuiltInRoles[$guid] }
    return $guid
}

function Test-RoleCanWrite {
    #true when a role grants any write/delete/action outside pure reads
    param([string]$RoleDefinitionId)
    $guid = Get-RoleDefinitionGuid $RoleDefinitionId
    if ($guid -in $script:PrivilegedRoleIds) { return $true }
    $definition = (Get-RoleDefinitionMap)[$guid]
    if (-not $definition) { return $true }
    foreach ($permission in @($definition.properties.permissions)) {
        foreach ($action in @($permission.actions)) { if ($action -and $action -notmatch '/read$' -and $action -ne '*/read') { return $true } }
    }
    return $false
}

function Test-RolePrivileged {
    #privileged administrator roles, plus custom roles that can assign roles or do everything
    param([string]$RoleDefinitionId)
    $guid = Get-RoleDefinitionGuid $RoleDefinitionId
    if ($guid -in $script:PrivilegedRoleIds) { return $true }
    $definition = (Get-RoleDefinitionMap)[$guid]
    if (-not $definition -or $definition.properties.type -ne 'CustomRole') { return $false }
    foreach ($permission in @($definition.properties.permissions)) {
        foreach ($action in @($permission.actions)) {
            if ($action -in '*', 'Microsoft.Authorization/*', 'Microsoft.Authorization/roleAssignments/write', 'Microsoft.Authorization/*/write') { return $true }
        }
    }
    return $false
}

function Get-ScopeLevel {
    #'root', 'managementGroup', 'subscription', 'resourceGroup' or 'resource'
    param([string]$Scope)
    if ($Scope -eq '/') { return 'root' }
    if ($Scope -match '^/providers/Microsoft\.Management/managementGroups/') { return 'managementGroup' }
    if ($Scope -match '^/subscriptions/[^/]+$') { return 'subscription' }
    if ($Scope -match '^/subscriptions/[^/]+/resourceGroups/[^/]+$') { return 'resourceGroup' }
    return 'resource'
}

function Get-PrincipalMap {
    #object id -> directory object, enriched with users.json details; includes soft deleted principals
    if (-not $script:Ingest.Cache.ContainsKey('#principals')) {
        $map = @{}
        foreach ($object in @(Get-IngestData 'identity/directoryObjects')) { if ($object) { $map[$object.id.ToLowerInvariant()] = $object } }
        foreach ($user in @(Get-IngestData 'identity/users')) { if ($user) { $map[$user.id.ToLowerInvariant()] = $user } }
        $script:Ingest.Cache['#principals'] = $map
    }
    return $script:Ingest.Cache['#principals']
}

function Get-Principal { param([string]$Id) if (-not $Id) { return $null }; return (Get-PrincipalMap)[$Id.ToLowerInvariant()] }

function Get-PrincipalLabel {
    param([string]$Id)
    $principal = Get-Principal $Id
    if (-not $principal) { return $Id }
    if ($principal.userPrincipalName) { return "$($principal.displayName) ($($principal.userPrincipalName))" }
    return "$($principal.displayName) ($Id)"
}

function Get-GroupMap {
    #group object id (lowercase) -> group record from identity/groups.json
    if (-not $script:Ingest.Cache.ContainsKey('#groups')) {
        $map = @{}
        foreach ($group in @(Get-IngestData 'identity/groups')) { if ($group) { $map[$group.id.ToLowerInvariant()] = $group } }
        $script:Ingest.Cache['#groups'] = $map
    }
    return $script:Ingest.Cache['#groups']
}

function Get-GroupMembers {
    #transitive members of a group as collected by the ingestion; empty when unknown
    param([string]$GroupId)
    $group = (Get-GroupMap)[$GroupId.ToLowerInvariant()]
    if (-not $group) { return }
    return @($group.transitiveMembers | Where-Object { $_ })
}

function Test-GroupMembersComplete {
    #false when the group was not collected or its transitive member listing failed during ingestion,
    #so "no members match" cannot be distinguished from "members unknown"
    param([string]$GroupId)
    if (-not $GroupId) { return $false }
    $group = (Get-GroupMap)[$GroupId.ToLowerInvariant()]
    if (-not $group) { return $false }
    return ($null -eq $group.transitiveMembersError)
}

function Test-GuestUser {
    param($User)
    return ($User.userType -eq 'Guest' -or ([string]$User.userPrincipalName) -match '#EXT#')
}

function Get-AssignmentUsers {
    #users behind a role assignment: the user itself, or the transitive user members of a group
    param([Parameter(Mandatory = $true)]$Assignment)
    $principalId = $Assignment.properties.principalId
    if ($Assignment.properties.principalType -eq 'User') {
        $user = Get-Principal $principalId
        if ($user) { return $user }
        return
    }
    if ($Assignment.properties.principalType -eq 'Group') {
        return @(Get-GroupMembers $principalId | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.user' } | ForEach-Object {
                $detail = Get-Principal $_.id
                if ($detail) { $detail } else { $_ }
            })
    }
    return
}

function Test-AssignmentUsersResolved {
    #false when the users behind an assignment cannot be determined: an unresolved user principal,
    #or a group whose transitive members were not collected. Tests report Unknown instead of Pass in that case.
    param([Parameter(Mandatory = $true)]$Assignment)
    $principalId = $Assignment.properties.principalId
    switch ($Assignment.properties.principalType) {
        'User' { return [bool](Get-Principal $principalId) }
        'Group' { return (Test-GroupMembersComplete $principalId) }
    }
    return $true
}

function Get-ActiveRoleAssignments {
    #role assignments that apply to this subscription (inherited, at subscription, and below)
    return @(Get-IngestData 'rbac/roleAssignments' | Where-Object { $_ })
}

function Get-PrincipalAccessMap {
    #principal object id (lowercase) -> role assignments that apply to it, directly or through group membership
    if (-not $script:Ingest.Cache.ContainsKey('#access')) {
        $map = @{}
        foreach ($assignment in (Get-ActiveRoleAssignments)) {
            $ids = @($assignment.properties.principalId)
            if ($assignment.properties.principalType -eq 'Group') { $ids += @(Get-GroupMembers $assignment.properties.principalId | ForEach-Object id) }
            foreach ($id in $ids) {
                if (-not $id) { continue }
                $key = $id.ToLowerInvariant()
                if (-not $map.ContainsKey($key)) { $map[$key] = [System.Collections.Generic.List[object]]::new() }
                $map[$key].Add($assignment)
            }
        }
        $script:Ingest.Cache['#access'] = $map
    }
    return $script:Ingest.Cache['#access']
}

function New-SubscriptionFinding {
    param([Parameter(Mandatory = $true)]$Result)
    return New-Finding -ResourceId (Get-SubscriptionScope) -ResourceType 'Microsoft.Resources/subscriptions' -ResourceName $script:Ingest.Manifest.subscription.displayName -Result $Result
}

function New-TenantFinding {
    param([Parameter(Mandatory = $true)]$Result, [string]$Suffix)
    $id = "/tenants/$($script:Ingest.Manifest.subscription.tenantId)$Suffix"
    return New-Finding -ResourceId $id -ResourceType 'Microsoft.Entra/tenants' -Result $Result
}

function Get-LockMap {
    #lock scope (lowercase) -> locks; lock ids are '<scope>/providers/Microsoft.Authorization/locks/<name>'
    if (-not $script:Ingest.Cache.ContainsKey('#locks')) {
        $map = @{}
        foreach ($lock in @(Get-IngestData 'subscription/locks' | Where-Object { $_ })) {
            $scope = ($lock.id -replace '(?i)/providers/Microsoft\.Authorization/locks/[^/]+$', '').ToLowerInvariant()
            if (-not $map[$scope]) { $map[$scope] = [System.Collections.Generic.List[object]]::new() }
            $map[$scope].Add($lock)
        }
        $script:Ingest.Cache['#locks'] = $map
    }
    return $script:Ingest.Cache['#locks']
}

function Get-EffectiveLocks {
    #locks at the resource or any parent scope
    param([Parameter(Mandatory = $true)][string]$ResourceId)
    $map = Get-LockMap
    $found = [System.Collections.Generic.List[object]]::new()
    $scope = $ResourceId.ToLowerInvariant()
    while ($scope) {
        if ($map.ContainsKey($scope)) { $found.AddRange($map[$scope]) }
        $parent = $scope.Substring(0, [math]::Max(0, $scope.LastIndexOf('/')))
        if ($parent -eq $scope) { break }
        $scope = $parent
    }
    return $found
}

function Test-DiagnosticLogsEnabled {
    #true when at least one diagnostic setting sends logs (category or category group) to a destination
    param($Settings, [string[]]$RequiredCategories)
    foreach ($setting in @($Settings)) {
        if (-not $setting) { continue }
        $properties = $setting.properties
        if (-not ($properties.workspaceId -or $properties.storageAccountId -or $properties.eventHubAuthorizationRuleId -or $properties.marketplacePartnerId)) { continue }
        $enabled = @($properties.logs | Where-Object { $_.enabled })
        if (-not $enabled) { continue }
        if (-not $RequiredCategories) { return $true }
        if ($enabled | Where-Object { $_.categoryGroup -in 'allLogs', 'audit' }) { return $true }
        $categories = @($enabled | ForEach-Object category)
        if (-not ($RequiredCategories | Where-Object { $_ -notin $categories })) { return $true }
    }
    return $false
}

function Test-InternetSource {
    param([string]$Prefix)
    return ($Prefix -in '*', 'Internet', '0.0.0.0/0', '::/0', 'Any', '0.0.0.0')
}

function Test-PortInRange {
    #'*', '3389', '3000-4000'
    param([string]$Range, [int]$Port)
    if (-not $Range) { return $false }
    if ($Range -eq '*') { return $true }
    if ($Range -match '^(\d+)-(\d+)$') { return ($Port -ge [int]$Matches[1] -and $Port -le [int]$Matches[2]) }
    if ($Range -match '^\d+$') { return ([int]$Range -eq $Port) }
    return $false
}

function Get-NsgInternetExposure {
    #first inbound rule (by priority) matching traffic from the Internet to the port/protocol; returns the rule when it allows, else $null
    param([Parameter(Mandatory = $true)]$Nsg, [Parameter(Mandatory = $true)][int]$Port, [ValidateSet('Tcp', 'Udp')][string]$Protocol = 'Tcp')
    $rules = @($Nsg.properties.securityRules) + @($Nsg.properties.defaultSecurityRules) | Where-Object { $_ -and $_.properties.direction -eq 'Inbound' }
    foreach ($rule in ($rules | Sort-Object { [int]$_.properties.priority })) {
        $p = $rule.properties
        if ($p.protocol -notin '*', $Protocol) { continue }
        $sources = @($p.sourceAddressPrefix) + @($p.sourceAddressPrefixes) | Where-Object { $_ }
        if (-not ($sources | Where-Object { Test-InternetSource $_ })) { continue }
        $ranges = @($p.destinationPortRange) + @($p.destinationPortRanges) | Where-Object { $_ }
        if (-not ($ranges | Where-Object { Test-PortInRange -Range $_ -Port $Port })) { continue }
        if ($p.access -eq 'Allow') { return $rule }
        return $null
    }
    return $null
}

function Get-PolicyAssignments { return @(Get-IngestData 'policy/policyAssignments' | Where-Object { $_ }) }

function Get-SecretPatterns {
    #patterns for credentials in text; values are never copied into findings
    return [ordered]@{
        'Storage account key'           = '(?i)AccountKey\s*=\s*[A-Za-z0-9+/]{40,}={0,2}'
        'Shared access key'             = '(?i)SharedAccessKey\s*=\s*[A-Za-z0-9+/]{20,}={0,2}'
        'SAS token signature'           = '(?i)[?&]sig=[A-Za-z0-9%+/]{30,}'
        'Entra client secret'           = '[A-Za-z0-9_~.\-]{3}\dQ~[A-Za-z0-9_~.\-]{31,34}'
        'Private key'                   = '-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----'
        'GitHub token'                  = '\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b'
        'AWS access key'                = '\bAKIA[0-9A-Z]{16}\b'
        'Plain text SecureString'       = '(?i)ConvertTo-SecureString\s+(?:-String\s+)?["''][^"''$]{4,}["'']\s+-AsPlainText'
        'Hardcoded password assignment' = '(?i)\b(?:password|passwd|pwd|clientsecret|client_secret|apikey|api_key)\b\s*[:=]\s*["''][^"''$\s{}]{8,}["'']'
    }
}

function Find-Secrets {
    #pattern names found in a text; the matched values are not returned
    param([string]$Text)
    $found = [System.Collections.Generic.List[string]]::new()
    if (-not $Text) { return }
    $patterns = Get-SecretPatterns
    foreach ($name in $patterns.Keys) { if ($Text -match $patterns[$name]) { $found.Add($name) } }
    return $found
}

#endregion