diff --git a/Hawk/Hawk.psd1 b/Hawk/Hawk.psd1 index 74ffcfc..ce37aa9 100644 --- a/Hawk/Hawk.psd1 +++ b/Hawk/Hawk.psd1 @@ -73,7 +73,7 @@ 'Get-HawkUserAutoReply', 'Get-HawkUserMessageTrace', 'Get-HawkUserMobileDevice', - 'Get-HawkTenantAZAdmins', + 'Get-HawkTenantAZAdmin', 'Get-HawkTenantEXOAdmins', 'Get-HawkTenantMailItemsAccessed', 'Get-HawkTenantAppAndSPNCredentialDetails', diff --git a/Hawk/functions/Tenant/Get-HawkTenantAZAdmin.ps1 b/Hawk/functions/Tenant/Get-HawkTenantAZAdmin.ps1 new file mode 100644 index 0000000..2a38d57 --- /dev/null +++ b/Hawk/functions/Tenant/Get-HawkTenantAZAdmin.ps1 @@ -0,0 +1,91 @@ +Function Get-HawkTenantAZAdmin { + <# + .SYNOPSIS + Tenant Azure Active Directory Administrator export using Microsoft Graph. + .DESCRIPTION + Tenant Azure Active Directory Administrator export. Reviewing administrator access is key to knowing who can make changes + to the tenant and conduct other administrative actions to users and applications. + .EXAMPLE + Get-HawkTenantAZAdmin + Gets all Azure AD Admins + .OUTPUTS + AzureADAdministrators.csv + .LINK + https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.identity.directorymanagement/get-mgdirectoryrole + .NOTES + Requires Microsoft.Graph.Identity.DirectoryManagement module + #> + [CmdletBinding()] + param() + + BEGIN { + # Initializing Hawk Object if not present + if ([string]::IsNullOrEmpty($Hawk.FilePath)) { + Initialize-HawkGlobalObject + } + Out-LogFile "Gathering Azure AD Administrators" + + Test-GraphConnection + Send-AIEvent -Event "CmdRun" + } + + PROCESS { + try { + # Get all directory roles + $directoryRoles = Get-MgDirectoryRole -ErrorAction Stop + Out-LogFile "Retrieved $(($directoryRoles | Measure-Object).Count) directory roles" + + $roles = foreach ($role in $directoryRoles) { + # Get members for each role + $members = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -ErrorAction Stop + + if (-not $members) { + [PSCustomObject]@{ + AdminGroupName = $role.DisplayName + Members = "No Members" + MemberType = "None" # Added member type for better analysis + MemberId = $null + } + } + else { + foreach ($member in $members) { + # Determine member type and get appropriate properties + if ($member.AdditionalProperties.'@odata.type' -eq "#microsoft.graph.user") { + [PSCustomObject]@{ + AdminGroupName = $role.DisplayName + Members = $member.AdditionalProperties.userPrincipalName + MemberType = "User" + MemberId = $member.Id + } + } + else { + # Groups or Service Principals + [PSCustomObject]@{ + AdminGroupName = $role.DisplayName + Members = $member.AdditionalProperties.displayName + MemberType = ($member.AdditionalProperties.'@odata.type' -replace '#microsoft.graph.', '') + MemberId = $member.Id + } + } + } + } + } + + if ($roles) { + $roles | Out-MultipleFileType -FilePrefix "AzureADAdministrators" -csv -json + Out-LogFile "Successfully exported Azure AD Administrators data" + } + else { + Out-LogFile "No administrator roles found or accessible" -notice + } + } + catch { + Out-LogFile "Error retrieving Azure AD Administrators: $($_.Exception.Message)" -notice + Write-Error -ErrorRecord $_ -ErrorAction Continue + } + } + + END { + Out-LogFile "Completed exporting Azure AD Admins" + } + } \ No newline at end of file diff --git a/Hawk/functions/Tenant/Get-HawkTenantAZAdmins.ps1 b/Hawk/functions/Tenant/Get-HawkTenantAZAdmins.ps1 deleted file mode 100644 index 9d33a92..0000000 --- a/Hawk/functions/Tenant/Get-HawkTenantAZAdmins.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -Function Get-HawkTenantAZAdmins{ -<# -.SYNOPSIS - Tenant Azure Active Directory Administrator export. Must be connected to Azure-AD using the Connect-AzureAD cmdlet -.DESCRIPTION - Tenant Azure Active Directory Administrator export. Reviewing administrator access is key to knowing who can make changes - to the tenant and conduct other administrative actions to users and applications. -.EXAMPLE - Get-HawkTenantAZAdmins - Gets all Azure AD Admins -.OUTPUTS - AzureADAdministrators.csv -.LINK - https://docs.microsoft.com/en-us/powershell/module/azuread/get-azureaddirectoryrolemember?view=azureadps-2.0 -.NOTES -#> -BEGIN{ - #Initializing Hawk Object if not present - if ([string]::IsNullOrEmpty($Hawk.FilePath)) { - Initialize-HawkGlobalObject - } - Out-LogFile "Gathering Azure AD Administrators" - - Test-AzureADConnection -} -PROCESS{ - $roles = foreach ($role in Get-MgDirectoryRole){ - $admins = (Get-MGDirectoryRoleMember -DirectoryRoleId $role.id) - if ([string]::IsNullOrWhiteSpace($admins)) { - [PSCustomObject]@{ - AdminGroupName = $role.DisplayName - Members = "No Members" - } - } - foreach ($admin in $admins){ - if($admin.AdditionalProperties.'@odata.type' -eq "#microsoft.graph.user"){ - [PSCustomObject]@{ - AdminGroupName = $role.DisplayName - Members = $admin.AdditionalProperties.userPrincipalName - } - } - else{ - [PSCustomObject]@{ - AdminGroupName = $role.DisplayName - Members = $admin.AdditionalProperties.displayName - } - } - } - } - $roles | Out-MultipleFileType -FilePrefix "AzureADAdministrators" -csv -json - -} -END{ - Out-LogFile "Completed exporting Azure AD Admins" -} -}#End Function \ No newline at end of file diff --git a/Hawk/functions/Tenant/Start-HawkTenantInvestigation.ps1 b/Hawk/functions/Tenant/Start-HawkTenantInvestigation.ps1 index 5c2cb11..dc3b555 100644 --- a/Hawk/functions/Tenant/Start-HawkTenantInvestigation.ps1 +++ b/Hawk/functions/Tenant/Start-HawkTenantInvestigation.ps1 @@ -87,8 +87,8 @@ } if ($PSCmdlet.ShouldProcess("Azure Admins", "Get Azure admin list")) { - Out-LogFile "Running Get-HawkTenantAZAdmins" -action - Get-HawkTenantAZAdmins + Out-LogFile "Running Get-HawkTenantAZAdmin" -action + Get-HawkTenantAZAdmin } if ($PSCmdlet.ShouldProcess("App and SPN Credentials", "Get credential details")) { diff --git a/Hawk/internal/configurations/PSScriptAnalyzerSettings.psd1 b/Hawk/internal/configurations/PSScriptAnalyzerSettings.psd1 index d9f535d..f18fdeb 100644 --- a/Hawk/internal/configurations/PSScriptAnalyzerSettings.psd1 +++ b/Hawk/internal/configurations/PSScriptAnalyzerSettings.psd1 @@ -5,5 +5,7 @@ # It is assumed this was done with good reason. 'PSAvoidTrailingWhitespace' 'PSShouldProcess' + # Exclude this as old test rules use Global Vars, will need to fix old tests and re-include this rule + 'PSAvoidGlobalVars' ) } \ No newline at end of file diff --git a/Hawk/tests/Run-PesterTests.ps1 b/Hawk/tests/Run-PesterTests.ps1 index 65fca6e..43daf9a 100644 --- a/Hawk/tests/Run-PesterTests.ps1 +++ b/Hawk/tests/Run-PesterTests.ps1 @@ -1,41 +1,147 @@ -# Load Pester module if not already loaded -Import-Module -Name Pester -ErrorAction Stop - -# Log function for consistent output -function Log { - param( - [string]$Message, - [string]$Level = "Info" - ) - $timestamp = Get-Date -Format "HH:mm:ss" - Write-Output "[$timestamp][$Level] $Message" -} - -# Start of test execution -Log "Starting Tests" - -# Define the tests directory -$testDirectory = "$PSScriptRoot" - -# Get all test files in the directory, excluding specific ones -$testFiles = Get-ChildItem -Path $testDirectory -Recurse -Include *.Tests.ps1 | - Where-Object { $_.Name -notin @('pester.ps1', 'Run-PesterTests.ps1') } - -# Ensure we found test files -if (-not $testFiles) { - Log "No test files found to execute." "Error" - exit 1 -} - -# Loop through each test file -foreach ($testFile in $testFiles) { - Log "Executing $($testFile.FullName)" "Info" - try { - # Run tests with minimal output - Invoke-Pester -Path $testFile.FullName -Output Minimal -PassThru | Out-Null - } catch { - Log "Error running $($testFile.FullName): $_" "Error" - } -} +<# + .NOTES + The original test this is based upon was written by June Blender. + After several rounds of modifications it stands now as it is, but the honor remains hers. + + Thank you June, for all you have done! + + .DESCRIPTION + This test evaluates the help for all commands in a module. + + .PARAMETER SkipTest + Disables this test. + + .PARAMETER CommandPath + List of paths under which the script files are stored. + This test assumes that all functions have their own file that is named after themselves. + These paths are used to search for commands that should exist and be tested. + Will search recursively and accepts wildcards, make sure only functions are found + + .PARAMETER ModuleName + Name of the module to be tested. + The module must already be imported + + .PARAMETER ExceptionsFile + File in which exceptions and adjustments are configured. + In it there should be two arrays and a hashtable defined: + $global:FunctionHelpTestExceptions + $global:HelpTestEnumeratedArrays + $global:HelpTestSkipParameterType + These can be used to tweak the tests slightly in cases of need. + See the example file for explanations on each of these usage and effect. +#> +[CmdletBinding()] +Param ( + [switch] + $SkipTest, + + [string[]] + $CommandPath = @("$global:testroot\..\functions", "$global:testroot\..\internal\functions"), + + [string] + $ModuleName = "Hawk", + + [string] + $ExceptionsFile = "$global:testroot\general\Help.Exceptions.ps1" +) +if ($SkipTest) { return } +. $ExceptionsFile + +$includedNames = (Get-ChildItem $CommandPath -Recurse -File | Where-Object Name -like "*.ps1").BaseName +$commandTypes = @('Cmdlet', 'Function') +if ($PSVersionTable.PSEdition -eq 'Desktop' ) { $commandTypes += 'Workflow' } +$commands = Get-Command -Module (Get-Module $ModuleName) -CommandType $commandTypes | Where-Object Name -In $includedNames + +## When testing help, remember that help is cached at the beginning of each session. +## To test, restart session. + + +foreach ($command in $commands) { + $commandName = $command.Name + + # Skip all functions that are on the exclusions list + if ($global:FunctionHelpTestExceptions -contains $commandName) { continue } -Log "All tests executed successfully!" "Success" + # The module-qualified command fails on Microsoft.PowerShell.Archive cmdlets + $Help = Get-Help $commandName -ErrorAction SilentlyContinue + + Describe "Test help for $commandName" { + + # If help is not found, synopsis in auto-generated help is the syntax diagram + It "should not be auto-generated" -TestCases @{ Help = $Help } { + $Help.Synopsis | Should -Not -BeLike '*`[``]*' + } + + # Should be a description for every function + It "gets description for $commandName" -TestCases @{ Help = $Help } { + $Help.Description | Should -Not -BeNullOrEmpty + } + + # Should be at least one example + It "gets example code from $commandName" -TestCases @{ Help = $Help } { + ($Help.Examples.Example | Select-Object -First 1).Code | Should -Not -BeNullOrEmpty + } + + # Should be at least one example description + It "gets example help from $commandName" -TestCases @{ Help = $Help } { + ($Help.Examples.Example.Remarks | Select-Object -First 1).Text | Should -Not -BeNullOrEmpty + } + + Context "Test parameter help for $commandName" { + + $common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable' + + $parameters = $command.ParameterSets.Parameters | Sort-Object -Property Name -Unique | Where-Object Name -notin $common + $parameterNames = $parameters.Name + $HelpParameterNames = $Help.Parameters.Parameter.Name | Sort-Object -Unique + foreach ($parameter in $parameters) { + $parameterName = $parameter.Name + $parameterHelp = $Help.parameters.parameter | Where-Object Name -EQ $parameterName + + # Should be a description for every parameter + It "gets help for parameter: $parameterName : in $commandName" -TestCases @{ parameterHelp = $parameterHelp } { + $parameterHelp.Description.Text | Should -Not -BeNullOrEmpty + } + + $codeMandatory = $parameter.IsMandatory.toString() + It "help for $parameterName parameter in $commandName has correct Mandatory value" -TestCases @{ parameterHelp = $parameterHelp; codeMandatory = $codeMandatory } { + $parameterHelp.Required | Should -Be $codeMandatory + } + + if ($HelpTestSkipParameterType[$commandName] -contains $parameterName) { continue } + + $codeType = $parameter.ParameterType.Name + + if ($parameter.ParameterType.IsEnum) { + # Enumerations often have issues with the typename not being reliably available + $names = $parameter.ParameterType::GetNames($parameter.ParameterType) + # Parameter type in Help should match code + It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ parameterHelp = $parameterHelp; names = $names } { + $parameterHelp.parameterValueGroup.parameterValue | Should -be $names + } + } + elseif ($parameter.ParameterType.FullName -in $HelpTestEnumeratedArrays) { + # Enumerations often have issues with the typename not being reliably available + $names = [Enum]::GetNames($parameter.ParameterType.DeclaredMembers[0].ReturnType) + It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ parameterHelp = $parameterHelp; names = $names } { + $parameterHelp.parameterValueGroup.parameterValue | Should -be $names + } + } + else { + # To avoid calling Trim method on a null object. + $helpType = if ($parameterHelp.parameterValue) { $parameterHelp.parameterValue.Trim() } + # Parameter type in Help should match code + It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ helpType = $helpType; codeType = $codeType } { + $helpType | Should -be $codeType + } + } + } + foreach ($helpParm in $HelpParameterNames) { + # Shouldn't find extra parameters in help. + It "finds help parameter in code: $helpParm" -TestCases @{ helpParm = $helpParm; parameterNames = $parameterNames } { + $helpParm -in $parameterNames | Should -Be $true + } + } + } + } +} \ No newline at end of file diff --git a/Hawk/tests/general/Help.Tests.ps1 b/Hawk/tests/general/Help.Tests.ps1 index 28fc10d..43daf9a 100644 --- a/Hawk/tests/general/Help.Tests.ps1 +++ b/Hawk/tests/general/Help.Tests.ps1 @@ -10,7 +10,7 @@ .PARAMETER SkipTest Disables this test. - + .PARAMETER CommandPath List of paths under which the script files are stored. This test assumes that all functions have their own file that is named after themselves. @@ -34,13 +34,13 @@ Param ( [switch] $SkipTest, - + [string[]] $CommandPath = @("$global:testroot\..\functions", "$global:testroot\..\internal\functions"), - + [string] $ModuleName = "Hawk", - + [string] $ExceptionsFile = "$global:testroot\general\Help.Exceptions.ps1" ) @@ -58,60 +58,60 @@ $commands = Get-Command -Module (Get-Module $ModuleName) -CommandType $commandTy foreach ($command in $commands) { $commandName = $command.Name - + # Skip all functions that are on the exclusions list if ($global:FunctionHelpTestExceptions -contains $commandName) { continue } - + # The module-qualified command fails on Microsoft.PowerShell.Archive cmdlets $Help = Get-Help $commandName -ErrorAction SilentlyContinue - + Describe "Test help for $commandName" { - + # If help is not found, synopsis in auto-generated help is the syntax diagram It "should not be auto-generated" -TestCases @{ Help = $Help } { $Help.Synopsis | Should -Not -BeLike '*`[``]*' } - + # Should be a description for every function It "gets description for $commandName" -TestCases @{ Help = $Help } { $Help.Description | Should -Not -BeNullOrEmpty } - + # Should be at least one example It "gets example code from $commandName" -TestCases @{ Help = $Help } { ($Help.Examples.Example | Select-Object -First 1).Code | Should -Not -BeNullOrEmpty } - + # Should be at least one example description It "gets example help from $commandName" -TestCases @{ Help = $Help } { ($Help.Examples.Example.Remarks | Select-Object -First 1).Text | Should -Not -BeNullOrEmpty } - + Context "Test parameter help for $commandName" { - + $common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable' - + $parameters = $command.ParameterSets.Parameters | Sort-Object -Property Name -Unique | Where-Object Name -notin $common $parameterNames = $parameters.Name $HelpParameterNames = $Help.Parameters.Parameter.Name | Sort-Object -Unique foreach ($parameter in $parameters) { $parameterName = $parameter.Name $parameterHelp = $Help.parameters.parameter | Where-Object Name -EQ $parameterName - + # Should be a description for every parameter It "gets help for parameter: $parameterName : in $commandName" -TestCases @{ parameterHelp = $parameterHelp } { $parameterHelp.Description.Text | Should -Not -BeNullOrEmpty } - + $codeMandatory = $parameter.IsMandatory.toString() It "help for $parameterName parameter in $commandName has correct Mandatory value" -TestCases @{ parameterHelp = $parameterHelp; codeMandatory = $codeMandatory } { $parameterHelp.Required | Should -Be $codeMandatory } - + if ($HelpTestSkipParameterType[$commandName] -contains $parameterName) { continue } - + $codeType = $parameter.ParameterType.Name - + if ($parameter.ParameterType.IsEnum) { # Enumerations often have issues with the typename not being reliably available $names = $parameter.ParameterType::GetNames($parameter.ParameterType)