From 40c5a5f1b7b90eed39b8248a3cf17811689ee5e5 Mon Sep 17 00:00:00 2001 From: Lukas Gosling <71852244+l-gosling@users.noreply.github.com> Date: Fri, 22 May 2026 23:44:41 +0200 Subject: [PATCH 1/3] chore: add .gemini and graphify-out directories to .gitignore --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 50bdf1417..a702c42b3 100644 --- a/.gitignore +++ b/.gitignore @@ -499,6 +499,13 @@ test.json .claude/** !.claude/agents/** +# Gemini CLI +.gemini/** +GEMINI.md + +# Graphify +graphify-out/** + # Don't allow Maester test results in the main branch test-results From 912461e639f4bdc1e1b7a79698158a60ff0510a7 Mon Sep 17 00:00:00 2001 From: Lukas Gosling <71852244+l-gosling@users.noreply.github.com> Date: Sat, 23 May 2026 01:10:33 +0200 Subject: [PATCH 2/3] feat: implement permission checks and authorization gathering for Maester functions --- powershell/Maester.psm1 | 1 + powershell/internal/Clear-ModuleVariable.ps1 | 1 + powershell/internal/Get-MtAuthorization.ps1 | 85 ++++++++++++ powershell/internal/Test-MtHasPermission.ps1 | 126 ++++++++++++++++++ .../internal/orca/Get-ORCACollection.ps1 | 6 +- powershell/public/Invoke-Maester.ps1 | 3 + .../public/cis/Test-MtCisGlobalAdminCount.ps1 | 7 +- .../entra/Test-MtCisaBlockHighRiskSignIn.ps1 | 74 +++++----- ...Test-MtManagementGroupWriteRequirement.ps1 | 19 ++- .../Test-MtAppManagementPolicyEnabled.ps1 | 7 +- ...Test-MtAppRegistrationOwnersWithoutMFA.ps1 | 12 +- .../entra/Test-MtCaDeviceComplianceExists.ps1 | 7 +- .../entra/Test-MtCaLicenseUtilization.ps1 | 7 +- .../maester/entra/Test-MtCaMfaForAdmin.ps1 | 7 +- .../maester/exchange/Test-MtExoSetScl.ps1 | 10 +- tests/maester-config.json | 94 +++++++++++-- 16 files changed, 402 insertions(+), 64 deletions(-) create mode 100644 powershell/internal/Get-MtAuthorization.ps1 create mode 100644 powershell/internal/Test-MtHasPermission.ps1 diff --git a/powershell/Maester.psm1 b/powershell/Maester.psm1 index 1cb989e9f..ad54281fe 100644 --- a/powershell/Maester.psm1 +++ b/powershell/Maester.psm1 @@ -25,6 +25,7 @@ $__MtSession = @{ DataverseApiBase = $null # Resolved Dataverse OData API base URL (e.g. https://org123.api.crm.dynamics.com/api/data/v9.2) DataverseResourceUrl = $null # Dataverse resource URL for token acquisition (e.g. https://org123.crm.dynamics.com) DataverseEnvironmentId = $null # Environment identifier for display (e.g. org123.crm.dynamics.com) + Authorization = $null } New-Variable -Name __MtSession -Value $__MtSession -Scope Script -Force diff --git a/powershell/internal/Clear-ModuleVariable.ps1 b/powershell/internal/Clear-ModuleVariable.ps1 index 17879e660..1bb690361 100644 --- a/powershell/internal/Clear-ModuleVariable.ps1 +++ b/powershell/internal/Clear-ModuleVariable.ps1 @@ -21,5 +21,6 @@ Clear-MtExoCache $__MtSession.AIAgentInfo = $null $__MtSession.AzureDevOpsConnection = $null + $__MtSession.Authorization = $null # $__MtSession.Connections = @() # Do not clear connections as they are used to track the connection state. This module variable should only be set by Connect-Maester and Disconnect-Maester. } diff --git a/powershell/internal/Get-MtAuthorization.ps1 b/powershell/internal/Get-MtAuthorization.ps1 new file mode 100644 index 000000000..6066d5d86 --- /dev/null +++ b/powershell/internal/Get-MtAuthorization.ps1 @@ -0,0 +1,85 @@ +function Get-MtAuthorization { + <# + .SYNOPSIS + Gathers the available permissions and roles for the current connected services. + + .DESCRIPTION + This function is called at the start of a Maester run to determine what the current + session is authorized to do. The results are stored in $__MtSession.Authorization + and used by tests to gracefully skip if required permissions are missing. + #> + [CmdletBinding()] + param() + + if ($null -ne $__MtSession.Authorization) { + return $__MtSession.Authorization + } + + $auth = @{ + Graph = @() + ExchangeOnline = @() + Azure = @() + Teams = @() + EntraRoles = @() + } + + # 1. Graph Scopes + if ($mgContext = Get-MgContext) { + $auth.Graph = $mgContext.Scopes + Write-Verbose "Gathered $($auth.Graph.Count) Graph scopes." + + # 2. Entra Roles + try { + if ($mgContext.AuthType -eq 'Delegated') { + $memberships = Invoke-MtGraphRequest -RelativeUri 'me/memberOf' -ErrorAction SilentlyContinue + $auth.EntraRoles = $memberships | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.directoryRole' } | Select-Object -ExpandProperty displayName + } else { + # Application permissions - check the service principal for the current app + # We need the service principal ID (not AppId). + $sp = Invoke-MtGraphRequest -RelativeUri "servicePrincipals" -Filter "appId eq '$($mgContext.ClientId)'" -ErrorAction SilentlyContinue + # Handle collection return + $spId = if ($sp -is [array]) { $sp[0].id } else { $sp.id } + + if ($spId) { + $memberships = Invoke-MtGraphRequest -RelativeUri "servicePrincipals/$spId/memberOf" -ErrorAction SilentlyContinue + $auth.EntraRoles = $memberships | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.directoryRole' } | Select-Object -ExpandProperty displayName + } + } + Write-Verbose "Gathered $($auth.EntraRoles.Count) Entra roles." + } catch { + Write-Verbose "Failed to gather Entra roles: $($_.Exception.Message)" + } + } + + # 3. Exchange Online Roles + if (Test-MtConnection -Service ExchangeOnline) { + try { + $roleAssignments = Get-MtExo -Request ManagementRoleAssignment -ErrorAction SilentlyContinue + if ($roleAssignments) { + $auth.ExchangeOnline = $roleAssignments.Role | Select-Object -Unique + } + Write-Verbose "Gathered $($auth.ExchangeOnline.Count) Exchange roles." + } catch { + Write-Verbose "Failed to gather Exchange roles: $($_.Exception.Message)" + } + } + + # 4. Azure Roles + if (Test-MtConnection -Service Azure) { + try { + $azContext = Get-AzContext + if ($azContext) { + $roleAssignments = Get-AzRoleAssignment -SignInName $azContext.Account.Id -ErrorAction SilentlyContinue + if ($roleAssignments) { + $auth.Azure = $roleAssignments.RoleDefinitionName | Select-Object -Unique + } + } + Write-Verbose "Gathered $($auth.Azure.Count) Azure roles." + } catch { + Write-Verbose "Failed to gather Azure roles: $($_.Exception.Message)" + } + } + + $__MtSession.Authorization = $auth + return $auth +} diff --git a/powershell/internal/Test-MtHasPermission.ps1 b/powershell/internal/Test-MtHasPermission.ps1 new file mode 100644 index 000000000..455f4cc56 --- /dev/null +++ b/powershell/internal/Test-MtHasPermission.ps1 @@ -0,0 +1,126 @@ +function Test-MtHasPermission { + <# + .SYNOPSIS + Checks if the current session has the required permissions or roles. + + .PARAMETER TestId + The ID of the test to check permissions for. Requirements are read from Maester config. + + .PARAMETER RequiredPermissions + Optional hashtable or object of permissions to check against. Overrides config lookup. + Example: @{ Graph = @('User.Read.All'); EntraRoles = @('Global Reader') } + + .DESCRIPTION + For Graph scopes, all listed permissions are required (AND check), with Read permissions + automatically being satisfied by their ReadWrite equivalents. + For EntraRoles, ExchangeOnline, and Azure roles, only one of the listed roles is required (OR check). + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [string]$TestId, + $RequiredPermissions + ) + + Write-Verbose "Checking permissions for TestId: $TestId" + + if (-not $RequiredPermissions -and [string]::IsNullOrEmpty($TestId)) { + return $true + } + + if (-not $RequiredPermissions) { + $testSetting = Get-MtMaesterConfigTestSetting -TestId $TestId + if ($null -ne $testSetting) { + $RequiredPermissions = $testSetting.RequiredPermissions + } + } + + if ($null -eq $RequiredPermissions) { + Write-Verbose "No permissions required for $TestId" + return $true + } + + # Handle PSCustomObject (from JSON) or Hashtable + $serviceKeys = @() + try { + if ($RequiredPermissions -is [hashtable] -or $RequiredPermissions -is [System.Collections.IDictionary]) { + $serviceKeys = $RequiredPermissions.Keys + } elseif ($null -ne $RequiredPermissions.PSObject -and $null -ne $RequiredPermissions.PSObject.Properties) { + $serviceKeys = $RequiredPermissions.PSObject.Properties.Name + } + } catch { + Write-Verbose "Failed to extract service keys: $_" + } + + if ($null -eq $serviceKeys -or $serviceKeys.Count -eq 0) { + Write-Verbose "No service-specific permissions found in requirement." + return $true + } + + $available = Get-MtAuthorization + + foreach ($service in $serviceKeys) { + $required = @() + if ($RequiredPermissions -is [hashtable] -or $RequiredPermissions -is [System.Collections.IDictionary]) { + $required = @($RequiredPermissions[$service]) + } else { + $required = @($RequiredPermissions.$service) + } + + # Filter out null/empty requirements + $required = $required | Where-Object { $_ } + if ($required.Count -eq 0) { continue } + + $owned = @($available[$service]) + if ($null -eq $owned) { $owned = @() } + + Write-Verbose "Checking ${service}: Required: $($required -join ', '), Owned: $($owned.Count) items" + + if ($service -eq 'Graph') { + # AND check for Graph scopes + foreach ($perm in $required) { + $found = $false + foreach ($o in $owned) { + if ($o -ieq $perm) { + $found = $true + break + } + } + + if (-not $found -and $perm -match '\.Read\.') { + $rwPerm = $perm -replace '\.Read\.', '.ReadWrite.' + foreach ($o in $owned) { + if ($o -ieq $rwPerm) { + $found = $true + break + } + } + } + + if (-not $found) { + Write-Verbose "Missing required ${service} scope: $perm" + return $false + } + } + } else { + # OR check for Roles (at least one must match) + $foundAny = $false + foreach ($perm in $required) { + foreach ($o in $owned) { + if ($o -ieq $perm) { + $foundAny = $true + break + } + } + if ($foundAny) { break } + } + + if (-not $foundAny) { + Write-Verbose "Missing at least one required ${service} role: $($required -join ', ')" + return $false + } + } + } + + return $true +} diff --git a/powershell/internal/orca/Get-ORCACollection.ps1 b/powershell/internal/orca/Get-ORCACollection.ps1 index 76e5a7076..f0f68e884 100644 --- a/powershell/internal/orca/Get-ORCACollection.ps1 +++ b/powershell/internal/orca/Get-ORCACollection.ps1 @@ -42,7 +42,11 @@ Function Get-ORCACollection if($SCC -and $Collection["Services"] -band [ORCAService]::MDO) { Write-Verbose "$(Get-Date) Getting Protection Alerts" - $Collection["ProtectionAlert"] = Get-ProtectionAlert | Where-Object {$_.IsSystemRule} + if (Test-MtHasPermission -RequiredPermissions @{ ExchangeOnline = @('View-Only Configuration') }) { + $Collection["ProtectionAlert"] = Get-ProtectionAlert | Where-Object {$_.IsSystemRule} + } else { + Write-Verbose "$(Get-Date) Skipping Protection Alerts due to missing 'View-Only Configuration' role." + } } Write-Verbose "$(Get-Date) Getting EOP Preset Policy Settings" diff --git a/powershell/public/Invoke-Maester.ps1 b/powershell/public/Invoke-Maester.ps1 index 59d866025..2940e75ba 100644 --- a/powershell/public/Invoke-Maester.ps1 +++ b/powershell/public/Invoke-Maester.ps1 @@ -327,6 +327,9 @@ # Initialize MtSession after Graph connected. Initialize-MtSession + # Gathers available permissions for connected services. + Get-MtAuthorization | Out-Null + if ($isWebUri) { # Check if TeamChannelWebhookUri is a valid URL. $urlPattern = '^(https)://[^\s/$.?#].[^\s]*$' diff --git a/powershell/public/cis/Test-MtCisGlobalAdminCount.ps1 b/powershell/public/cis/Test-MtCisGlobalAdminCount.ps1 index 961340fd7..b2119f77b 100644 --- a/powershell/public/cis/Test-MtCisGlobalAdminCount.ps1 +++ b/powershell/public/cis/Test-MtCisGlobalAdminCount.ps1 @@ -1,4 +1,4 @@ -function Test-MtCisGlobalAdminCount { +function Test-MtCisGlobalAdminCount { <# .SYNOPSIS Checks if the number of Global Admins is between 2 and 4 @@ -23,6 +23,11 @@ Add-MtTestResultDetail -SkippedBecause NotConnectedGraph return $null } + + if (-not (Test-MtHasPermission -TestId 'MT.1032')) { + Add-MtTestResultDetail -SkippedBecause LimitedPermissions + return $null + } try { Write-Verbose 'Getting role' diff --git a/powershell/public/cisa/entra/Test-MtCisaBlockHighRiskSignIn.ps1 b/powershell/public/cisa/entra/Test-MtCisaBlockHighRiskSignIn.ps1 index 04e6a073e..58ec3d31d 100644 --- a/powershell/public/cisa/entra/Test-MtCisaBlockHighRiskSignIn.ps1 +++ b/powershell/public/cisa/entra/Test-MtCisaBlockHighRiskSignIn.ps1 @@ -1,4 +1,4 @@ -function Test-MtCisaBlockHighRiskSignIn { +function Test-MtCisaBlockHighRiskSignIn { <# .SYNOPSIS Checks if Sign-In Risk Based Policies - MS.AAD.2.3 is set to 'blocked' @@ -23,45 +23,55 @@ return $null } - $EntraIDPlan = Get-MtLicenseInformation -Product EntraID - if($EntraIDPlan -ne "P2"){ - Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP2 + if (-not (Test-MtHasPermission -TestId 'CISA.MS.AAD.2.3')) { + Add-MtTestResultDetail -SkippedBecause LimitedPermissions return $null } - $result = Get-MtConditionalAccessPolicy | Where-Object { $_.state -eq "enabled" } + try { + $EntraIDPlan = Get-MtLicenseInformation -Product EntraID + if($EntraIDPlan -ne "P2"){ + Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP2 + return $null + } - $blockPolicies = $result | Where-Object {` - $_.grantControls.builtInControls -contains "block" -and ` - $_.conditions.applications.includeApplications -contains "all" -and ` - $_.conditions.signInRiskLevels -contains "high" -and ` - $_.conditions.users.includeUsers -contains "All" } + $result = Get-MtConditionalAccessPolicy | Where-Object { $_.state -eq "enabled" } - $testResult = ($blockPolicies|Measure-Object).Count -ge 1 + $blockPolicies = $result | Where-Object {` + $_.grantControls.builtInControls -contains "block" -and ` + $_.conditions.applications.includeApplications -contains "all" -and ` + $_.conditions.signInRiskLevels -contains "high" -and ` + $_.conditions.users.includeUsers -contains "All" } - if ($testResult) { - $testResultMarkdown = "Well done. Your tenant has one or more policies that block high risk sign-ins.`n`n" - } else { - $testResultMarkdown = "Your tenant does not have any conditional access policies that block high risk sign-ins.`n`n" - } + $testResult = ($blockPolicies|Measure-Object).Count -ge 1 - $checks = @{ - EnabledCount = ($result|Measure-Object).Count - BlockCount = (($result|Where-Object {$_.grantControls.builtInControls -contains "block"})|Measure-Object).Count - BlockAllAppsCount = (($result|Where-Object {$_.grantControls.builtInControls -contains "block" -and $_.conditions.applications.includeApplications -contains "all"})|Measure-Object).Count - BlockAllAppsSignInRiskHighCount = (($result|Where-Object {$_.grantControls.builtInControls -contains "block" -and $_.conditions.applications.includeApplications -contains "all" -and $_.conditions.signInRiskLevels -contains "high"})|Measure-Object).Count - BlockAllAppsSignInRiskHighAllUsersCount = (($result|Where-Object {$_.grantControls.builtInControls -contains "block" -and $_.conditions.applications.includeApplications -contains "all" -and $_.conditions.signInRiskLevels -contains "high" -and $_.conditions.users.includeUsers -contains "All"})|Measure-Object).Count - } + if ($testResult) { + $testResultMarkdown = "Well done. Your tenant has one or more policies that block high risk sign-ins.`n`n" + } else { + $testResultMarkdown = "Your tenant does not have any conditional access policies that block high risk sign-ins.`n`n" + } + + $checks = @{ + EnabledCount = ($result|Measure-Object).Count + BlockCount = (($result|Where-Object {$_.grantControls.builtInControls -contains "block"})|Measure-Object).Count + BlockAllAppsCount = (($result|Where-Object {$_.grantControls.builtInControls -contains "block" -and $_.conditions.applications.includeApplications -contains "all"})|Measure-Object).Count + BlockAllAppsSignInRiskHighCount = (($result|Where-Object {$_.grantControls.builtInControls -contains "block" -and $_.conditions.applications.includeApplications -contains "all" -and $_.conditions.signInRiskLevels -contains "high"})|Measure-Object).Count + BlockAllAppsSignInRiskHighAllUsersCount = (($result|Where-Object {$_.grantControls.builtInControls -contains "block" -and $_.conditions.applications.includeApplications -contains "all" -and $_.conditions.signInRiskLevels -contains "high" -and $_.conditions.users.includeUsers -contains "All"})|Measure-Object).Count + } - $testResultMarkdown += "| Criteria | Count of Policies |`n" - $testResultMarkdown += "| --- | --- |`n" - $testResultMarkdown += "| Enabled | $($checks.EnabledCount) |`n" - $testResultMarkdown += "| Enabled & Blocking | $($checks.BlockCount) |`n" - $testResultMarkdown += "| Enabled, Blocking, & All Apps | $($checks.BlockAllAppsCount) |`n" - $testResultMarkdown += "| Enabled, Blocking, All Apps, & Sign In Risk High | $($checks.BlockAllAppsSignInRiskHighCount) |`n" - $testResultMarkdown += "| Enabled, Blocking, All Apps, Sign In Risk High, & All Users | $($checks.BlockAllAppsSignInRiskHighAllUsersCount) |`n`n" + $testResultMarkdown += "| Criteria | Count of Policies |`n" + $testResultMarkdown += "| --- | --- |`n" + $testResultMarkdown += "| Enabled | $($checks.EnabledCount) |`n" + $testResultMarkdown += "| Enabled & Blocking | $($checks.BlockCount) |`n" + $testResultMarkdown += "| Enabled, Blocking, & All Apps | $($checks.BlockAllAppsCount) |`n" + $testResultMarkdown += "| Enabled, Blocking, All Apps, & Sign In Risk High | $($checks.BlockAllAppsSignInRiskHighCount) |`n" + $testResultMarkdown += "| Enabled, Blocking, All Apps, Sign In Risk High, & All Users | $($checks.BlockAllAppsSignInRiskHighAllUsersCount) |`n`n" - Add-MtTestResultDetail -Result $testResultMarkdown -GraphObjectType ConditionalAccess -GraphObjects $blockPolicies + Add-MtTestResultDetail -Result $testResultMarkdown -GraphObjectType ConditionalAccess -GraphObjects $blockPolicies - return $testResult + return $testResult + } catch { + Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ + return $null + } } diff --git a/powershell/public/maester/azure/Test-MtManagementGroupWriteRequirement.ps1 b/powershell/public/maester/azure/Test-MtManagementGroupWriteRequirement.ps1 index db3911baf..a128d6b75 100644 --- a/powershell/public/maester/azure/Test-MtManagementGroupWriteRequirement.ps1 +++ b/powershell/public/maester/azure/Test-MtManagementGroupWriteRequirement.ps1 @@ -1,4 +1,4 @@ -function Test-MtManagementGroupWriteRequirement { +function Test-MtManagementGroupWriteRequirement { <# .SYNOPSIS Checks if write permissions are required to create new management groups @@ -24,16 +24,21 @@ return $null } - # Get all management groups in the tenant and filter the tenant root management group by id - $rootManagementGroup = Get-MtAzureManagementGroup | Where-Object { $_.id -match "$($_.properties.tenantid)$" } - - if (!$rootManagementGroup) { - Write-Verbose "Tenant Root Group not found in management groups." - Add-MtTestResultDetail -SkippedBecause "Custom" -SkippedCustomReason "Tenant Root Group not found" + if (-not (Test-MtHasPermission -TestId 'MT.1064')) { + Add-MtTestResultDetail -SkippedBecause LimitedPermissions return $null } try { + # Get all management groups in the tenant and filter the tenant root management group by id + $rootManagementGroup = Get-MtAzureManagementGroup | Where-Object { $_.id -match "$($_.properties.tenantid)$" } + + if (!$rootManagementGroup) { + Write-Verbose "Tenant Root Group not found in management groups." + Add-MtTestResultDetail -SkippedBecause "Custom" -SkippedCustomReason "Tenant Root Group not found" + return $null + } + # Query the management group settings to check authorization requirements $settingResponse = Invoke-MtAzureRequest ` -RelativeUri "/providers/Microsoft.Management/managementGroups/$($rootManagementGroup.name)/settings/default" ` diff --git a/powershell/public/maester/entra/Test-MtAppManagementPolicyEnabled.ps1 b/powershell/public/maester/entra/Test-MtAppManagementPolicyEnabled.ps1 index fc0a8f8a4..2548f1cde 100644 --- a/powershell/public/maester/entra/Test-MtAppManagementPolicyEnabled.ps1 +++ b/powershell/public/maester/entra/Test-MtAppManagementPolicyEnabled.ps1 @@ -1,4 +1,4 @@ -function Test-MtAppManagementPolicyEnabled { +function Test-MtAppManagementPolicyEnabled { <# .Synopsis Checks if the default app management policy is enabled. @@ -16,6 +16,11 @@ [OutputType([bool])] param() + if (-not (Test-MtHasPermission -TestId 'MT.1002')) { + Add-MtTestResultDetail -SkippedBecause LimitedPermissions + return $null + } + try { $defaultAppManagementPolicy = Invoke-MtGraphRequest -RelativeUri 'policies/defaultAppManagementPolicy' Write-Verbose -Message "Default App Management Policy: $($defaultAppManagementPolicy.isEnabled)" diff --git a/powershell/public/maester/entra/Test-MtAppRegistrationOwnersWithoutMFA.ps1 b/powershell/public/maester/entra/Test-MtAppRegistrationOwnersWithoutMFA.ps1 index 569937f54..a563cbb34 100644 --- a/powershell/public/maester/entra/Test-MtAppRegistrationOwnersWithoutMFA.ps1 +++ b/powershell/public/maester/entra/Test-MtAppRegistrationOwnersWithoutMFA.ps1 @@ -1,4 +1,4 @@ -function Test-MtAppRegistrationOwnersWithoutMFA { +function Test-MtAppRegistrationOwnersWithoutMFA { <# .SYNOPSIS Tests if app registration owners have Multi-Factor Authentication (MFA) enabled. @@ -26,6 +26,11 @@ return $null } + if (-not (Test-MtHasPermission -TestId 'MT.1063')) { + Add-MtTestResultDetail -SkippedBecause LimitedPermissions + return $null + } + try { Write-Verbose "Step 1: Retrieving app registrations with owners..." @@ -214,12 +219,11 @@ } Add-MtTestResultDetail -Result $testResultMarkdown + return $testPassed } catch { Write-Error $_.Exception.Message - Add-MtTestResultDetail -Result "**Error** checking app registration owners: $($_.Exception.Message)" + Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ return $false } - - return $testPassed } diff --git a/powershell/public/maester/entra/Test-MtCaDeviceComplianceExists.ps1 b/powershell/public/maester/entra/Test-MtCaDeviceComplianceExists.ps1 index 070caf6e2..55f3d8ece 100644 --- a/powershell/public/maester/entra/Test-MtCaDeviceComplianceExists.ps1 +++ b/powershell/public/maester/entra/Test-MtCaDeviceComplianceExists.ps1 @@ -1,4 +1,4 @@ -function Test-MtCaDeviceComplianceExists { +function Test-MtCaDeviceComplianceExists { <# .Synopsis Checks if the tenant has at least one conditional access policy requiring device compliance. @@ -25,6 +25,11 @@ return $null } + if (-not (Test-MtHasPermission -TestId 'MT.1001')) { + Add-MtTestResultDetail -SkippedBecause LimitedPermissions + return $null + } + try { $policies = Get-MtConditionalAccessPolicy | Where-Object { $_.state -eq 'enabled' } diff --git a/powershell/public/maester/entra/Test-MtCaLicenseUtilization.ps1 b/powershell/public/maester/entra/Test-MtCaLicenseUtilization.ps1 index 08939af21..6a7d7f98a 100644 --- a/powershell/public/maester/entra/Test-MtCaLicenseUtilization.ps1 +++ b/powershell/public/maester/entra/Test-MtCaLicenseUtilization.ps1 @@ -1,4 +1,4 @@ -function Test-MtCaLicenseUtilization { +function Test-MtCaLicenseUtilization { <# .SYNOPSIS Test Conditional Access License Utilization and return stats on usage for the specific license. @@ -38,6 +38,11 @@ return $null } + if (-not (Test-MtHasPermission -TestId 'MT.1022')) { + Add-MtTestResultDetail -SkippedBecause LimitedPermissions + return $null + } + try { # Get the total number of users in the tenant $TotalUserCount = Get-MtTotalEntraIdUserCount diff --git a/powershell/public/maester/entra/Test-MtCaMfaForAdmin.ps1 b/powershell/public/maester/entra/Test-MtCaMfaForAdmin.ps1 index 09eb22b0e..917c40910 100644 --- a/powershell/public/maester/entra/Test-MtCaMfaForAdmin.ps1 +++ b/powershell/public/maester/entra/Test-MtCaMfaForAdmin.ps1 @@ -1,4 +1,4 @@ -function Test-MtCaMfaForAdmin { +function Test-MtCaMfaForAdmin { <# .Synopsis Checks if the tenant has at least one conditional access policy requiring MFA for admins @@ -25,6 +25,11 @@ return $null } + if (-not (Test-MtHasPermission -TestId 'MT.1006')) { + Add-MtTestResultDetail -SkippedBecause LimitedPermissions + return $null + } + $AdministrativeRolesToCheck = @( '62e90394-69f5-4237-9190-012177145e10', '194ae4cb-b126-40b2-bd5b-6091b380977d', diff --git a/powershell/public/maester/exchange/Test-MtExoSetScl.ps1 b/powershell/public/maester/exchange/Test-MtExoSetScl.ps1 index e47e7fd1e..8171b985b 100644 --- a/powershell/public/maester/exchange/Test-MtExoSetScl.ps1 +++ b/powershell/public/maester/exchange/Test-MtExoSetScl.ps1 @@ -1,4 +1,4 @@ -function Test-MtExoSetScl { +function Test-MtExoSetScl { <# .SYNOPSIS Checks if Spam confidence level (SCL) is configured in mail transport rules with specific domains @@ -25,6 +25,11 @@ return $null } + if (-not (Test-MtHasPermission -TestId 'MT.1043')) { + Add-MtTestResultDetail -SkippedBecause LimitedPermissions + return $null + } + try { $portalLink_TransportRules = "https://admin.exchange.microsoft.com/#/transportrules" @@ -42,10 +47,9 @@ } Add-MtTestResultDetail -Result $testResultMarkdown + return !$result } catch { Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ return $null } - - return !$result } diff --git a/tests/maester-config.json b/tests/maester-config.json index e055960b7..90c08726e 100644 --- a/tests/maester-config.json +++ b/tests/maester-config.json @@ -152,7 +152,12 @@ { "Id": "CISA.MS.AAD.2.3", "Severity": "High", - "Title": "Sign-ins detected as high risk SHALL be blocked." + "Title": "Sign-ins detected as high risk SHALL be blocked.", + "RequiredPermissions": { + "Graph": [ + "Policy.Read.All" + ] + } }, { "Id": "CISA.MS.AAD.3.1", @@ -722,12 +727,23 @@ { "Id": "MT.1001", "Severity": "Medium", - "Title": "At least one Conditional Access policy is configured with device compliance." + "Title": "At least one Conditional Access policy is configured with device compliance.", + "RequiredPermissions": { + "Graph": [ + "Policy.Read.ConditionalAccess", + "Application.Read.All" + ] + } }, { "Id": "MT.1002", "Severity": "High", - "Title": "App management restrictions on applications and service principals is configured and enabled." + "Title": "App management restrictions on applications and service principals is configured and enabled.", + "RequiredPermissions": { + "Graph": [ + "Policy.Read.All" + ] + } }, { "Id": "MT.1003", @@ -747,7 +763,13 @@ { "Id": "MT.1006", "Severity": "High", - "Title": "At least one Conditional Access policy is configured to require MFA for admins." + "Title": "At least one Conditional Access policy is configured to require MFA for admins.", + "RequiredPermissions": { + "Graph": [ + "Policy.Read.ConditionalAccess", + "Directory.Read.All" + ] + } }, { "Id": "MT.1007", @@ -827,7 +849,17 @@ { "Id": "MT.1022", "Severity": "Medium", - "Title": "All users utilizing a P1 license should be licensed." + "Title": "All users utilizing a P1 license should be licensed.", + "RequiredPermissions": { + "Graph": [ + "Organization.Read.All" + ], + "EntraRoles": [ + "Global Reader", + "Security Reader", + "Global Administrator" + ] + } }, { "Id": "MT.1023", @@ -872,7 +904,17 @@ { "Id": "MT.1032", "Severity": "High", - "Title": "Limited number of Global Admins are assigned." + "Title": "Limited number of Global Admins are assigned.", + "RequiredPermissions": { + "Graph": [ + "RoleManagement.Read.Directory" + ], + "EntraRoles": [ + "Global Reader", + "Security Reader", + "Global Administrator" + ] + } }, { "Id": "MT.1033.0", @@ -962,7 +1004,12 @@ { "Id": "MT.1043", "Severity": "Medium", - "Title": "Ensure Spam confidence level (SCL) is configured in mail transport rules with specific domains" + "Title": "Ensure Spam confidence level (SCL) is configured in mail transport rules with specific domains", + "RequiredPermissions": { + "ExchangeOnline": [ + "View-Only Configuration" + ] + } }, { "Id": "MT.1044", @@ -1057,12 +1104,24 @@ { "Id": "MT.1063", "Severity": "High", - "Title": "All app registration owners should have MFA registered" + "Title": "All app registration owners should have MFA registered", + "RequiredPermissions": { + "Graph": [ + "Application.Read.All", + "User.Read.All" + ] + } }, { "Id": "MT.1064", "Severity": "High", - "Title": "Management group creation should be limited to users with explicit write access" + "Title": "Management group creation should be limited to users with explicit write access", + "RequiredPermissions": { + "Azure": [ + "Owner", + "User Access Administrator" + ] + } }, { "Id": "MT.1065", @@ -1477,7 +1536,12 @@ { "Id": "ORCA.100", "Severity": "Medium", - "Title": "Bulk Complaint Level threshold is between 4 and 6." + "Title": "Bulk Complaint Level threshold is between 4 and 6.", + "RequiredPermissions": { + "ExchangeOnline": [ + "View-Only Configuration" + ] + } }, { "Id": "ORCA.101", @@ -1797,7 +1861,13 @@ { "Id": "ORCA.242", "Severity": "High", - "Title": "Important protection alerts responsible for AIR activities are enabled." + "Title": "Important protection alerts responsible for AIR activities are enabled.", + "RequiredPermissions": { + "ExchangeOnline": [ + "View-Only Configuration", + "Security Reader" + ] + } }, { "Id": "ORCA.243", @@ -2030,4 +2100,4 @@ "Title": "(Organization) Disallow extensions from accessing resources on the local network" } ] -} +} \ No newline at end of file From 98bd56d89a2f126935201db144e0446c9b3768dd Mon Sep 17 00:00:00 2001 From: Lukas Gosling <71852244+l-gosling@users.noreply.github.com> Date: Sat, 23 May 2026 01:39:56 +0200 Subject: [PATCH 3/3] feat: enhance test metadata generation with severity and permissions using AI --- .github/workflows/update-test-metadata.yml | 47 ++++++ .../test-metadata/Update-SeverityLevels.ps1 | 117 -------------- .../test-metadata/Update-TestMetadata.ps1 | 152 ++++++++++++++++++ .../aitools/test-metadata/prompt-severity.md | 58 ++++++- powershell/internal/Test-MtHasPermission.ps1 | 20 ++- powershell/public/Invoke-Maester.ps1 | 7 + tests/maester-config.json | 3 +- 7 files changed, 282 insertions(+), 122 deletions(-) create mode 100644 .github/workflows/update-test-metadata.yml delete mode 100644 build/aitools/test-metadata/Update-SeverityLevels.ps1 create mode 100644 build/aitools/test-metadata/Update-TestMetadata.ps1 diff --git a/.github/workflows/update-test-metadata.yml b/.github/workflows/update-test-metadata.yml new file mode 100644 index 000000000..281c0b622 --- /dev/null +++ b/.github/workflows/update-test-metadata.yml @@ -0,0 +1,47 @@ +name: 🤖 Update Test Metadata (AI) + +on: + pull_request: + paths: + - 'powershell/public/**' + - 'tests/**' + workflow_dispatch: + +permissions: {} + +concurrency: + group: update-test-metadata + cancel-in-progress: false + +jobs: + update-metadata: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Generate Test Metadata + shell: pwsh + run: | + ./build/aitools/test-metadata/Update-TestMetadata.ps1 + env: + GeminiApiKey: ${{ secrets.GEMINI_API_KEY }} + + - name: Commit and push changes + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git diff --quiet -- tests/maester-config.json; then + echo "No changes to commit." + exit 0 + fi + git add tests/maester-config.json + git commit -m "chore: update test metadata (AI generated permissions/severity)" + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/build/aitools/test-metadata/Update-SeverityLevels.ps1 b/build/aitools/test-metadata/Update-SeverityLevels.ps1 deleted file mode 100644 index 6d68ff6fe..000000000 --- a/build/aitools/test-metadata/Update-SeverityLevels.ps1 +++ /dev/null @@ -1,117 +0,0 @@ -# This script will read the tests in test-results.json and maester-config.json (if it exists) -# then determine the severity of the test using the Gemini AI API. -# The test-results.json file is a copy of one of the latest runs of Invoke-Maester. - -# This script can be reused if we need to do a bulk Severity update or add a similar property in the future. -# E.g Mitre ATT&CK, etc. - -function Get-PromptResult($prompt) { - $apiKey = $Env:GeminiApiKey - if (-not $apiKey) { - Write-Host "Gemini API key not found in environment variable. Set with the following command." -ForegroundColor Red - Write-Host ">`$Env:GeminiApiKey = ''" -ForegroundColor Red - Write-Host "You can get a new key from https://ai.google.dev/gemini-api/docs/api-key" - exit 1 - } - $uri = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=$apiKey" - - - $Body = @{ - contents = @( - @{ - parts = @( - @{ - text = $prompt - } - ) - } - ) - } | ConvertTo-Json -Depth 5 - - # Write-Host "Calling AI API with question: $question" -ForegroundColor Green - # Write-Host "Request Body: $Body" -ForegroundColor Green - - $Headers = @{ - "Content-Type" = "application/json" - } - - $Response = Invoke-RestMethod -Uri $Uri -Method Post -Headers $Headers -Body $Body - - return $Response.candidates.content.parts.text -} - -function Get-MtMaesterConfig($ConfigFilePath) { - if (-not (Test-Path $ConfigFilePath)) { - Write-Host "Maester config file not found. Creating a new one." -ForegroundColor Yellow - $maesterConfig = @{ - TestSettings = @() - } - } else { - Write-Host "Maester config file found. Loading existing settings, if you want a refresh all severity, delete TestSettings and run again." -ForegroundColor Green - $maesterConfig = Get-Content -Path $ConfigFilePath -Raw | ConvertFrom-Json - } - return $maesterConfig -} - -function Set-MtMaesterConfig($ConfigFilePath, $MaesterConfig) { - # Always sort TestSettings by Id - $MaesterConfig.TestSettings = $MaesterConfig.TestSettings | Sort-Object Id - # Convert the test settings array to JSON - $maesterConfigJson = $MaesterConfig | ConvertTo-Json -Depth 10 - # Save the setting - Set-Content -Path $ConfigFilePath -Value $maesterConfigJson -Force -} - -# Read the test-results.json file -$testResultsFilePath = "./test-results.json" -$testResults = Get-Content -Path $testResultsFilePath -Raw | ConvertFrom-Json - -$promptFilePath = "./prompt-severity.md" -$promptTemplate = Get-Content -Path $promptFilePath -Raw | Out-String - -$maesterConfig = Get-MtMaesterConfig './maester-config.json' - -# Loop through each test result and create a test setting -foreach ($testResult in $testResults.Tests) { - - # Skip if test already has a severity - if (![string]::IsNullOrEmpty($testResult.Severity)) { - Write-Host "Test $($testResult.Id) already has a severity $($testResult.ResultDetail.Severity). Skipping." -ForegroundColor Yellow - continue - } - - # Check if the test already exists in the Maester config - $existingSetting = $maesterConfig.TestSettings | Where-Object { $_.Id -eq $testResult.Id } - if ($existingSetting) { - Write-Host "Test $($testResult.Id) already exists in Maester config. Skipping." -ForegroundColor Yellow - } else { # Find out the severity of the test - $testInfo = [PSCustomObject]@{ - Id = $testResult.Id - Title = $testResult.Title - Description = $testResult.ResultDetail.Description - } - $testInfoJson = $testInfo | ConvertTo-Json -Depth 5 - - $prompt = $promptTemplate -replace "%TEST_INFO_JSON%", $testInfoJson - - Write-Host $testResult.Id " - " $testResult.Title -ForegroundColor Green - Start-Sleep -Seconds 5 - # Call the AI API with the prompt - $aiResponse = Get-PromptResult -prompt $prompt - # Remove the \n from the response - $severity = $aiResponse -replace "\n", "" - Write-Host "AI Response: $aiResponse" -ForegroundColor Blue - - # Create a new test setting object - $testSetting = [PSCustomObject]@{ - Id = $testResult.Id - Title = $testResult.Title - Severity = $severity - } - # Add the test setting to the array - $maesterConfig.TestSettings += $testSetting - - # Save the setting so we can resume if the script fails (AI API throttling, etc) - Set-MtMaesterConfig -ConfigFilePath $maesterConfigFilePath -MaesterConfig $maesterConfig - } -} \ No newline at end of file diff --git a/build/aitools/test-metadata/Update-TestMetadata.ps1 b/build/aitools/test-metadata/Update-TestMetadata.ps1 new file mode 100644 index 000000000..becb7f9c9 --- /dev/null +++ b/build/aitools/test-metadata/Update-TestMetadata.ps1 @@ -0,0 +1,152 @@ +# This script will read the tests in test-results.json and maester-config.json (if it exists) +# then determine the severity and required permissions of the test using the Gemini AI API. +# The test-results.json file is a copy of one of the latest runs of Invoke-Maester. + +function Get-PromptResult($prompt) { + $apiKey = $Env:GeminiApiKey + if (-not $apiKey) { + Write-Host "Gemini API key not found in environment variable. Set with the following command." -ForegroundColor Red + Write-Host ">`$Env:GeminiApiKey = ''" -ForegroundColor Red + Write-Host "You can get a new key from https://ai.google.dev/gemini-api/docs/api-key" + exit 1 + } + $uri = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=$apiKey" + + + $Body = @{ + contents = @( + @{ + parts = @( + @{ + text = $prompt + } + ) + } + ) + } | ConvertTo-Json -Depth 5 + + $Headers = @{ + "Content-Type" = "application/json" + } + + $Response = Invoke-RestMethod -Uri $Uri -Method Post -Headers $Headers -Body $Body + + return $Response.candidates.content.parts.text +} + +function Get-MtMaesterConfig($ConfigFilePath) { + if (-not (Test-Path $ConfigFilePath)) { + Write-Host "Maester config file not found. Creating a new one." -ForegroundColor Yellow + $maesterConfig = @{ + TestSettings = @() + } + } else { + Write-Host "Maester config file found. Loading existing settings." -ForegroundColor Green + $maesterConfig = Get-Content -Path $ConfigFilePath -Raw | ConvertFrom-Json + } + return $maesterConfig +} + +function Set-MtMaesterConfig($ConfigFilePath, $MaesterConfig) { + # Always sort TestSettings by Id + $MaesterConfig.TestSettings = $MaesterConfig.TestSettings | Sort-Object Id + # Convert the test settings array to JSON + $maesterConfigJson = $MaesterConfig | ConvertTo-Json -Depth 10 + # Save the setting + Set-Content -Path $ConfigFilePath -Value $maesterConfigJson -Force +} + +function Get-TestFunctionCode($ScriptBlock) { + # Heuristic: Find the first Test-Mt* or Get-Mt* function called in the script block + if ($ScriptBlock -match '(Test-Mt|Get-Mt|Get-ORCA|Get-Az|Get-EXO)[a-zA-Z0-9]+') { + $functionName = $Matches[0] + Write-Host "Searching for code for: $functionName" -ForegroundColor Cyan + + # Search in powershell/public and powershell/internal + $file = Get-ChildItem -Path "../../powershell" -Recurse -Filter "$functionName.ps1" | Select-Object -First 1 + if ($file) { + return Get-Content -Path $file.FullName -Raw + } + } + return "# Code not found for this test.`n$ScriptBlock" +} + +# Change to the script's directory context +$OriginalLocation = Get-Location +Set-Location $PSScriptRoot + +try { + # Read the test-results.json file + $testResultsFilePath = "./test-results.json" + if (-not (Test-Path $testResultsFilePath)) { + Write-Error "test-results.json not found at $testResultsFilePath" + exit 1 + } + $testResults = Get-Content -Path $testResultsFilePath -Raw | ConvertFrom-Json + + $promptFilePath = "./prompt-severity.md" + $promptTemplate = Get-Content -Path $promptFilePath -Raw | Out-String + + $configPath = "../../tests/maester-config.json" + $maesterConfig = Get-MtMaesterConfig $configPath + + # Loop through each test result and create a test setting + foreach ($testResult in $testResults.Tests) { + + # Skip if test already has both severity AND permissions (optional: force refresh logic) + $existingSetting = $maesterConfig.TestSettings | Where-Object { $_.Id -eq $testResult.Id } + + if ($existingSetting -and $existingSetting.Severity -and $existingSetting.RequiredPermissions) { + Write-Host "Test $($testResult.Id) already has metadata. Skipping." -ForegroundColor Yellow + continue + } + + # Find out the code of the test + $testCode = Get-TestFunctionCode -ScriptBlock $testResult.ScriptBlock + + $testInfo = [PSCustomObject]@{ + Id = $testResult.Id + Title = $testResult.Title + Description = $testResult.ResultDetail.Description + } + $testInfoJson = $testInfo | ConvertTo-Json -Depth 5 + + $prompt = $promptTemplate -replace "%TEST_INFO_JSON%", $testInfoJson + $prompt = $prompt -replace "%TEST_CODE%", $testCode + + Write-Host "Processing $($testResult.Id): $($testResult.Title)" -ForegroundColor Green + + # Call the AI API with the prompt + try { + $aiResponse = Get-PromptResult -prompt $prompt + Write-Host "AI Response: $aiResponse" -ForegroundColor Blue + + # AI response should be pure JSON now + $metadata = $aiResponse | ConvertFrom-Json + + if ($existingSetting) { + $existingSetting.Severity = $metadata.Severity + $existingSetting.RequiredPermissions = $metadata.RequiredPermissions + } else { + # Create a new test setting object + $testSetting = [PSCustomObject]@{ + Id = $testResult.Id + Title = $testResult.Title + Severity = $metadata.Severity + RequiredPermissions = $metadata.RequiredPermissions + } + $maesterConfig.TestSettings += $testSetting + } + + # Save periodically + Set-MtMaesterConfig -ConfigFilePath $configPath -MaesterConfig $maesterConfig + } catch { + Write-Warning "Failed to process $($testResult.Id): $($_.Exception.Message)" + } + + # Rate limiting friendly + Start-Sleep -Seconds 2 + } +} finally { + Set-Location $OriginalLocation +} diff --git a/build/aitools/test-metadata/prompt-severity.md b/build/aitools/test-metadata/prompt-severity.md index a9f9bd920..6f7d50327 100644 --- a/build/aitools/test-metadata/prompt-severity.md +++ b/build/aitools/test-metadata/prompt-severity.md @@ -1,13 +1,65 @@ # Instructions -Based on the five severity levels below, tell me the severity level that should be used for the test in the testInfo.json file. - -In the result, only include one of the severity levels (Critical, High, Medium, Low, Info) . Do not include any other text or characters (e.g. quotes, brackets, new line, etc.). +Based on the test information and the PowerShell code provided below, determine: +1. The **Severity** level (Critical, High, Medium, Low, Info). +2. The **RequiredPermissions** needed to run this test across different services. + +Output your response ONLY as a valid JSON object with the following keys: `Severity`, `RequiredPermissions`. +Do not include markdown fences or any other text. + +Example output: +{ + "Severity": "High", + "RequiredPermissions": { + "Graph": ["Policy.Read.All", "User.Read.All"], + "EntraRoles": ["Global Reader"], + "ExchangeOnline": ["View-Only Configuration"], + "Azure": ["Reader"] + } +} --- testInfo.json %TEST_INFO_JSON% +--- +PowerShell Code +%TEST_CODE% + +--- + +## Permission Mapping Rules + +Analyze the PowerShell code to identify which APIs and services are being called. + +### Microsoft Graph (Graph) +- If `Invoke-MtGraphRequest` is used, look at the `-RelativeUri`. + - `/policies/conditionalAccessPolicies` -> `Policy.Read.ConditionalAccess` or `Policy.Read.All` + - `/users` -> `User.Read.All` or `Directory.Read.All` + - `/applications` -> `Application.Read.All` + - `/groups` -> `Group.Read.All` + - `/directoryRoles` -> `RoleManagement.Read.Directory` +- Map other endpoints to their documented Graph permissions. +- Always prefer `.Read` permissions over `.ReadWrite` unless the code is performing a POST/PATCH/DELETE. + +### Entra ID Directory Roles (EntraRoles) +- If the test queries privileged data (like Global Admins, PIM assignments, or CA policies), specify at least one role that would grant this access, such as: + - `Global Reader` + - `Security Reader` + - `Global Administrator` (only if highly privileged access is needed) + +### Exchange Online (ExchangeOnline) +- If `Get-MtExo` or `Get-EXO*` cmdlets are used, identify the required Management Role: + - `View-Only Configuration` (most common for reading settings) + - `Security Reader` + - `View-Only Recipients` + +### Azure RBAC (Azure) +- If `Invoke-MtAzureRequest` or `Get-Az*` cmdlets are used, identify the required Azure role: + - `Reader` + - `Security Reader` + - `Owner` / `Contributor` (if write access is needed) + --- ## Severity Levels diff --git a/powershell/internal/Test-MtHasPermission.ps1 b/powershell/internal/Test-MtHasPermission.ps1 index 455f4cc56..7971035e5 100644 --- a/powershell/internal/Test-MtHasPermission.ps1 +++ b/powershell/internal/Test-MtHasPermission.ps1 @@ -24,13 +24,31 @@ function Test-MtHasPermission { Write-Verbose "Checking permissions for TestId: $TestId" + # 1. Check Global Bypass (CLI switch -SkipPermissionCheck) + if ($__MtSession.SkipPermissionCheck) { + Write-Verbose "Permission check skipped globally via CLI." + return $true + } + + # 2. Check Global Bypass (Config GlobalSettings.SkipPermissionCheck) + if ($__MtSession.MaesterConfig.GlobalSettings.SkipPermissionCheck) { + Write-Verbose "Permission check skipped globally via Maester config." + return $true + } + if (-not $RequiredPermissions -and [string]::IsNullOrEmpty($TestId)) { return $true } - if (-not $RequiredPermissions) { + $testSetting = $null + if (-not $RequiredPermissions -and ![string]::IsNullOrEmpty($TestId)) { $testSetting = Get-MtMaesterConfigTestSetting -TestId $TestId if ($null -ne $testSetting) { + # 3. Check Per-Test Bypass (Config TestSettings[].SkipPermissionCheck) + if ($testSetting.SkipPermissionCheck) { + Write-Verbose "Permission check skipped for test $TestId via Maester config." + return $true + } $RequiredPermissions = $testSetting.RequiredPermissions } } diff --git a/powershell/public/Invoke-Maester.ps1 b/powershell/public/Invoke-Maester.ps1 index 2940e75ba..1ea8cce5e 100644 --- a/powershell/public/Invoke-Maester.ps1 +++ b/powershell/public/Invoke-Maester.ps1 @@ -204,6 +204,9 @@ [Parameter(HelpMessage = 'Do not show the logo when starting Maester.')] [switch] $NoLogo, + # Skip the permission check for all tests. + [switch] $SkipPermissionCheck, + # The root directory for configuration drift tracking. [Parameter(HelpMessage = 'Specify drift root directory, see https://maester.dev/docs/tests/MT.1060')] [string] $DriftRoot @@ -306,6 +309,10 @@ # Reset the graph cache and urls to avoid stale data. Clear-ModuleVariable + if ($SkipPermissionCheck) { + $__MtSession.SkipPermissionCheck = $true + } + if (-not $DisableTelemetry) { Write-Telemetry -EventName InvokeMaester } diff --git a/tests/maester-config.json b/tests/maester-config.json index 90c08726e..a9a1dafcd 100644 --- a/tests/maester-config.json +++ b/tests/maester-config.json @@ -1,7 +1,8 @@ { "GlobalSettings": { "EmergencyAccessAccounts": [], - "DataverseEnvironmentUrl": "" + "DataverseEnvironmentUrl": "", + "SkipPermissionCheck": false }, "TestSettings": [ {