diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf111f..6f2c164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ The format is based on and uses the types of changes according to [Keep a Change ## [Unreleased] +### Added + +- Test definitions filter function. +- Logging function for future use. +- Test grade written to console. + +### Changed + +- Updated sync function to include connection info. +- Refactored connect/disconnect functions to evaluate needed connections. + ## [0.1.3] - 2024-05-28 ### Added diff --git a/helpers/Build-Help.ps1 b/helpers/Build-Help.ps1 index c00d0b8..359b09f 100644 --- a/helpers/Build-Help.ps1 +++ b/helpers/Build-Help.ps1 @@ -4,10 +4,10 @@ Import-Module .\output\module\M365FoundationsCISReport\*\*.psd1 <# - $ver = "v0.1.2" + $ver = "v0.1.3" git checkout main git pull origin main - git tag -a $ver -m "Release version $ver Bugfix Update" + git tag -a $ver -m "Release version $ver refactor Update" git push origin $ver "Fix: PR #37" git push origin $ver diff --git a/source/Private/Connect-M365Suite.ps1 b/source/Private/Connect-M365Suite.ps1 index 73be107..97d9e4c 100644 --- a/source/Private/Connect-M365Suite.ps1 +++ b/source/Private/Connect-M365Suite.ps1 @@ -1,56 +1,58 @@ function Connect-M365Suite { [CmdletBinding()] param ( - # Parameter to specify the SharePoint Online Tenant Admin URL [Parameter(Mandatory)] - [string]$TenantAdminUrl + [string]$TenantAdminUrl, + + [Parameter(Mandatory)] + [string[]]$RequiredConnections ) -$VerbosePreference = "SilentlyContinue" + + $VerbosePreference = "SilentlyContinue" + try { + if ($RequiredConnections -contains "AzureAD" -or $RequiredConnections -contains "AzureAD | EXO") { + Write-Host "Connecting to Azure Active Directory..." -ForegroundColor Cyan + Connect-AzureAD | Out-Null + Write-Host "Successfully connected to Azure Active Directory." -ForegroundColor Green + } - # Attempt to connect to Azure Active Directory - Write-Host "Connecting to Azure Active Directory..." -ForegroundColor Cyan - Connect-AzureAD | Out-Null - Write-Host "Successfully connected to Azure Active Directory." -ForegroundColor Green - - # Attempt to connect to Exchange Online - Write-Host "Connecting to Exchange Online..." -ForegroundColor Cyan - Connect-ExchangeOnline | Out-Null - Write-Host "Successfully connected to Exchange Online." -ForegroundColor Green - try { - # Attempt to connect to Microsoft Graph with specified scopes + if ($RequiredConnections -contains "Microsoft Graph") { Write-Host "Connecting to Microsoft Graph with scopes: Directory.Read.All, Domain.Read.All, Policy.Read.All, Organization.Read.All" -ForegroundColor Cyan - Connect-MgGraph -Scopes "Directory.Read.All", "Domain.Read.All", "Policy.Read.All", "Organization.Read.All" -NoWelcome | Out-Null - Write-Host "Successfully connected to Microsoft Graph with specified scopes." -ForegroundColor Green - } - catch { - Write-Host "Failed to connect o MgGraph, attempting device auth." -ForegroundColor Yellow - # Attempt to connect to Microsoft Graph with specified scopes - Write-Host "Connecting to Microsoft Graph using device auth with scopes: Directory.Read.All, Domain.Read.All, Policy.Read.All, Organization.Read.All" -ForegroundColor Cyan - Connect-MgGraph -Scopes "Directory.Read.All", "Domain.Read.All", "Policy.Read.All", "Organization.Read.All" -UseDeviceCode -NoWelcome | Out-Null - Write-Host "Successfully connected to Microsoft Graph with specified scopes." -ForegroundColor Green + try { + Connect-MgGraph -Scopes "Directory.Read.All", "Domain.Read.All", "Policy.Read.All", "Organization.Read.All" -NoWelcome | Out-Null + Write-Host "Successfully connected to Microsoft Graph with specified scopes." -ForegroundColor Green + } + catch { + Write-Host "Failed to connect to MgGraph, attempting device auth." -ForegroundColor Yellow + Connect-MgGraph -Scopes "Directory.Read.All", "Domain.Read.All", "Policy.Read.All", "Organization.Read.All" -UseDeviceCode -NoWelcome | Out-Null + Write-Host "Successfully connected to Microsoft Graph with specified scopes." -ForegroundColor Green + } } - # Validate SharePoint Online Tenant Admin URL - if (-not $TenantAdminUrl) { - throw "SharePoint Online Tenant Admin URL is required." + if ($RequiredConnections -contains "EXO" -or $RequiredConnections -contains "AzureAD | EXO" -or $RequiredConnections -contains "Microsoft Teams | EXO") { + Write-Host "Connecting to Exchange Online..." -ForegroundColor Cyan + Connect-ExchangeOnline | Out-Null + Write-Host "Successfully connected to Exchange Online." -ForegroundColor Green } - # Attempt to connect to SharePoint Online - Write-Host "Connecting to SharePoint Online..." -ForegroundColor Cyan - Connect-SPOService -Url $TenantAdminUrl | Out-Null - Write-Host "Successfully connected to SharePoint Online." -ForegroundColor Green + if ($RequiredConnections -contains "SPO") { + Write-Host "Connecting to SharePoint Online..." -ForegroundColor Cyan + Connect-SPOService -Url $TenantAdminUrl | Out-Null + Write-Host "Successfully connected to SharePoint Online." -ForegroundColor Green + } - # Attempt to connect to Microsoft Teams - Write-Host "Connecting to Microsoft Teams..." -ForegroundColor Cyan - Connect-MicrosoftTeams | Out-Null - Write-Host "Successfully connected to Microsoft Teams." -ForegroundColor Green + if ($RequiredConnections -contains "Microsoft Teams" -or $RequiredConnections -contains "Microsoft Teams | EXO") { + Write-Host "Connecting to Microsoft Teams..." -ForegroundColor Cyan + Connect-MicrosoftTeams | Out-Null + Write-Host "Successfully connected to Microsoft Teams." -ForegroundColor Green + } } catch { $VerbosePreference = "Continue" Write-Host "There was an error establishing one or more connections: $_" -ForegroundColor Red throw $_ } + $VerbosePreference = "Continue" } - diff --git a/source/Private/Disconnect-M365Suite.ps1 b/source/Private/Disconnect-M365Suite.ps1 index 686a38c..8d14b89 100644 --- a/source/Private/Disconnect-M365Suite.ps1 +++ b/source/Private/Disconnect-M365Suite.ps1 @@ -1,39 +1,59 @@ function Disconnect-M365Suite { + param ( + [Parameter(Mandatory)] + [string[]]$RequiredConnections + ) + # Clean up sessions try { - Write-Host "Disconnecting from Exchange Online..." -ForegroundColor Green - Disconnect-ExchangeOnline -Confirm:$false | Out-Null + if ($RequiredConnections -contains "EXO" -or $RequiredConnections -contains "AzureAD | EXO" -or $RequiredConnections -contains "Microsoft Teams | EXO") { + Write-Host "Disconnecting from Exchange Online..." -ForegroundColor Green + Disconnect-ExchangeOnline -Confirm:$false | Out-Null + } } catch { Write-Warning "Failed to disconnect from Exchange Online: $_" } + try { - Write-Host "Disconnecting from Azure AD..." -ForegroundColor Green - Disconnect-AzureAD | Out-Null + if ($RequiredConnections -contains "AzureAD" -or $RequiredConnections -contains "AzureAD | EXO") { + Write-Host "Disconnecting from Azure AD..." -ForegroundColor Green + Disconnect-AzureAD | Out-Null + } } catch { Write-Warning "Failed to disconnect from Azure AD: $_" } + try { - Write-Host "Disconnecting from Microsoft Graph..." -ForegroundColor Green - Disconnect-MgGraph | Out-Null + if ($RequiredConnections -contains "Microsoft Graph") { + Write-Host "Disconnecting from Microsoft Graph..." -ForegroundColor Green + Disconnect-MgGraph | Out-Null + } } catch { Write-Warning "Failed to disconnect from Microsoft Graph: $_" } + try { - Write-Host "Disconnecting from SharePoint Online..." -ForegroundColor Green - Disconnect-SPOService | Out-Null + if ($RequiredConnections -contains "SPO") { + Write-Host "Disconnecting from SharePoint Online..." -ForegroundColor Green + Disconnect-SPOService | Out-Null + } } catch { Write-Warning "Failed to disconnect from SharePoint Online: $_" } + try { - Write-Host "Disconnecting from Microsoft Teams..." -ForegroundColor Green - Disconnect-MicrosoftTeams | Out-Null + if ($RequiredConnections -contains "Microsoft Teams" -or $RequiredConnections -contains "Microsoft Teams | EXO") { + Write-Host "Disconnecting from Microsoft Teams..." -ForegroundColor Green + Disconnect-MicrosoftTeams | Out-Null + } } catch { Write-Warning "Failed to disconnect from Microsoft Teams: $_" } - Write-Host "All sessions have been disconnected." -ForegroundColor Green + + Write-Host "All necessary sessions have been disconnected." -ForegroundColor Green } \ No newline at end of file diff --git a/source/Private/Get-TestDefinitionsObject.ps1 b/source/Private/Get-TestDefinitionsObject.ps1 new file mode 100644 index 0000000..6e42d23 --- /dev/null +++ b/source/Private/Get-TestDefinitionsObject.ps1 @@ -0,0 +1,63 @@ +function Get-TestDefinitionsObject { + param ( + [Parameter(Mandatory = $true)] + [object[]]$TestDefinitions, + + [Parameter(Mandatory = $true)] + [string]$ParameterSetName, + + [string]$ELevel, + [string]$ProfileLevel, + [string[]]$IncludeRecommendation, + [string[]]$SkipRecommendation + ) + + Write-Verbose "Initial test definitions count: $($TestDefinitions.Count)" + + switch ($ParameterSetName) { + 'ELevelFilter' { + Write-Verbose "Applying ELevelFilter" + if ($null -ne $ELevel -and $null -ne $ProfileLevel) { + Write-Verbose "Filtering on ELevel = $ELevel and ProfileLevel = $ProfileLevel" + $TestDefinitions = $TestDefinitions | Where-Object { + $_.ELevel -eq $ELevel -and $_.ProfileLevel -eq $ProfileLevel + } + } + elseif ($null -ne $ELevel) { + Write-Verbose "Filtering on ELevel = $ELevel" + $TestDefinitions = $TestDefinitions | Where-Object { + $_.ELevel -eq $ELevel + } + } + elseif ($null -ne $ProfileLevel) { + Write-Verbose "Filtering on ProfileLevel = $ProfileLevel" + $TestDefinitions = $TestDefinitions | Where-Object { + $_.ProfileLevel -eq $ProfileLevel + } + } + } + 'IG1Filter' { + Write-Verbose "Applying IG1Filter" + $TestDefinitions = $TestDefinitions | Where-Object { $_.IG1 -eq 'TRUE' } + } + 'IG2Filter' { + Write-Verbose "Applying IG2Filter" + $TestDefinitions = $TestDefinitions | Where-Object { $_.IG2 -eq 'TRUE' } + } + 'IG3Filter' { + Write-Verbose "Applying IG3Filter" + $TestDefinitions = $TestDefinitions | Where-Object { $_.IG3 -eq 'TRUE' } + } + 'RecFilter' { + Write-Verbose "Applying RecFilter" + $TestDefinitions = $TestDefinitions | Where-Object { $IncludeRecommendation -contains $_.Rec } + } + 'SkipRecFilter' { + Write-Verbose "Applying SkipRecFilter" + $TestDefinitions = $TestDefinitions | Where-Object { $SkipRecommendation -notcontains $_.Rec } + } + } + + Write-Verbose "Filtered test definitions count: $($TestDefinitions.Count)" + return $TestDefinitions +} \ No newline at end of file diff --git a/source/Private/Merge-CISExcelAndCsvData.ps1 b/source/Private/Merge-CISExcelAndCsvData.ps1 index b63eacf..bc09c22 100644 --- a/source/Private/Merge-CISExcelAndCsvData.ps1 +++ b/source/Private/Merge-CISExcelAndCsvData.ps1 @@ -23,11 +23,10 @@ function Merge-CISExcelAndCsvData { foreach ($property in $excelItem.PSObject.Properties) { $newObject | Add-Member -MemberType NoteProperty -Name $property.Name -Value $property.Value } - + $newObject | Add-Member -MemberType NoteProperty -Name 'CSV_Connection' -Value $csvRow.Connection $newObject | Add-Member -MemberType NoteProperty -Name 'CSV_Status' -Value $csvRow.Status $newObject | Add-Member -MemberType NoteProperty -Name 'CSV_Details' -Value $csvRow.Details $newObject | Add-Member -MemberType NoteProperty -Name 'CSV_FailureReason' -Value $csvRow.FailureReason - return $newObject } @@ -37,7 +36,7 @@ function Merge-CISExcelAndCsvData { if ($csvRow) { CreateMergedObject -excelItem $item -csvRow $csvRow } else { - CreateMergedObject -excelItem $item -csvRow ([PSCustomObject]@{Status=$null; Details=$null; FailureReason=$null}) + CreateMergedObject -excelItem $item -csvRow ([PSCustomObject]@{Connection=$null;Status=$null; Details=$null; FailureReason=$null }) } } diff --git a/source/Private/Test-IsAdmin.ps1 b/source/Private/Test-IsAdmin.ps1 new file mode 100644 index 0000000..64c47dc --- /dev/null +++ b/source/Private/Test-IsAdmin.ps1 @@ -0,0 +1,22 @@ +function Test-IsAdmin { + <# + .SYNOPSIS + Checks if the current user is an administrator on the machine. + .DESCRIPTION + This private function returns a Boolean value indicating whether + the current user has administrator privileges on the machine. + It does this by creating a new WindowsPrincipal object, passing + in a WindowsIdentity object representing the current user, and + then checking if that principal is in the Administrator role. + .INPUTS + None. + .OUTPUTS + Boolean. Returns True if the current user is an administrator, and False otherwise. + .EXAMPLE + PS C:\> Test-IsAdmin + True + #> + + # Create a new WindowsPrincipal object for the current user and check if it is in the Administrator role + (New-Object Security.Principal.WindowsPrincipal ([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) +} \ No newline at end of file diff --git a/source/Private/Write-AuditLog.ps1 b/source/Private/Write-AuditLog.ps1 new file mode 100644 index 0000000..1ea0fb8 --- /dev/null +++ b/source/Private/Write-AuditLog.ps1 @@ -0,0 +1,212 @@ +function Write-AuditLog { + <# + .SYNOPSIS + Writes log messages to the console and updates the script-wide log variable. + .DESCRIPTION + The Write-AuditLog function writes log messages to the console based on the severity (Verbose, Warning, or Error) and updates + the script-wide log variable ($script:LogString) with the log entry. You can use the Start, End, and EndFunction switches to + manage the lifecycle of the logging. + .INPUTS + System.String + You can pipe a string to the Write-AuditLog function as the Message parameter. + You can also pipe an object with a Severity property as the Severity parameter. + .OUTPUTS + None + The Write-AuditLog function doesn't output any objects to the pipeline. It writes messages to the console and updates the + script-wide log variable ($script:LogString). + .PARAMETER BeginFunction + Sets the message to "Begin [FunctionName] function log.", where FunctionName is the name of the calling function, and adds it to the log variable. + .PARAMETER Message + The message string to log. + .PARAMETER Severity + The severity of the log message. Accepted values are 'Information', 'Warning', and 'Error'. Defaults to 'Information'. + .PARAMETER Start + Initializes the script-wide log variable and sets the message to "Begin [FunctionName] Log.", where FunctionName is the name of the calling function. + .PARAMETER End + Sets the message to "End Log" and exports the log to a CSV file if the OutputPath parameter is provided. + .PARAMETER EndFunction + Sets the message to "End [FunctionName] log.", where FunctionName is the name of the calling function, and adds it to the log variable. + .PARAMETER OutputPath + The file path for exporting the log to a CSV file when using the End switch. + .EXAMPLE + Write-AuditLog -Message "This is a test message." + + Writes a test message with the default severity (Information) to the console and adds it to the log variable. + .EXAMPLE + Write-AuditLog -Message "This is a warning message." -Severity "Warning" + + Writes a warning message to the console and adds it to the log variable. + .EXAMPLE + Write-AuditLog -Start + + Initializes the log variable and sets the message to "Begin [FunctionName] Log.", where FunctionName is the name of the calling function. + .EXAMPLE + Write-AuditLog -BeginFunction + + Sets the message to "Begin [FunctionName] function log.", where FunctionName is the name of the calling function, and adds it to the log variable. + .EXAMPLE + Write-AuditLog -EndFunction + + Sets the message to "End [FunctionName] log.", where FunctionName is the name of the calling function, and adds it to the log variable. + .EXAMPLE + Write-AuditLog -End -OutputPath "C:\Logs\auditlog.csv" + + Sets the message to "End Log", adds it to the log variable, and exports the log to a CSV file. + .NOTES + Author: DrIOSx +#> + [CmdletBinding(DefaultParameterSetName = 'Default')] + param( + ### + [Parameter( + Mandatory = $false, + HelpMessage = 'Input a Message string.', + Position = 0, + ParameterSetName = 'Default', + ValueFromPipeline = $true + )] + [ValidateNotNullOrEmpty()] + [string]$Message, + ### + [Parameter( + Mandatory = $false, + HelpMessage = 'Information, Warning or Error.', + Position = 1, + ParameterSetName = 'Default', + ValueFromPipelineByPropertyName = $true + )] + [ValidateNotNullOrEmpty()] + [ValidateSet('Information', 'Warning', 'Error')] + [string]$Severity = 'Information', + ### + [Parameter( + Mandatory = $false, + ParameterSetName = 'End' + )] + [switch]$End, + ### + [Parameter( + Mandatory = $false, + ParameterSetName = 'BeginFunction' + )] + [switch]$BeginFunction, + [Parameter( + Mandatory = $false, + ParameterSetName = 'EndFunction' + )] + [switch]$EndFunction, + ### + [Parameter( + Mandatory = $false, + ParameterSetName = 'Start' + )] + [switch]$Start, + ### + [Parameter( + Mandatory = $false, + ParameterSetName = 'End' + )] + [string]$OutputPath + ) + begin { + $ErrorActionPreference = "SilentlyContinue" + # Define variables to hold information about the command that was invoked. + $ModuleName = $Script:MyInvocation.MyCommand.Name -replace '\..*' + $callStack = Get-PSCallStack + if ($callStack.Count -gt 1) { + $FuncName = $callStack[1].Command + } else { + $FuncName = "DirectCall" # Or any other default name you prefer + } + #Write-Verbose "Funcname Name is $FuncName!" -Verbose + $ModuleVer = $MyInvocation.MyCommand.Version.ToString() + # Set the error action preference to continue. + $ErrorActionPreference = "Continue" + } + process { + try { + if (-not $Start -and -not (Test-Path variable:script:LogString)) { + throw "The logging variable is not initialized. Please call Write-AuditLog with the -Start switch or ensure $script:LogString is set." + } + $Function = $($FuncName + '.v' + $ModuleVer) + if ($Start) { + $script:LogString = @() + $Message = '+++ Begin Log | ' + $Function + ' |' + } + elseif ($BeginFunction) { + $Message = '>>> Begin Function Log | ' + $Function + ' |' + } + $logEntry = [pscustomobject]@{ + Time = ((Get-Date).ToString('yyyy-MM-dd hh:mmTss')) + Module = $ModuleName + PSVersion = ($PSVersionTable.PSVersion).ToString() + PSEdition = ($PSVersionTable.PSEdition).ToString() + IsAdmin = $(Test-IsAdmin) + User = "$Env:USERDOMAIN\$Env:USERNAME" + HostName = $Env:COMPUTERNAME + InvokedBy = $Function + Severity = $Severity + Message = $Message + RunID = -1 + } + if ($BeginFunction) { + $maxRunID = ($script:LogString | Where-Object { $_.InvokedBy -eq $Function } | Measure-Object -Property RunID -Maximum).Maximum + if ($null -eq $maxRunID) { $maxRunID = -1 } + $logEntry.RunID = $maxRunID + 1 + } + else { + $lastRunID = ($script:LogString | Where-Object { $_.InvokedBy -eq $Function } | Select-Object -Last 1).RunID + if ($null -eq $lastRunID) { $lastRunID = 0 } + $logEntry.RunID = $lastRunID + } + if ($EndFunction) { + $FunctionStart = "$((($script:LogString | Where-Object {$_.InvokedBy -eq $Function -and $_.RunId -eq $lastRunID } | Sort-Object Time)[0]).Time)" + $startTime = ([DateTime]::ParseExact("$FunctionStart", 'yyyy-MM-dd hh:mmTss', $null)) + $endTime = Get-Date + $timeTaken = $endTime - $startTime + $Message = '<<< End Function Log | ' + $Function + ' | Runtime: ' + "$($timeTaken.Minutes) min $($timeTaken.Seconds) sec" + $logEntry.Message = $Message + } + elseif ($End) { + $startTime = ([DateTime]::ParseExact($($script:LogString[0].Time), 'yyyy-MM-dd hh:mmTss', $null)) + $endTime = Get-Date + $timeTaken = $endTime - $startTime + $Message = '--- End Log | ' + $Function + ' | Runtime: ' + "$($timeTaken.Minutes) min $($timeTaken.Seconds) sec" + $logEntry.Message = $Message + } + $script:LogString += $logEntry + switch ($Severity) { + 'Warning' { + Write-Warning ('[WARNING] ! ' + $Message) + $UserInput = Read-Host "Warning encountered! Do you want to continue? (Y/N)" + if ($UserInput -eq 'N') { + throw "Script execution stopped by user." + } + } + 'Error' { Write-Error ('[ERROR] X - ' + $FuncName + ' ' + $Message) -ErrorAction Continue } + 'Verbose' { Write-Verbose ('[VERBOSE] ~ ' + $Message) } + Default { Write-Information ('[INFO] * ' + $Message) -InformationAction Continue} + } + } + catch { + throw "Write-AuditLog encountered an error (process block): $($_)" + } + + } + end { + try { + if ($End) { + if (-not [string]::IsNullOrEmpty($OutputPath)) { + $script:LogString | Export-Csv -Path $OutputPath -NoTypeInformation + Write-Verbose "LogPath: $(Split-Path -Path $OutputPath -Parent)" + } + else { + throw "OutputPath is not specified for End action." + } + } + } + catch { + throw "Error in Write-AuditLog (end block): $($_.Exception.Message)" + } + } +} \ No newline at end of file diff --git a/source/Public/Invoke-M365SecurityAudit.ps1 b/source/Public/Invoke-M365SecurityAudit.ps1 index fb0ee29..e8a29ee 100644 --- a/source/Public/Invoke-M365SecurityAudit.ps1 +++ b/source/Public/Invoke-M365SecurityAudit.ps1 @@ -59,7 +59,6 @@ .LINK https://criticalsolutionsnetwork.github.io/M365FoundationsCISReport/#Invoke-M365SecurityAudit #> - function Invoke-M365SecurityAudit { [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'Default')] [OutputType([CISAuditResult[]])] @@ -71,28 +70,28 @@ function Invoke-M365SecurityAudit { [string]$DomainName, # E-Level with optional ProfileLevel selection - [Parameter(ParameterSetName = 'ELevelFilter')] + [Parameter(Mandatory = $true, ParameterSetName = 'ELevelFilter')] [ValidateSet('E3', 'E5')] [string]$ELevel, - [Parameter(ParameterSetName = 'ELevelFilter')] + [Parameter(Mandatory = $true, ParameterSetName = 'ELevelFilter')] [ValidateSet('L1', 'L2')] [string]$ProfileLevel, # IG Filters, one at a time - [Parameter(ParameterSetName = 'IG1Filter')] + [Parameter(Mandatory = $true, ParameterSetName = 'IG1Filter')] [switch]$IncludeIG1, - [Parameter(ParameterSetName = 'IG2Filter')] + [Parameter(Mandatory = $true, ParameterSetName = 'IG2Filter')] [switch]$IncludeIG2, - [Parameter(ParameterSetName = 'IG3Filter')] + [Parameter(Mandatory = $true, ParameterSetName = 'IG3Filter')] [switch]$IncludeIG3, # Inclusion of specific recommendation numbers - [Parameter(ParameterSetName = 'RecFilter')] + [Parameter(Mandatory = $true, ParameterSetName = 'RecFilter')] [ValidateSet( - '1.1.1','1.1.3', '1.2.1', '1.2.2', '1.3.1', '1.3.3', '1.3.6', '2.1.1', '2.1.2', ` + '1.1.1', '1.1.3', '1.2.1', '1.2.2', '1.3.1', '1.3.3', '1.3.6', '2.1.1', '2.1.2', ` '2.1.3', '2.1.4', '2.1.5', '2.1.6', '2.1.7', '2.1.9', '3.1.1', '5.1.2.3', ` '5.1.8.1', '6.1.1', '6.1.2', '6.1.3', '6.2.1', '6.2.2', '6.2.3', '6.3.1', ` '6.5.1', '6.5.2', '6.5.3', '7.2.1', '7.2.10', '7.2.2', '7.2.3', '7.2.4', ` @@ -103,9 +102,9 @@ function Invoke-M365SecurityAudit { [string[]]$IncludeRecommendation, # Exclusion of specific recommendation numbers - [Parameter(ParameterSetName = 'SkipRecFilter')] + [Parameter(Mandatory = $true, ParameterSetName = 'SkipRecFilter')] [ValidateSet( - '1.1.1','1.1.3', '1.2.1', '1.2.2', '1.3.1', '1.3.3', '1.3.6', '2.1.1', '2.1.2', ` + '1.1.1', '1.1.3', '1.2.1', '1.2.2', '1.3.1', '1.3.3', '1.3.6', '2.1.1', '2.1.2', ` '2.1.3', '2.1.4', '2.1.5', '2.1.6', '2.1.7', '2.1.9', '3.1.1', '5.1.2.3', ` '5.1.8.1', '6.1.1', '6.1.2', '6.1.3', '6.2.1', '6.2.2', '6.2.3', '6.3.1', ` '6.5.1', '6.5.2', '6.5.3', '7.2.1', '7.2.10', '7.2.2', '7.2.3', '7.2.4', ` @@ -152,11 +151,6 @@ function Invoke-M365SecurityAudit { # Loop through each required module and assert its availability # Establishing connections - #if (!($DoNotConnect -or $DoNotTest)) { - # Establishing connections - if (!($DoNotConnect)) { - Connect-M365Suite -TenantAdminUrl $TenantAdminUrl - } # Load test definitions from CSV $testDefinitionsPath = Join-Path -Path $PSScriptRoot -ChildPath "helper\TestDefinitions.csv" @@ -164,42 +158,23 @@ function Invoke-M365SecurityAudit { # Load the Test Definitions into the script scope for use in other functions $script:TestDefinitionsObject = $testDefinitions # Apply filters based on parameter sets - switch ($PSCmdlet.ParameterSetName) { - 'ELevelFilter' { - if ($null -ne $ELevel -and $null -ne $ProfileLevel) { - $testDefinitions = $testDefinitions | Where-Object { - $_.ELevel -eq $ELevel -and $_.ProfileLevel -eq $ProfileLevel - } - } - elseif ($null -ne $ELevel) { - $testDefinitions = $testDefinitions | Where-Object { - $_.ELevel -eq $ELevel - } - } - elseif ($null -ne $ProfileLevel) { - $testDefinitions = $testDefinitions | Where-Object { - $_.ProfileLevel -eq $ProfileLevel - } - } - } - 'IG1Filter' { - $testDefinitions = $testDefinitions | Where-Object { $_.IG1 -eq 'TRUE' } - } - 'IG2Filter' { - $testDefinitions = $testDefinitions | Where-Object { $_.IG2 -eq 'TRUE' } - } - 'IG3Filter' { - $testDefinitions = $testDefinitions | Where-Object { $_.IG3 -eq 'TRUE' } - } - 'RecFilter' { - $testDefinitions = $testDefinitions | Where-Object { $IncludeRecommendation -contains $_.Rec } - } - 'SkipRecFilter' { - $testDefinitions = $testDefinitions | Where-Object { $SkipRecommendation -notcontains $_.Rec } - } + $params = @{ + TestDefinitions = $testDefinitions + ParameterSetName = $PSCmdlet.ParameterSetName + ELevel = $ELevel + ProfileLevel = $ProfileLevel + IncludeRecommendation = $IncludeRecommendation + SkipRecommendation = $SkipRecommendation } + $testDefinitions = Get-TestDefinitionsObject @params # End switch ($PSCmdlet.ParameterSetName) + # Extract unique connections needed + $requiredConnections = $testDefinitions.Connection | Sort-Object -Unique + # Establishing connections if required + if (!($DoNotConnect)) { + Connect-M365Suite -TenantAdminUrl $TenantAdminUrl -RequiredConnections $requiredConnections + } # Determine which test files to load based on filtering $testsToLoad = $testDefinitions.TestFileName | ForEach-Object { $_ -replace '.ps1$', '' } @@ -251,8 +226,20 @@ function Invoke-M365SecurityAudit { End { if (!($DoNotDisconnect)) { # Clean up sessions - Disconnect-M365Suite + Disconnect-M365Suite -RequiredConnections $requiredConnections } + # Calculate the total number of tests + $totalTests = $allAuditResults.Count + + # Calculate the number of passed tests + $passedTests = $allAuditResults.ToArray() | Where-Object { $_.Result -eq $true } | Measure-Object | Select-Object -ExpandProperty Count + + # Calculate the pass percentage + $passPercentage = if ($totalTests -eq 0) { 0 } else { [math]::Round(($passedTests / $totalTests) * 100, 2) } + + # Display the pass percentage to the user + Write-Host "Audit completed. $passedTests out of $totalTests tests passed." -ForegroundColor Cyan + Write-Host "Your passing percentage is $passPercentage%." # Return all collected audit results return $allAuditResults.ToArray() # Check if the Disconnect switch is present diff --git a/source/helper/TestDefinitions.csv b/source/helper/TestDefinitions.csv index 32c46b7..a284e76 100644 --- a/source/helper/TestDefinitions.csv +++ b/source/helper/TestDefinitions.csv @@ -1,5 +1,5 @@ Index,TestFileName,Rec,RecDescription,ELevel,ProfileLevel,CISControl,CISDescription,IG1,IG2,IG3,Automated,Connection -1,Test-AdministrativeAccountCompliance.ps1,1.1.1,Ensure Administrative accounts are separate and cloud-only,E3,L1,5.4,Restrict Administrator Privileges to Dedicated Administrator Accounts,TRUE,TRUE,TRUE,FALSE,AzureAD +1,Test-AdministrativeAccountCompliance.ps1,1.1.1,Ensure Administrative accounts are separate and cloud-only,E3,L1,5.4,Restrict Administrator Privileges to Dedicated Administrator Accounts,TRUE,TRUE,TRUE,FALSE,Microsoft Graph 2,Test-GlobalAdminsCount.ps1,1.1.3,Ensure that between two and four global admins are designated,E3,L1,5.1,Establish and Maintain an Inventory of Accounts,TRUE,TRUE,TRUE,TRUE,Microsoft Graph 3,Test-ManagedApprovedPublicGroups.ps1,1.2.1,Ensure that only organizationally managed/approved public groups exist,E3,L2,3.3,Configure Data Access Control Lists,TRUE,TRUE,TRUE,TRUE,Microsoft Graph 4,Test-BlockSharedMailboxSignIn.ps1,1.2.2,Ensure sign-in to shared mailboxes is blocked,E3,L1,0,Explicitly Not Mapped,FALSE,FALSE,FALSE,TRUE,AzureAD | EXO @@ -18,8 +18,8 @@ 17,Test-RestrictTenantCreation.ps1,5.1.2.3,Ensure 'Restrict non-admin users from creating tenants' is set to 'Yes',E3,L1,0,Explicitly Not Mapped,FALSE,FALSE,FALSE,TRUE,Microsoft Graph 18,Test-PasswordHashSync.ps1,5.1.8.1,Ensure password hash sync is enabled for hybrid deployments,E3,L1,6.7,Centralize Access Control,FALSE,TRUE,TRUE,TRUE,Microsoft Graph 19,Test-AuditDisabledFalse.ps1,6.1.1,Ensure 'AuditDisabled' organizationally is set to 'False',E3,L1,8.2,Collect Audit Logs,TRUE,TRUE,TRUE,TRUE,Microsoft Graph -20,Test-MailboxAuditingE3.ps1,6.1.2,Ensure mailbox auditing for Office E3 users is Enabled,E3,L1,8.2,Collect audit logs.,TRUE,TRUE,TRUE,TRUE,EXO -21,Test-MailboxAuditingE5.ps1,6.1.3,Ensure mailbox auditing for Office E5 users is Enabled,E5,L1,8.2,Collect audit logs.,TRUE,TRUE,TRUE,TRUE,EXO +20,Test-MailboxAuditingE3.ps1,6.1.2,Ensure mailbox auditing for Office E3 users is Enabled,E3,L1,8.2,Collect audit logs.,TRUE,TRUE,TRUE,TRUE,AzureAD | EXO +21,Test-MailboxAuditingE5.ps1,6.1.3,Ensure mailbox auditing for Office E5 users is Enabled,E5,L1,8.2,Collect audit logs.,TRUE,TRUE,TRUE,TRUE,AzureAD | EXO 22,Test-BlockMailForwarding.ps1,6.2.1,Ensure all forms of mail forwarding are blocked and/or disabled,E3,L1,0,Explicitly Not Mapped,FALSE,FALSE,FALSE,TRUE,EXO 23,Test-NoWhitelistDomains.ps1,6.2.2,Ensure mail transport rules do not whitelist specific domains,E3,L1,0,Explicitly Not Mapped,FALSE,FALSE,FALSE,TRUE,EXO 24,Test-IdentifyExternalEmail.ps1,6.2.3,Ensure email from external senders is identified,E3,L1,0,Explicitly Not Mapped,FALSE,FALSE,FALSE,TRUE,EXO diff --git a/tests/Unit/Private/Get-TestDefinitionsObject.tests.ps1 b/tests/Unit/Private/Get-TestDefinitionsObject.tests.ps1 new file mode 100644 index 0000000..4a2aa69 --- /dev/null +++ b/tests/Unit/Private/Get-TestDefinitionsObject.tests.ps1 @@ -0,0 +1,27 @@ +$ProjectPath = "$PSScriptRoot\..\..\.." | Convert-Path +$ProjectName = ((Get-ChildItem -Path $ProjectPath\*\*.psd1).Where{ + ($_.Directory.Name -match 'source|src' -or $_.Directory.Name -eq $_.BaseName) -and + $(try { Test-ModuleManifest $_.FullName -ErrorAction Stop } catch { $false } ) + }).BaseName + + +Import-Module $ProjectName + +InModuleScope $ProjectName { + Describe Get-PrivateFunction { + Context 'Default' { + BeforeEach { + $return = Get-PrivateFunction -PrivateData 'string' + } + + It 'Returns a single object' { + ($return | Measure-Object).Count | Should -Be 1 + } + + It 'Returns a string based on the parameter PrivateData' { + $return | Should -Be 'string' + } + } + } +} + diff --git a/tests/Unit/Private/Test-IsAdmin.tests.ps1 b/tests/Unit/Private/Test-IsAdmin.tests.ps1 new file mode 100644 index 0000000..4a2aa69 --- /dev/null +++ b/tests/Unit/Private/Test-IsAdmin.tests.ps1 @@ -0,0 +1,27 @@ +$ProjectPath = "$PSScriptRoot\..\..\.." | Convert-Path +$ProjectName = ((Get-ChildItem -Path $ProjectPath\*\*.psd1).Where{ + ($_.Directory.Name -match 'source|src' -or $_.Directory.Name -eq $_.BaseName) -and + $(try { Test-ModuleManifest $_.FullName -ErrorAction Stop } catch { $false } ) + }).BaseName + + +Import-Module $ProjectName + +InModuleScope $ProjectName { + Describe Get-PrivateFunction { + Context 'Default' { + BeforeEach { + $return = Get-PrivateFunction -PrivateData 'string' + } + + It 'Returns a single object' { + ($return | Measure-Object).Count | Should -Be 1 + } + + It 'Returns a string based on the parameter PrivateData' { + $return | Should -Be 'string' + } + } + } +} + diff --git a/tests/Unit/Private/Write-AuditLog.tests.ps1 b/tests/Unit/Private/Write-AuditLog.tests.ps1 new file mode 100644 index 0000000..4a2aa69 --- /dev/null +++ b/tests/Unit/Private/Write-AuditLog.tests.ps1 @@ -0,0 +1,27 @@ +$ProjectPath = "$PSScriptRoot\..\..\.." | Convert-Path +$ProjectName = ((Get-ChildItem -Path $ProjectPath\*\*.psd1).Where{ + ($_.Directory.Name -match 'source|src' -or $_.Directory.Name -eq $_.BaseName) -and + $(try { Test-ModuleManifest $_.FullName -ErrorAction Stop } catch { $false } ) + }).BaseName + + +Import-Module $ProjectName + +InModuleScope $ProjectName { + Describe Get-PrivateFunction { + Context 'Default' { + BeforeEach { + $return = Get-PrivateFunction -PrivateData 'string' + } + + It 'Returns a single object' { + ($return | Measure-Object).Count | Should -Be 1 + } + + It 'Returns a string based on the parameter PrivateData' { + $return | Should -Be 'string' + } + } + } +} +