Checks/CommonPatterns.ps1
|
# CommonPatterns.ps1 # Reusable pattern handler functions for CIS Azure Foundation Benchmark v5.0.0 # Each function receives a $ControlDef hashtable and optional cached resources, # returning a result object via New-CISCheckResult. #region 1. Invoke-DefenderPlanCheck function Invoke-DefenderPlanCheck { <# .SYNOPSIS Checks if a Microsoft Defender plan is enabled at the expected pricing tier. .DESCRIPTION Uses Get-AzSecurityPricing to verify the specified Defender plan is set to the expected tier (typically 'Standard'). #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef ) try { $planName = $ControlDef.DefenderPlanName $expected = $ControlDef.ExpectedTier $pricing = Get-AzSecurityPricing -Name $planName -ErrorAction Stop if ($pricing.PricingTier -eq $expected) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "Defender plan '$planName' is set to '$($pricing.PricingTier)'." ` -TotalResources 1 ` -PassedResources 1 ` -FailedResources 0 } else { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "Defender plan '$planName' is set to '$($pricing.PricingTier)'; expected '$expected'." ` -AffectedResources @("DefenderPlan:$planName") ` -TotalResources 1 ` -PassedResources 0 ` -FailedResources 1 } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to query Defender plan '$($ControlDef.DefenderPlanName)': $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 2. Invoke-ActivityLogAlertCheck function Invoke-ActivityLogAlertCheck { <# .SYNOPSIS Checks whether an enabled Activity Log Alert exists for a specified operation name. .DESCRIPTION Searches cached activity log alerts for one whose conditions include the target OperationName. Handles both legacy (ConditionAllOf) and current (Condition.AllOf) Az.Monitor module property names. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter()] [object[]]$CachedAlerts = @() ) try { $targetOperation = $ControlDef.OperationName if ($null -eq $CachedAlerts -or $CachedAlerts.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "No activity log alerts found in the subscription. Expected an alert for '$targetOperation'." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 1 } $matchFound = $false $matchedAlert = $null foreach ($alert in $CachedAlerts) { # Skip disabled alerts $isEnabled = $true if ($null -ne $alert.Enabled) { $isEnabled = $alert.Enabled } if (-not $isEnabled) { continue } # Collect all condition entries - handle both old and new property structures $conditions = @() # New Az.Monitor module: Condition.AllOf if ($alert.Condition -and $alert.Condition.AllOf) { $conditions = @($alert.Condition.AllOf) } # Legacy Az.Monitor module: ConditionAllOf elseif ($alert.ConditionAllOf) { $conditions = @($alert.ConditionAllOf) } foreach ($cond in $conditions) { # Check Field/Equals pattern (PowerShell property access is case-insensitive) if ($cond.Field -eq 'operationName' -and $cond.Equals -eq $targetOperation) { $matchFound = $true $matchedAlert = $alert break } } if ($matchFound) { break } } if ($matchFound) { # Verify the alert has at least one action group configured $actionGroups = @() if ($matchedAlert.Action -and $matchedAlert.Action.ActionGroup) { $actionGroups = @($matchedAlert.Action.ActionGroup) } elseif ($matchedAlert.ActionGroup) { $actionGroups = @($matchedAlert.ActionGroup) } if ($actionGroups.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'WARNING' ` -Details "An activity log alert exists for '$targetOperation' but has no action groups configured. Notifications will not be sent." ` -AffectedResources @("Alert:$($matchedAlert.Name) (no action groups)") ` -TotalResources 1 ` -PassedResources 0 ` -FailedResources 1 } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "An enabled activity log alert exists for operation '$targetOperation' with $($actionGroups.Count) action group(s)." ` -TotalResources 1 ` -PassedResources 1 ` -FailedResources 0 } else { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "No enabled activity log alert found for operation '$targetOperation'." ` -AffectedResources @("MissingAlert:$targetOperation") ` -TotalResources 1 ` -PassedResources 0 ` -FailedResources 1 } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to evaluate activity log alerts for '$($ControlDef.OperationName)': $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 3. Invoke-NSGPortCheck function Test-PortInRange { <# .SYNOPSIS Tests whether a specific port number falls within a port range string. .DESCRIPTION Accepts range strings such as "3389", "3000-4000", or "*" (all ports). Returns $true if the target port is within the range. #> [CmdletBinding()] param( [Parameter(Mandatory)] [int]$TargetPort, [Parameter(Mandatory)] [string]$RangeString ) $RangeString = $RangeString.Trim() # Wildcard matches everything if ($RangeString -eq '*') { return $true } # Range format: "3000-4000" if ($RangeString -match '^\s*(\d+)\s*-\s*(\d+)\s*$') { $low = [int]$Matches[1] $high = [int]$Matches[2] return ($TargetPort -ge $low -and $TargetPort -le $high) } # Single port if ($RangeString -match '^\s*(\d+)\s*$') { return ($TargetPort -eq [int]$Matches[1]) } return $false } function Invoke-NSGPortCheck { <# .SYNOPSIS Checks NSGs for inbound Allow rules that expose specified ports to the Internet. .DESCRIPTION For each NSG, inspects inbound rules that allow traffic from Internet sources (*, 0.0.0.0/0, 0.0.0.0, Internet, Any) for the specified port and protocol. Special case: Port=-1 means check ALL ports for the given protocol. Port can be an array (e.g., @(80,443)) for multi-port checks. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter()] [object[]]$CachedNSGs = @() ) try { $targetPorts = $ControlDef.Port $protocol = $ControlDef.Protocol $serviceName = $ControlDef.ServiceName # Normalize port to array if ($targetPorts -isnot [array]) { $targetPorts = @($targetPorts) } $checkAllPorts = ($targetPorts.Count -eq 1 -and $targetPorts[0] -eq -1) # Internet source prefixes to flag $internetSources = @('*', '0.0.0.0/0', '0.0.0.0', 'Internet', 'Any') if ($null -eq $CachedNSGs -or $CachedNSGs.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No NSGs found in the subscription. Control not evaluated." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 0 } $totalNSGs = $CachedNSGs.Count $failedNSGs = 0 $affected = [System.Collections.Generic.List[string]]::new() foreach ($nsg in $CachedNSGs) { $nsgName = $nsg.Name $nsgFailed = $false # Combine default and custom security rules using List for efficiency $allRules = [System.Collections.Generic.List[object]]::new() if ($nsg.SecurityRules) { $allRules.AddRange(@($nsg.SecurityRules)) } if ($nsg.DefaultSecurityRules) { $allRules.AddRange(@($nsg.DefaultSecurityRules)) } # Sort rules by priority (lowest number = highest priority, evaluated first) $sortedRules = $allRules | Where-Object { $_.Direction -eq 'Inbound' } | Sort-Object { [int]$_.Priority } # Evaluate rules in priority order — a Deny rule at higher priority blocks the port $portExposed = $false foreach ($rule in $sortedRules) { # Check protocol match (TCP, UDP, or * for any) $ruleProtocol = $rule.Protocol if ($ruleProtocol -ne '*' -and $ruleProtocol -ne $protocol) { continue } # Check if the source is an Internet address $sourceMatched = $false $sourcePrefixes = [System.Collections.Generic.List[string]]::new() if ($rule.SourceAddressPrefix) { $sourcePrefixes.Add($rule.SourceAddressPrefix) } if ($rule.SourceAddressPrefixes) { foreach ($sp in $rule.SourceAddressPrefixes) { $sourcePrefixes.Add($sp) } } foreach ($prefix in $sourcePrefixes) { if ($prefix -in $internetSources) { $sourceMatched = $true break } } if (-not $sourceMatched) { continue } # Check if destination port matches $portMatched = $false $destPorts = [System.Collections.Generic.List[string]]::new() if ($rule.DestinationPortRange) { $destPorts.Add($rule.DestinationPortRange) } if ($rule.DestinationPortRanges) { foreach ($dp in $rule.DestinationPortRanges) { $destPorts.Add($dp) } } if ($checkAllPorts) { # Port=-1: only flag wildcard rules that allow ALL traffic (e.g., '*' or '0-65535') foreach ($portRange in $destPorts) { if ($portRange -eq '*' -or $portRange -eq '0-65535') { $portMatched = $true break } } } else { foreach ($portRange in $destPorts) { foreach ($tp in $targetPorts) { if (Test-PortInRange -TargetPort $tp -RangeString $portRange) { $portMatched = $true break } } if ($portMatched) { break } } } if ($portMatched) { if ($rule.Access -eq 'Deny') { # A higher-priority Deny rule blocks this port — port is NOT exposed $portExposed = $false break } else { # An Allow rule exposes the port, but keep checking for higher-priority Deny $portExposed = $true $nsgFailed = $true break } } } if ($nsgFailed -and $portExposed) { $failedNSGs++ $affected.Add("NSG:$nsgName (allows $serviceName from Internet)") } } $passedNSGs = $totalNSGs - $failedNSGs $portDisplay = if ($checkAllPorts) { "all $protocol ports" } else { "port(s) $($targetPorts -join ',')" } if ($failedNSGs -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "None of the $totalNSGs NSGs allow inbound $serviceName ($portDisplay) from the Internet." ` -TotalResources $totalNSGs ` -PassedResources $passedNSGs ` -FailedResources 0 } else { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "$failedNSGs of $totalNSGs NSGs allow inbound $serviceName ($portDisplay) from the Internet." ` -AffectedResources $affected.ToArray() ` -TotalResources $totalNSGs ` -PassedResources $passedNSGs ` -FailedResources $failedNSGs } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to evaluate NSG rules for $($ControlDef.ServiceName): $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 4. Invoke-StorageAccountPropertyCheck function Invoke-StorageAccountPropertyCheck { <# .SYNOPSIS Checks a property on each storage account against an expected value. .DESCRIPTION Supports dot-notation property paths (e.g., "NetworkRuleSet.DefaultAction") to navigate nested objects on the storage account resource. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter()] [object[]]$CachedStorageAccounts = @() ) try { $propertyPath = $ControlDef.PropertyPath $expectedValue = $ControlDef.ExpectedValue if ($null -eq $CachedStorageAccounts -or $CachedStorageAccounts.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No storage accounts found in the subscription. Control not evaluated." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 0 } $total = $CachedStorageAccounts.Count $passed = 0 $failed = 0 $affected = [System.Collections.Generic.List[string]]::new() foreach ($sa in $CachedStorageAccounts) { $saName = $sa.StorageAccountName # Navigate the dot-notation property path $currentObj = $sa $segments = $propertyPath.Split('.') $resolved = $true foreach ($segment in $segments) { if ($null -eq $currentObj) { $resolved = $false break } try { $currentObj = $currentObj.$segment } catch { $resolved = $false break } } $actualValue = if ($resolved) { $currentObj } else { $null } # Compare: handle $null expected, boolean, and string comparisons $isMatch = $false if ($null -eq $expectedValue) { $isMatch = ($null -eq $actualValue) } elseif ($expectedValue -is [bool]) { # Explicit $null check: in PowerShell, $null -eq $false is $true, which would # cause false PASSes when a property was never set (e.g. AllowSharedKeyAccess) if ($null -eq $actualValue) { $isMatch = $false } else { $isMatch = ($actualValue -eq $expectedValue) } } else { $isMatch = ([string]$actualValue -eq [string]$expectedValue) } if ($isMatch) { $passed++ } else { $failed++ $affected.Add("StorageAccount:$saName ($propertyPath='$actualValue', expected='$expectedValue')") } } if ($failed -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $total storage accounts have $propertyPath set to '$expectedValue'." ` -TotalResources $total ` -PassedResources $passed ` -FailedResources 0 } else { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "$failed of $total storage accounts do not have $propertyPath set to '$expectedValue'." ` -AffectedResources $affected.ToArray() ` -TotalResources $total ` -PassedResources $passed ` -FailedResources $failed } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to check storage account property '$($ControlDef.PropertyPath)': $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 5. Invoke-StorageBlobPropertyCheck function Invoke-StorageBlobPropertyCheck { <# .SYNOPSIS Checks blob service properties for each storage account. .DESCRIPTION Supports CheckType values: BlobSoftDelete, ContainerSoftDelete, BlobVersioning. Uses pre-cached blob service properties when available, falls back to API calls. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter()] [object[]]$CachedStorageAccounts = @(), [Parameter()] [hashtable]$CachedBlobProperties = @{} ) try { $checkType = $ControlDef.CheckType if ($null -eq $CachedStorageAccounts -or $CachedStorageAccounts.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No storage accounts found in the subscription. Control not evaluated." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 0 } $total = $CachedStorageAccounts.Count $passed = 0 $failed = 0 $affected = [System.Collections.Generic.List[string]]::new() foreach ($sa in $CachedStorageAccounts) { $saName = $sa.StorageAccountName $rgName = $sa.ResourceGroupName try { # Use cached properties if available, otherwise fetch $blobService = if ($CachedBlobProperties -and $CachedBlobProperties.ContainsKey($saName)) { $CachedBlobProperties[$saName] } else { Get-AzStorageBlobServiceProperty ` -StorageAccountName $saName ` -ResourceGroupName $rgName ` -ErrorAction Stop } $isCompliant = $false switch ($checkType) { 'BlobSoftDelete' { $isCompliant = ($blobService.DeleteRetentionPolicy.Enabled -eq $true) } 'ContainerSoftDelete' { $isCompliant = ($blobService.ContainerDeleteRetentionPolicy.Enabled -eq $true) } 'BlobVersioning' { $isCompliant = ($blobService.IsVersioningEnabled -eq $true) } default { $isCompliant = $false } } if ($isCompliant) { $passed++ } else { $failed++ $affected.Add("StorageAccount:$saName ($checkType not enabled)") } } catch { $failed++ $affected.Add("StorageAccount:$saName (error checking $checkType - $($_.Exception.Message))") } } if ($failed -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $total storage accounts have $checkType enabled." ` -TotalResources $total ` -PassedResources $passed ` -FailedResources 0 } else { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "$failed of $total storage accounts do not have $checkType enabled." ` -AffectedResources $affected.ToArray() ` -TotalResources $total ` -PassedResources $passed ` -FailedResources $failed } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to check blob property '$($ControlDef.CheckType)': $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 6. Invoke-StorageFilePropertyCheck function Invoke-StorageFilePropertyCheck { <# .SYNOPSIS Checks file service properties for each storage account. .DESCRIPTION Supports CheckType values: SoftDelete, SMBVersion, SMBEncryption. Uses pre-cached file service properties when available, falls back to API calls. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter()] [object[]]$CachedStorageAccounts = @(), [Parameter()] [hashtable]$CachedFileProperties = @{} ) try { $checkType = $ControlDef.CheckType if ($null -eq $CachedStorageAccounts -or $CachedStorageAccounts.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No storage accounts found in the subscription. Control not evaluated." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 0 } $total = $CachedStorageAccounts.Count $passed = 0 $failed = 0 $affected = [System.Collections.Generic.List[string]]::new() foreach ($sa in $CachedStorageAccounts) { $saName = $sa.StorageAccountName $rgName = $sa.ResourceGroupName try { # Use cached properties if available, otherwise fetch $fileService = if ($CachedFileProperties -and $CachedFileProperties.ContainsKey($saName)) { $CachedFileProperties[$saName] } else { Get-AzStorageFileServiceProperty ` -StorageAccountName $saName ` -ResourceGroupName $rgName ` -ErrorAction Stop } $isCompliant = $false switch ($checkType) { 'SoftDelete' { $isCompliant = ($fileService.ShareDeleteRetentionPolicy.Enabled -eq $true) } 'SMBVersion' { # Minimum SMB version should be 3.1.1 or higher $smbSetting = $fileService.ProtocolSetting.Smb.Versions if ($smbSetting) { # If explicitly configured, check that only SMB3.1.1 (or higher) is allowed # Versions may be a string like "SMB3.1.1" or a semicolon-separated list $versions = ($smbSetting -split '[;,]') | ForEach-Object { $_.Trim() } # Fail if SMB2.1 or SMB3.0 are permitted alongside or instead of 3.1.1 $hasLegacy = $versions | Where-Object { $_ -match 'SMB2|SMB3\.0' } $isCompliant = ($null -eq $hasLegacy -or $hasLegacy.Count -eq 0) } else { # Default: Azure allows all SMB versions including legacy $isCompliant = $false } } 'SMBEncryption' { # Channel encryption should include AES-256-GCM $encSetting = $fileService.ProtocolSetting.Smb.ChannelEncryption if ($encSetting) { $isCompliant = ($encSetting -match 'AES-256-GCM') } else { $isCompliant = $false } } default { $isCompliant = $false } } if ($isCompliant) { $passed++ } else { $failed++ $affected.Add("StorageAccount:$saName ($checkType not configured correctly)") } } catch { $failed++ $affected.Add("StorageAccount:$saName (error checking $checkType - $($_.Exception.Message))") } } if ($failed -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $total storage accounts have $checkType configured correctly." ` -TotalResources $total ` -PassedResources $passed ` -FailedResources 0 } else { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "$failed of $total storage accounts do not have $checkType configured correctly." ` -AffectedResources $affected.ToArray() ` -TotalResources $total ` -PassedResources $passed ` -FailedResources $failed } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to check file service property '$($ControlDef.CheckType)': $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 7. Invoke-KeyVaultPropertyCheck function Invoke-KeyVaultPropertyCheck { <# .SYNOPSIS Checks a property on each Key Vault against an expected value. .DESCRIPTION The cached Key Vault list from Get-AzKeyVault returns basic info only. This function uses pre-fetched full vault details from CachedKeyVaultDetails (falling back to Get-AzKeyVault -VaultName per vault if not cached), then navigates the PropertyPath and compares to ExpectedValue. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter()] [object[]]$CachedKeyVaults = @(), [Parameter()] [hashtable]$CachedKeyVaultDetails = @{} ) try { $propertyPath = $ControlDef.PropertyPath $expectedValue = $ControlDef.ExpectedValue if ($null -eq $CachedKeyVaults -or $CachedKeyVaults.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No Key Vaults found in the subscription. Control not evaluated." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 0 } $total = $CachedKeyVaults.Count $passed = 0 $failed = 0 $accessErrors = 0 $affected = [System.Collections.Generic.List[string]]::new() foreach ($kv in $CachedKeyVaults) { $vaultName = $kv.VaultName $rgName = $kv.ResourceGroupName try { # Retrieve full vault details (prefer cache to avoid N+1 API calls) $vaultDetail = if ($CachedKeyVaultDetails.ContainsKey($vaultName)) { $CachedKeyVaultDetails[$vaultName] } else { Get-AzKeyVault -VaultName $vaultName -ResourceGroupName $rgName -ErrorAction Stop } # Navigate dot-notation property path $currentObj = $vaultDetail $segments = $propertyPath.Split('.') $resolved = $true foreach ($segment in $segments) { if ($null -eq $currentObj) { $resolved = $false break } try { $currentObj = $currentObj.$segment } catch { $resolved = $false break } } $actualValue = if ($resolved) { $currentObj } else { $null } # Compare values $isMatch = $false if ($null -eq $expectedValue) { $isMatch = ($null -eq $actualValue) } elseif ($expectedValue -is [bool]) { $isMatch = ($null -ne $actualValue -and [bool]$actualValue -eq $expectedValue) } else { $isMatch = ([string]$actualValue -eq [string]$expectedValue) } if ($isMatch) { $passed++ } else { $failed++ $affected.Add("KeyVault:$vaultName ($propertyPath='$actualValue', expected='$expectedValue')") } } catch { if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization|Forbidden|AccessDenied') { $accessErrors++ $affected.Add("KeyVault:$vaultName (access denied - insufficient permissions)") } else { $failed++ $affected.Add("KeyVault:$vaultName (error: $(Format-CISErrorMessage $_.Exception.Message))") } } } if ($failed -eq 0 -and $accessErrors -gt 0) { # Only access errors, no actual compliance failures — report WARNING return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'WARNING' ` -Details "Could not access $accessErrors of $total Key Vault(s) due to insufficient permissions. Accessible vaults ($passed) comply with $propertyPath='$expectedValue'." ` -AffectedResources $affected.ToArray() ` -TotalResources $total ` -PassedResources $passed ` -FailedResources 0 } elseif ($failed -eq 0 -and $accessErrors -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $total Key Vaults have $propertyPath set to '$expectedValue'." ` -TotalResources $total ` -PassedResources $passed ` -FailedResources 0 } else { $statusNote = if ($accessErrors -gt 0) { " ($accessErrors additional vault(s) could not be accessed)" } else { '' } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "$failed of $total Key Vaults do not have $propertyPath set to '$expectedValue'.$statusNote" ` -AffectedResources $affected.ToArray() ` -TotalResources $total ` -PassedResources $passed ` -FailedResources $failed } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to check Key Vault property '$($ControlDef.PropertyPath)': $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 8. Invoke-KeyVaultKeyExpiryCheck function Invoke-KeyVaultKeyExpiryCheck { <# .SYNOPSIS Checks that all keys in matching Key Vaults have an expiration date set. .DESCRIPTION Filters Key Vaults by VaultType (RBAC or NonRBAC) based on EnableRbacAuthorization, then retrieves keys for each vault and verifies each key has an expiration date. Uses pre-fetched vault details from CachedKeyVaultDetails when available. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter()] [object[]]$CachedKeyVaults = @(), [Parameter()] [hashtable]$CachedKeyVaultDetails = @{} ) try { $vaultType = $ControlDef.VaultType # 'RBAC' or 'NonRBAC' if ($null -eq $CachedKeyVaults -or $CachedKeyVaults.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No Key Vaults found in the subscription. Control not evaluated." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 0 } $totalKeys = 0 $passedKeys = 0 $failedKeys = 0 $accessErrors = 0 $affected = [System.Collections.Generic.List[string]]::new() $vaultsChecked = 0 foreach ($kv in $CachedKeyVaults) { $vaultName = $kv.VaultName $rgName = $kv.ResourceGroupName try { # Get full vault details to check RBAC authorization (prefer cache) $vaultDetail = if ($CachedKeyVaultDetails.ContainsKey($vaultName)) { $CachedKeyVaultDetails[$vaultName] } else { Get-AzKeyVault -VaultName $vaultName -ResourceGroupName $rgName -ErrorAction Stop } $isRBAC = ($vaultDetail.EnableRbacAuthorization -eq $true) # Filter by vault type if ($vaultType -eq 'RBAC' -and -not $isRBAC) { continue } if ($vaultType -eq 'NonRBAC' -and $isRBAC) { continue } $vaultsChecked++ # Get all keys in this vault $keys = @(Get-AzKeyVaultKey -VaultName $vaultName -ErrorAction Stop) foreach ($key in $keys) { # Skip disabled keys — CIS only requires expiry on active keys if ($key.Enabled -ne $true) { continue } $totalKeys++ if ($null -ne $key.Expires) { $passedKeys++ } else { $failedKeys++ $affected.Add("KeyVault:$vaultName/Key:$($key.Name) (no expiration date)") } } } catch { if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization|Forbidden|AccessDenied') { $accessErrors++ $affected.Add("KeyVault:$vaultName (access denied - insufficient permissions)") } else { $affected.Add("KeyVault:$vaultName (error listing keys - $(Format-CISErrorMessage $_.Exception.Message))") } } } if ($vaultsChecked -eq 0 -and $accessErrors -gt 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'WARNING' ` -Details "Could not access any $vaultType Key Vault(s) due to insufficient permissions ($accessErrors vault(s))." ` -AffectedResources $affected.ToArray() ` -TotalResources 0 -PassedResources 0 -FailedResources 0 } if ($vaultsChecked -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No $vaultType Key Vaults found in the subscription. Control not evaluated." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 0 } if ($failedKeys -eq 0 -and $accessErrors -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $totalKeys key(s) across $vaultsChecked $vaultType vault(s) have an expiration date set." ` -TotalResources $totalKeys ` -PassedResources $passedKeys ` -FailedResources 0 } elseif ($failedKeys -eq 0 -and $accessErrors -gt 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'WARNING' ` -Details "All accessible keys ($passedKeys) have expiration dates, but $accessErrors vault(s) could not be accessed." ` -AffectedResources $affected.ToArray() ` -TotalResources $totalKeys ` -PassedResources $passedKeys ` -FailedResources 0 } else { $accessNote = if ($accessErrors -gt 0) { " ($accessErrors additional vault(s) inaccessible)" } else { '' } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "$failedKeys of $totalKeys key(s) across $vaultsChecked $vaultType vault(s) do not have an expiration date.$accessNote" ` -AffectedResources $affected.ToArray() ` -TotalResources $totalKeys ` -PassedResources $passedKeys ` -FailedResources $failedKeys } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to check Key Vault key expiry ($($ControlDef.VaultType)): $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 9. Invoke-KeyVaultSecretExpiryCheck function Invoke-KeyVaultSecretExpiryCheck { <# .SYNOPSIS Checks that all secrets in matching Key Vaults have an expiration date set. .DESCRIPTION Filters Key Vaults by VaultType (RBAC or NonRBAC) based on EnableRbacAuthorization, then retrieves secrets for each vault and verifies each secret has an expiration date. Uses pre-fetched vault details from CachedKeyVaultDetails when available. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter()] [object[]]$CachedKeyVaults = @(), [Parameter()] [hashtable]$CachedKeyVaultDetails = @{} ) try { $vaultType = $ControlDef.VaultType # 'RBAC' or 'NonRBAC' if ($null -eq $CachedKeyVaults -or $CachedKeyVaults.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No Key Vaults found in the subscription. Control not evaluated." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 0 } $totalSecrets = 0 $passedSecrets = 0 $failedSecrets = 0 $accessErrors = 0 $affected = [System.Collections.Generic.List[string]]::new() $vaultsChecked = 0 foreach ($kv in $CachedKeyVaults) { $vaultName = $kv.VaultName $rgName = $kv.ResourceGroupName try { # Get full vault details to check RBAC authorization (prefer cache) $vaultDetail = if ($CachedKeyVaultDetails.ContainsKey($vaultName)) { $CachedKeyVaultDetails[$vaultName] } else { Get-AzKeyVault -VaultName $vaultName -ResourceGroupName $rgName -ErrorAction Stop } $isRBAC = ($vaultDetail.EnableRbacAuthorization -eq $true) # Filter by vault type if ($vaultType -eq 'RBAC' -and -not $isRBAC) { continue } if ($vaultType -eq 'NonRBAC' -and $isRBAC) { continue } $vaultsChecked++ # Get all secrets in this vault $secrets = @(Get-AzKeyVaultSecret -VaultName $vaultName -ErrorAction Stop) foreach ($secret in $secrets) { # Skip disabled secrets — CIS only requires expiry on active secrets if ($secret.Enabled -ne $true) { continue } $totalSecrets++ if ($null -ne $secret.Expires) { $passedSecrets++ } else { $failedSecrets++ $affected.Add("KeyVault:$vaultName/Secret:$($secret.Name) (no expiration date)") } } } catch { if ($_.Exception.Message -match 'AuthorizationFailed|does not have authorization|Forbidden|AccessDenied') { $accessErrors++ $affected.Add("KeyVault:$vaultName (access denied - insufficient permissions)") } else { $affected.Add("KeyVault:$vaultName (error listing secrets - $(Format-CISErrorMessage $_.Exception.Message))") } } } if ($vaultsChecked -eq 0 -and $accessErrors -gt 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'WARNING' ` -Details "Could not access any $vaultType Key Vault(s) due to insufficient permissions ($accessErrors vault(s))." ` -AffectedResources $affected.ToArray() ` -TotalResources 0 -PassedResources 0 -FailedResources 0 } if ($vaultsChecked -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No $vaultType Key Vaults found in the subscription. Control not evaluated." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 0 } if ($failedSecrets -eq 0 -and $accessErrors -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $totalSecrets secret(s) across $vaultsChecked $vaultType vault(s) have an expiration date set." ` -TotalResources $totalSecrets ` -PassedResources $passedSecrets ` -FailedResources 0 } elseif ($failedSecrets -eq 0 -and $accessErrors -gt 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'WARNING' ` -Details "All accessible secrets ($passedSecrets) have expiration dates, but $accessErrors vault(s) could not be accessed." ` -AffectedResources $affected.ToArray() ` -TotalResources $totalSecrets ` -PassedResources $passedSecrets ` -FailedResources 0 } else { $accessNote = if ($accessErrors -gt 0) { " ($accessErrors additional vault(s) inaccessible)" } else { '' } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "$failedSecrets of $totalSecrets secret(s) across $vaultsChecked $vaultType vault(s) do not have an expiration date.$accessNote" ` -AffectedResources $affected.ToArray() ` -TotalResources $totalSecrets ` -PassedResources $passedSecrets ` -FailedResources $failedSecrets } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to check Key Vault secret expiry ($($ControlDef.VaultType)): $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 10. Invoke-DiagnosticSettingCheck function Invoke-DiagnosticSettingCheck { <# .SYNOPSIS Checks diagnostic settings at subscription or resource level. .DESCRIPTION Handles subscription-level diagnostic settings (Get-AzSubscriptionDiagnosticSetting) and resource-level diagnostic settings (Get-AzDiagnosticSetting). The ControlDef may specify DiagnosticLevel ('Subscription' or 'Resource'), RequiredCategories (array of log category names to verify), and ResourceType. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter()] [hashtable]$ResourceCache = @{} ) try { $level = if ($ControlDef.DiagnosticLevel) { $ControlDef.DiagnosticLevel } else { 'Subscription' } switch ($level) { 'Subscription' { # Check subscription-level diagnostic settings $diagSettings = @(Get-AzSubscriptionDiagnosticSetting -ErrorAction Stop) if ($diagSettings.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "No diagnostic settings configured for the subscription." ` -AffectedResources @('Subscription:CurrentSubscription') ` -TotalResources 1 ` -PassedResources 0 ` -FailedResources 1 } # If required categories are specified, check them if ($ControlDef.RequiredCategories) { $requiredCategories = @($ControlDef.RequiredCategories) $missingCategories = [System.Collections.Generic.List[string]]::new() foreach ($reqCat in $requiredCategories) { $found = $false foreach ($ds in $diagSettings) { $enabledLogs = @() # Support both $ds.Logs (newer Az.Monitor) and $ds.Log (older Az.Monitor) $logEntries = if ($ds.Logs) { $ds.Logs } elseif ($ds.Log) { $ds.Log } else { $null } if ($logEntries) { $enabledLogs = @($logEntries | Where-Object { $_.Enabled -eq $true } | ForEach-Object { $_.Category }) } if ($reqCat -in $enabledLogs) { $found = $true break } } if (-not $found) { $missingCategories.Add($reqCat) } } if ($missingCategories.Count -gt 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "Subscription diagnostic settings are missing required categories: $($missingCategories -join ', ')." ` -AffectedResources @("MissingCategories:$($missingCategories -join ',')") ` -TotalResources $requiredCategories.Count ` -PassedResources ($requiredCategories.Count - $missingCategories.Count) ` -FailedResources $missingCategories.Count } } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "Subscription diagnostic settings are properly configured ($($diagSettings.Count) setting(s) found)." ` -TotalResources 1 ` -PassedResources 1 ` -FailedResources 0 } 'Resource' { # Check resource-level diagnostic settings for a specific resource type $resourceType = $ControlDef.ResourceType $resources = @() if ($resourceType -and $ResourceCache.ContainsKey($resourceType)) { $resources = @($ResourceCache[$resourceType]) } if ($resources.Count -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "N/A - No $resourceType resources found in the subscription. Control not evaluated." ` -TotalResources 0 ` -PassedResources 0 ` -FailedResources 0 } $total = $resources.Count $passed = 0 $failed = 0 $affected = [System.Collections.Generic.List[string]]::new() foreach ($resource in $resources) { $resourceId = $resource.Id $resourceName = if ($resource.Name) { $resource.Name } else { $resourceId } try { $diagSettings = @(Get-AzDiagnosticSetting -ResourceId $resourceId -ErrorAction Stop) if ($diagSettings.Count -gt 0) { $passed++ } else { $failed++ $affected.Add("$($resourceType):$resourceName (no diagnostic settings)") } } catch { $failed++ $affected.Add("$($resourceType):$resourceName (error - $($_.Exception.Message))") } } if ($failed -eq 0) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "All $total $resourceType resources have diagnostic settings configured." ` -TotalResources $total ` -PassedResources $passed ` -FailedResources 0 } else { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "$failed of $total $resourceType resources lack diagnostic settings." ` -AffectedResources $affected.ToArray() ` -TotalResources $total ` -PassedResources $passed ` -FailedResources $failed } } default { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Unknown DiagnosticLevel: '$level'. Expected 'Subscription' or 'Resource'." } } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to check diagnostic settings: $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 11. Invoke-GraphAPIPropertyCheck function Invoke-GraphAPIPropertyCheck { <# .SYNOPSIS Checks a Microsoft Graph API property against an expected value. .DESCRIPTION Uses Invoke-MgGraphRequest to query the specified Graph endpoint, then navigates the PropertyPath and compares to ExpectedValue. Requires an active Microsoft Graph connection (Connect-MgGraph). #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef, [Parameter()] [hashtable]$EnvironmentInfo = @{} ) try { # Verify Graph connection if ($EnvironmentInfo.NeedsGraph -and -not $EnvironmentInfo.GraphConnected) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Microsoft Graph is not connected. Run 'Connect-MgGraph -Scopes Policy.Read.All,Directory.Read.All' first." } $endpoint = $ControlDef.GraphEndpoint $propertyPath = $ControlDef.PropertyPath $expectedValue = $ControlDef.ExpectedValue # Build the full Graph API URL with security validation $graphUrl = if ($endpoint -match '^https?://') { # Validate that full URLs only point to trusted Microsoft Graph endpoints $allowedPrefixes = @( 'https://graph.microsoft.com/', 'https://graph.microsoft.us/', 'https://graph.microsoft.de/', 'https://microsoftgraph.chinacloudapi.cn/' ) $isTrusted = $false foreach ($prefix in $allowedPrefixes) { if ($endpoint.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { $isTrusted = $true break } } if (-not $isTrusted) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Graph endpoint URL '$endpoint' is not a trusted Microsoft Graph domain. Aborting to prevent token leakage." } $endpoint } else { "https://graph.microsoft.com/v1.0/$endpoint" } # Call the Graph API $response = Invoke-MgGraphRequest -Method GET -Uri $graphUrl -ErrorAction Stop if ($null -eq $response) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Graph API returned null for endpoint '$endpoint'." } # Navigate dot-notation property path $currentObj = $response $segments = $propertyPath.Split('.') $resolved = $true foreach ($segment in $segments) { if ($null -eq $currentObj) { $resolved = $false break } # Handle both PSObject properties and hashtable keys if ($currentObj -is [hashtable] -or $currentObj -is [System.Collections.IDictionary]) { # Graph API returns camelCase keys (e.g. 'isEnabled') but ControlDefinitions # may use PascalCase (e.g. 'IsEnabled') — do case-insensitive key lookup $matchedKey = $null if ($currentObj.ContainsKey($segment)) { $matchedKey = $segment } else { # Case-insensitive fallback search foreach ($k in $currentObj.Keys) { if ([string]::Equals($k, $segment, [System.StringComparison]::OrdinalIgnoreCase)) { $matchedKey = $k break } } } if ($null -ne $matchedKey) { $currentObj = $currentObj[$matchedKey] } else { $resolved = $false break } } else { try { $currentObj = $currentObj.$segment } catch { $resolved = $false break } } } $actualValue = if ($resolved) { $currentObj } else { $null } # Compare values $isMatch = $false if ($null -eq $expectedValue) { $isMatch = ($null -eq $actualValue) } elseif ($expectedValue -is [bool]) { # Explicit $null check: in PowerShell, $null -eq $false is $true if ($null -eq $actualValue) { $isMatch = $false } elseif ($actualValue -is [bool]) { $isMatch = ($actualValue -eq $expectedValue) } else { # Graph API may return booleans as strings $isMatch = ([string]$actualValue -eq [string]$expectedValue) } } else { $isMatch = ([string]$actualValue -eq [string]$expectedValue) } if ($isMatch) { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'PASS' ` -Details "Graph API property '$propertyPath' is '$actualValue' (expected '$expectedValue') at endpoint '$endpoint'." ` -TotalResources 1 ` -PassedResources 1 ` -FailedResources 0 } else { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'FAIL' ` -Details "Graph API property '$propertyPath' is '$actualValue'; expected '$expectedValue' at endpoint '$endpoint'." ` -AffectedResources @("GraphPolicy:$endpoint ($propertyPath)") ` -TotalResources 1 ` -PassedResources 0 ` -FailedResources 1 } } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to query Graph API endpoint '$($ControlDef.GraphEndpoint)': $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion #region 12. Invoke-ManualCheck function Invoke-ManualCheck { <# .SYNOPSIS Returns an INFO result for controls that require manual verification. .DESCRIPTION This is the simplest handler - it returns the ManualGuidance text from the ControlDef with an INFO status, indicating the check cannot be automated. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$ControlDef ) try { $guidance = if ($ControlDef.ManualGuidance) { $ControlDef.ManualGuidance } else { 'This control requires manual verification. Please review the CIS Benchmark documentation for audit steps.' } return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'INFO' ` -AssessmentStatus 'Manual' ` -Details "Manual check required. Guidance: $guidance" } catch { return New-CISCheckResult ` -ControlId $ControlDef.ControlId ` -Title $ControlDef.Title ` -Status 'ERROR' ` -Details "Failed to generate manual check result: $(Format-CISErrorMessage $_.Exception.Message)" } } #endregion |