Modules/Orchestrator.psm1
using module 'ScubaConfig\ScubaConfig.psm1' function Invoke-SCuBA { <# .SYNOPSIS Execute the SCuBAGear tool security baselines for specified M365 products. .Description This is the main function that runs the Providers, Rego, and Report creation all in one PowerShell script call. .Parameter ProductNames A list of one or more M365 shortened product names that the tool will assess when it is executed. Acceptable product name values are listed below. To assess Azure Active Directory you would enter the value aad. To assess Exchange Online you would enter exo and so forth. - Azure Active Directory: aad - Defender for Office 365: defender - Exchange Online: exo - MS Power Platform: powerplatform - SharePoint Online: sharepoint - MS Teams: teams. Use '*' to run all baselines. .Parameter M365Environment This parameter is used to authenticate to the different commercial/government environments. Valid values include "commercial", "gcc", "gcchigh", or "dod". - For M365 tenants with E3/E5 licenses enter the value **"commercial"**. - For M365 Government Commercial Cloud tenants with G3/G5 licenses enter the value **"gcc"**. - For M365 Government Commercial Cloud High tenants enter the value **"gcchigh"**. - For M365 Department of Defense tenants enter the value **"dod"**. Default value is 'commercial'. .Parameter OPAPath The folder location of the OPA Rego executable file. The OPA Rego executable embedded with this project is located in the project's root folder. If you want to execute the tool using a version of OPA Rego located in another folder, then customize the variable value with the full path to the alternative OPA Rego exe file. .Parameter LogIn A `$true` or `$false` variable that if set to `$true` will prompt you to provide credentials if you want to establish a connection to the specified M365 products in the **$ProductNames** variable. For most use cases, leave this variable to be `$true`. A connection is established in the current PowerShell terminal session with the first authentication. If you want to run another verification in the same PowerShell session simply set this variable to be `$false` to bypass the reauthenticating in the same session. Default is $true. Note: defender will ask for authentication even if this variable is set to `$false` .Parameter Version Will output the current ScubaGear version to the terminal without running this cmdlet. .Parameter AppID The application ID of the service principal that's used during certificate based authentication. A valid value is the GUID of the application ID (service principal). .Parameter CertificateThumbprint The thumbprint value specifies the certificate that's used for certificate base authentication. The underlying PowerShell modules retrieve the certificate from the user's certificate store. As such, a copy of the certificate must be located there. .Parameter Organization Specify the organization that's used in certificate based authentication. Use the tenant's tenantname.onmicrosoft.com domain for the parameter value. .Parameter OutPath The folder path where both the output JSON and the HTML report will be created. The folder will be created if it does not exist. Defaults to current directory. .Parameter OutFolderName The name of the folder in OutPath where both the output JSON and the HTML report will be created. Defaults to "M365BaselineConformance". The client's local timestamp will be appended. .Parameter OutProviderFileName The name of the Provider output JSON created in the folder created in OutPath. Defaults to "ProviderSettingsExport". .Parameter OutRegoFileName The name of the Rego output JSON and CSV created in the folder created in OutPath. Defaults to "TestResults". .Parameter OutReportName The name of the main html file page created in the folder created in OutPath. Defaults to "BaselineReports". .Parameter KeepIndividualJSON Keeps ScubaGear legacy output where files are not merged into an all in one JSON. This parameter is for backwards compatibility for those working with the older ScubaGear output files. .Parameter OutJsonFileName If KeepIndividualJSON is not set, the name of the consolidated json created in the folder created in OutPath. Defaults to "ScubaResults". The report UUID will be appended to this. .Parameter OutCsvFileName The CSV created in the folder created in OutPath that contains the CSV version of the test results. Defaults to "ScubaResults". .Parameter OutActionPlanFileName The CSV created in the folder created in OutPath that contains a CSV template prepopulated with the failed SHALL controls with fields for documenting failure causes and remediation plans. Defaults to "ActionPlan". .Parameter DisconnectOnExit Set switch to disconnect all active connections on exit from ScubaGear (default: $false) .Parameter ConfigFilePath Local file path to a JSON or YAML formatted configuration file. Configuration file parameters can be used in place of command-line parameters. Additional parameters and variables not available on the command line can also be included in the file that will be provided to the tool for use in specific tests. .Parameter DarkMode Set switch to enable report dark mode by default. .Parameter Quiet Do not launch external browser for report. .Parameter NumberOfUUIDCharactersToTruncate Controls how many characters will be truncated from the report UUID when appended to the end of OutJsonFileName. Valid values are 0, 13, 18, 36 .Example Invoke-SCuBA Run an assessment against by default a commercial M365 Tenant against the Azure Active Directory, Exchange Online, Microsoft Defender, One Drive, SharePoint Online, and Microsoft Teams security baselines. The output will stored in the current directory in a folder called M365BaselineConformance_*. .Example Invoke-SCuBA -Version This example returns the version of SCuBAGear. .Example Invoke-SCuBA -ConfigFilePath MyConfig.json This example uses the specified configuration file when executing SCuBAGear. .Example Invoke-SCuBA -ProductNames aad, defender -OPAPath . -OutPath . The example will run the tool against the Azure Active Directory, and Defender security baselines. .Example Invoke-SCuBA -ProductNames * -M365Environment dod -OPAPath . -OutPath . This example will run the tool against all available security baselines with the 'dod' teams endpoint. .Example Invoke-SCuBA -ProductNames aad,exo -M365Environment gcc -OPAPath . -OutPath . -DisconnectOnExit Run the tool against Azure Active Directory and Exchange Online security baselines, disconnecting connections for those products when complete. .Example Invoke-SCuBA -ProductNames * -CertificateThumbprint <insert-thumbprint> -AppID <insert-appid> -Organization "tenant.onmicrosoft.com" This example will run the tool against all available security baselines while authenticating using a Service Principal with the CertificateThumprint bundle of parameters. .Functionality Public #> [CmdletBinding(DefaultParameterSetName='Report')] param ( [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", '*', IgnoreCase = $false)] [string[]] $ProductNames = [ScubaConfig]::ScubaDefault('DefaultProductNames'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateSet("commercial", "gcc", "gcchigh", "dod", IgnoreCase = $false)] [ValidateNotNullOrEmpty()] [string] $M365Environment = [ScubaConfig]::ScubaDefault('DefaultM365Environment'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateScript({Test-Path -PathType Container $_})] [string] $OPAPath = [ScubaConfig]::ScubaDefault('DefaultOPAPath'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [ValidateSet($true, $false)] [boolean] $LogIn = [ScubaConfig]::ScubaDefault('DefaultLogIn'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [switch] $DisconnectOnExit, [Parameter(ParameterSetName = 'VersionOnly')] [ValidateNotNullOrEmpty()] [switch] $Version, [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $AppID, [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $CertificateThumbprint, [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $Organization, [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutPath = [ScubaConfig]::ScubaDefault('DefaultOutPath'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutFolderName = [ScubaConfig]::ScubaDefault('DefaultOutFolderName'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutProviderFileName = [ScubaConfig]::ScubaDefault('DefaultOutProviderFileName'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutRegoFileName = [ScubaConfig]::ScubaDefault('DefaultOutRegoFileName'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutReportName = [ScubaConfig]::ScubaDefault('DefaultOutReportName'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [switch] $KeepIndividualJSON, [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutJsonFileName = [ScubaConfig]::ScubaDefault('DefaultOutJsonFileName'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutCsvFileName = [ScubaConfig]::ScubaDefault('DefaultOutCsvFileName'), [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutActionPlanFileName = [ScubaConfig]::ScubaDefault('DefaultOutActionPlanFileName'), [Parameter(Mandatory = $true, ParameterSetName = 'Configuration')] [ValidateNotNullOrEmpty()] [ValidateScript({ if (-Not ($_ | Test-Path)){ throw "SCuBA configuration file or folder does not exist. $_" } if (-Not ($_ | Test-Path -PathType Leaf)){ throw "SCuBA configuration Path argument must be a file." } return $true })] [System.IO.FileInfo] $ConfigFilePath, [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [switch] $DarkMode, [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [switch] $Quiet, [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [ValidateSet(0, 13, 18, 36)] [int] $NumberOfUUIDCharactersToTruncate = [ScubaConfig]::ScubaDefault('DefaultNumberOfUUIDCharactersToTruncate') ) process { # Retrieve ScubaGear Module versions $ParentPath = Split-Path $PSScriptRoot -Parent -ErrorAction 'Stop' $ScubaManifest = Import-PowerShellDataFile (Join-Path -Path $ParentPath -ChildPath 'ScubaGear.psd1' -Resolve) -ErrorAction 'Stop' $ModuleVersion = $ScubaManifest.ModuleVersion if ($Version) { Write-Output("SCuBA Gear v$ModuleVersion") return } # Transform ProductNames into list of all products if it contains wildcard if ($ProductNames.Contains('*')){ $ProductNames = $PSBoundParameters['ProductNames'] = "aad", "defender", "exo", "powerplatform", "sharepoint", "teams" Write-Debug "Setting ProductName to all products because of wildcard" } # Default execution ParameterSet if ($PSCmdlet.ParameterSetName -eq 'Report'){ $ProvidedParameters = @{ 'ProductNames' = $ProductNames | Sort-Object -Unique 'M365Environment' = $M365Environment 'OPAPath' = $OPAPath 'LogIn' = $LogIn 'DisconnectOnExit' = $DisconnectOnExit 'OutPath' = $OutPath 'OutFolderName' = $OutFolderName 'OutProviderFileName' = $OutProviderFileName 'OutRegoFileName' = $OutRegoFileName 'OutReportName' = $OutReportName 'KeepIndividualJSON' = $KeepIndividualJSON 'OutJsonFileName' = $OutJsonFileName 'OutCsvFileName' = $OutCsvFileName 'OutActionPlanFileName' = $OutActionPlanFileName 'NumberOfUUIDCharactersToTruncate' = $NumberOfUUIDCharactersToTruncate } $ScubaConfig = New-Object -Type PSObject -Property $ProvidedParameters } Remove-Resources # Unload helper modules if they are still in the PowerShell session Import-Resources # Imports Providers, RunRego, CreateReport, Connection # Loads and executes parameters from a Configuration file if ($PSCmdlet.ParameterSetName -eq 'Configuration'){ [ScubaConfig]::ResetInstance() if (-Not ([ScubaConfig]::GetInstance().LoadConfig($ConfigFilePath))){ Write-Error -Message "The config file failed to load: $ConfigFilePath" } else { $ScubaConfig = [ScubaConfig]::GetInstance().Configuration } # Authentications parameters use below $SPparams = 'AppID', 'CertificateThumbprint', 'Organization' # Bound parameters indicate a parameter has been passed in. # However authentication parameters are special and are not handled within # the config module (since you can't make a default). If an authentication # parameter is set in the config file but not supplied on the command line # set the Bound parameters value which make it appear as if it was supplied on the # command line foreach ( $value in $SPparams ) { if ( $ScubaConfig[$value] -and (-not $PSBoundParameters[$value] )) { $PSBoundParameters.Add($value, $ScubaConfig[$value]) } } # Now the bound parameters contain the following # 1) Non Authentication Parameters explicitly passed in # 2) Authentication parameters ( passed in or from the config file as per code above ) # # So to provide for a command line override of config values just set the corresponding # config value from the bound parameters to override. This is redundant copy for # the authentication parameters ( but keeps the logic simpler) # We do not allow ConfigFilePath to be copied as it will be propagated to the # config module by reference and causes issues # foreach ( $value in $PSBoundParameters.keys ) { if ( $value -ne "ConfigFilePath" ) { $ScubaConfig[$value] = $PSBoundParameters[$value] } } } if ($ScubaConfig.OutCsvFileName -eq $ScubaConfig.OutActionPlanFileName) { $ErrorMessage = "OutCsvFileName and OutActionPlanFileName cannot be equal to each other. " $ErrorMessage += "Both are set to $($ScubaConfig.OutCsvFileName). Stopping execution." throw $ErrorMessage } # Creates the output folder $Date = Get-Date -ErrorAction 'Stop' $FormattedTimeStamp = $Date.ToString("yyyy_MM_dd_HH_mm_ss") $OutFolderPath = $ScubaConfig.OutPath $FolderName = "$($ScubaConfig.OutFolderName)_$($FormattedTimeStamp)" New-Item -Path $OutFolderPath -Name $($FolderName) -ItemType Directory -ErrorAction 'Stop' | Out-Null $OutFolderPath = Join-Path -Path $OutFolderPath -ChildPath $FolderName -ErrorAction 'Stop' # Product Authentication $ConnectionParams = @{ 'LogIn' = $ScubaConfig.LogIn; 'ProductNames' = $ScubaConfig.ProductNames; 'M365Environment' = $ScubaConfig.M365Environment; 'BoundParameters' = $PSBoundParameters; } $ProdAuthFailed = Invoke-Connection @ConnectionParams if ($ProdAuthFailed.Count -gt 0) { $ScubaConfig.ProductNames = Compare-ProductList -ProductNames $ScubaConfig.ProductNames ` -ProductsFailed $ProdAuthFailed ` -ExceptionMessage 'All indicated Products were unable to authenticate' } # Tenant Metadata for the Report $TenantDetails = Get-TenantDetail -ProductNames $ScubaConfig.ProductNames -M365Environment $ScubaConfig.M365Environment # Generate a GUID to uniquely identify the output JSON $Guid = New-Guid -ErrorAction 'Stop' try { # Provider Execution $ProviderParams = @{ 'ProductNames' = $ScubaConfig.ProductNames; 'M365Environment' = $ScubaConfig.M365Environment; 'TenantDetails' = $TenantDetails; 'ModuleVersion' = $ModuleVersion; 'OutFolderPath' = $OutFolderPath; 'OutProviderFileName' = $ScubaConfig.OutProviderFileName; 'Guid' = $Guid; 'BoundParameters' = $PSBoundParameters; } $ProdProviderFailed = Invoke-ProviderList @ProviderParams if ($ProdProviderFailed.Count -gt 0) { $ScubaConfig.ProductNames = Compare-ProductList -ProductNames $ScubaConfig.ProductNames ` -ProductsFailed $ProdProviderFailed ` -ExceptionMessage 'All indicated Product Providers failed to execute' } # OPA Rego invocation $RegoParams = @{ 'ProductNames' = $ScubaConfig.ProductNames; 'OPAPath' = $ScubaConfig.OPAPath; 'ParentPath' = $ParentPath; 'OutFolderPath' = $OutFolderPath; 'OutProviderFileName' = $ScubaConfig.OutProviderFileName; 'OutRegoFileName' = $ScubaConfig.OutRegoFileName; } $ProdRegoFailed = Invoke-RunRego @RegoParams if ($ProdRegoFailed.Count -gt 0) { $ScubaConfig.ProductNames = Compare-ProductList -ProductNames $ScubaConfig.ProductNames ` -ProductsFailed $ProdRegoFailed ` -ExceptionMessage 'All indicated Product Rego invocations failed' } # Report Creation # Converted back from JSON String for PS Object use $TenantDetails = $TenantDetails | ConvertFrom-Json $ReportParams = @{ 'ProductNames' = $ScubaConfig.ProductNames 'TenantDetails' = $TenantDetails 'ModuleVersion' = $ModuleVersion 'OutFolderPath' = $OutFolderPath 'OutProviderFileName' = $ScubaConfig.OutProviderFileName 'OutRegoFileName' = $ScubaConfig.OutRegoFileName 'OutReportName' = $ScubaConfig.OutReportName 'DarkMode' = $DarkMode 'Quiet' = $Quiet } Invoke-ReportCreation @ReportParams $FullNameParams = @{ 'OutJsonFileName' = $ScubaConfig.OutJsonFileName; 'Guid' = $Guid; 'NumberOfUUIDCharactersToTruncate' = $ScubaConfig.NumberOfUUIDCharactersToTruncate; } $FullScubaResultsName = Get-FullOutJsonName @FullNameParams if (-not $KeepIndividualJSON) { # Craft the complete json version of the output $JsonParams = @{ 'ProductNames' = $ScubaConfig.ProductNames; 'OutFolderPath' = $OutFolderPath; 'OutProviderFileName' = $ScubaConfig.OutProviderFileName; 'TenantDetails' = $TenantDetails; 'ModuleVersion' = $ModuleVersion; 'FullScubaResultsName' = $FullScubaResultsName; 'Guid' = $Guid; } Merge-JsonOutput @JsonParams } # Craft the csv version of just the results $CsvParams = @{ 'ProductNames' = $ScubaConfig.ProductNames; 'OutFolderPath' = $OutFolderPath; 'FullScubaResultsName' = $FullScubaResultsName; 'OutCsvFileName' = $ScubaConfig.OutCsvFileName; 'OutActionPlanFileName' = $ScubaConfig.OutActionPlanFileName; } ConvertTo-ResultsCsv @CsvParams } finally { if ($ScubaConfig.DisconnectOnExit) { if ($VerbosePreference -eq "Continue") { Disconnect-SCuBATenant -ProductNames $ScubaConfig.ProductNames -ErrorAction SilentlyContinue -Verbose } else { Disconnect-SCuBATenant -ProductNames $ScubaConfig.ProductNames -ErrorAction SilentlyContinue } } [ScubaConfig]::ResetInstance() } } } $ArgToProd = @{ teams = "Teams"; exo = "EXO"; defender = "Defender"; aad = "AAD"; powerplatform = "PowerPlatform"; sharepoint = "SharePoint"; } $ProdToFullName = @{ Teams = "Microsoft Teams"; EXO = "Exchange Online"; Defender = "Microsoft 365 Defender"; AAD = "Azure Active Directory"; PowerPlatform = "Microsoft Power Platform"; SharePoint = "SharePoint Online"; } $IndividualReportFolderName = "IndividualReports" function Get-FileEncoding{ <# .Description This function returns encoding type for setting content. .Functionality Internal #> $PSVersion = $PSVersionTable.PSVersion $Encoding = 'utf8' if ($PSVersion -ge '6.0'){ $Encoding = 'utf8NoBom' } return $Encoding } function Invoke-ProviderList { <# .Description This function runs the various providers modules stored in the Providers Folder Output will be stored as a ProviderSettingsExport.json in the OutPath Folder .Functionality Internal #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", '*', IgnoreCase = $false)] [string[]] $ProductNames, [Parameter(Mandatory = $true)] [ValidateSet("commercial", "gcc", "gcchigh", "dod", IgnoreCase = $false)] [ValidateNotNullOrEmpty()] [string] $M365Environment, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $TenantDetails, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $ModuleVersion, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $OutFolderPath, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $OutProviderFileName, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Guid, [Parameter(Mandatory = $true)] [hashtable] $BoundParameters ) process { try { # yes the syntax has to be like this # fixing the spacing causes PowerShell interpreter errors $ProviderJSON = @" "@ $N = 0 $Len = $ProductNames.Length $ProdProviderFailed = @() $ConnectTenantParams = @{ 'M365Environment' = $M365Environment } $SPOProviderParams = @{ 'M365Environment' = $M365Environment } $PnPFlag = $false if ($BoundParameters.AppID) { $ServicePrincipalParams = Get-ServicePrincipalParams -BoundParameters $BoundParameters $ConnectTenantParams += @{ServicePrincipalParams = $ServicePrincipalParams; } $PnPFlag = $true $SPOProviderParams += @{PnPFlag = $PnPFlag } } foreach ($Product in $ProductNames) { $BaselineName = $ArgToProd[$Product] $N += 1 $Percent = $N * 100 / $Len $Status = "Running the $($BaselineName) Provider; $($N) of $($Len) Product settings extracted" $ProgressParams = @{ 'Activity' = "Running the provider for each baseline"; 'Status' = $Status; 'PercentComplete' = $Percent; 'Id' = 1; 'ErrorAction' = 'Stop'; } Write-Progress @ProgressParams try { $RetVal = "" switch ($Product) { "aad" { $RetVal = Export-AADProvider -M365Environment $M365Environment | Select-Object -Last 1 } "exo" { $RetVal = Export-EXOProvider | Select-Object -Last 1 } "defender" { $RetVal = Export-DefenderProvider @ConnectTenantParams | Select-Object -Last 1 } "powerplatform" { $RetVal = Export-PowerPlatformProvider -M365Environment $M365Environment | Select-Object -Last 1 } "sharepoint" { $RetVal = Export-SharePointProvider @SPOProviderParams | Select-Object -Last 1 } "teams" { $RetVal = Export-TeamsProvider | Select-Object -Last 1 } default { Write-Error -Message "Invalid ProductName argument" } } $ProviderJSON += $RetVal } catch { Write-Warning "Error with the $($BaselineName) Provider: $($_.Exception.Message)`n$($_.ScriptStackTrace)" $ProdProviderFailed += $Product Write-Warning "$($Product) will be omitted from the output because of the failure above `n`n" } } $ProviderJSON = $ProviderJSON.TrimEnd(",") $TimeZone = "" $CurrentDate = Get-Date -ErrorAction 'Stop' $TimestampZulu = $CurrentDate.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") $GetTimeZone = Get-TimeZone -ErrorAction 'Stop' if (($CurrentDate).IsDaylightSavingTime()) { $TimeZone = ($GetTimeZone).DaylightName } else { $TimeZone = ($GetTimeZone).StandardName } $ConfigDetails = @(ConvertTo-Json -Depth 100 $([ScubaConfig]::GetInstance().Configuration)) if(! $ConfigDetails) { $ConfigDetails = "{}" } $BaselineSettingsExport = @" { "baseline_version": "1", "module_version": "$ModuleVersion", "date": "$($CurrentDate) $($TimeZone)", "timestamp_zulu": "$($TimestampZulu)", "report_uuid": "$($Guid)", "tenant_details": $($TenantDetails), "scuba_config": $($ConfigDetails), $ProviderJSON } "@ # PowerShell 5 includes the "byte-order mark" (BOM) when it writes UTF-8 files. However, OPA (as of 0.68) appears to not # be able to handle the "\/" character sequence if the input json is UTF-8 encoded with the BOM, resulting # in the "unable to parse input: yaml" error message. As such, we need to save the provider output without # the BOM $ActualSavedLocation = Set-Utf8NoBom -Content $BaselineSettingsExport ` -Location $OutFolderPath -FileName "$OutProviderFileName.json" Write-Debug $ActualSavedLocation $ProdProviderFailed } catch { $InvokeProviderListErrorMessage = "Fatal Error involving the Provider functions. ` Ending ScubaGear execution. Error: $($_.Exception.Message)` `n$($_.ScriptStackTrace)" throw $InvokeProviderListErrorMessage } } } function Invoke-RunRego { <# .Description This function runs the RunRego module. Which runs the various rego files against the ProviderSettings.json using the specified OPA executable Output will be stored as a TestResults.json in the OutPath Folder .Functionality Internal #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", '*', IgnoreCase = $false)] [string[]] $ProductNames, [ValidateNotNullOrEmpty()] [string] $OPAPath = [ScubaConfig]::ScubaDefault('DefaultOPAPath'), [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $ParentPath, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $OutFolderPath, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String] $OutProviderFileName, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $OutRegoFileName ) process { try { $ProdRegoFailed = @() $TestResults = @() $N = 0 $Len = $ProductNames.Length foreach ($Product in $ProductNames) { $BaselineName = $ArgToProd[$Product] $N += 1 $Percent = $N * 100 / $Len $Status = "Running the $($BaselineName) Rego Verification; $($N) of $($Len) Rego verifications completed" $ProgressParams = @{ 'Activity' = "Running the rego for each baseline"; 'Status' = $Status; 'PercentComplete' = $Percent; 'Id' = 1; 'ErrorAction' = 'Stop'; } Write-Progress @ProgressParams $InputFile = Join-Path -Path $OutFolderPath "$($OutProviderFileName).json" -ErrorAction 'Stop' $RegoFile = Join-Path -Path $ParentPath -ChildPath "Rego" -ErrorAction 'Stop' $RegoFile = Join-Path -Path $RegoFile -ChildPath "$($BaselineName)Config.rego" -ErrorAction 'Stop' $params = @{ 'InputFile' = $InputFile; 'RegoFile' = $RegoFile; 'PackageName' = $Product; 'OPAPath' = $OPAPath } try { $RetVal = Invoke-Rego @params $TestResults += $RetVal } catch { Write-Warning "Error with the $($BaselineName) Rego invocation: $($_.Exception.Message)`n$($_.ScriptStackTrace)" $ProdRegoFailed += $Product Write-Warning "$($Product) will be omitted from the output because of the failure above" } } $TestResultsJson = $TestResults | ConvertTo-Json -Depth 5 -ErrorAction 'Stop' $FileName = Join-Path -Path $OutFolderPath "$($OutRegoFileName).json" -ErrorAction 'Stop' $TestResultsJson | Set-Content -Path $FileName -Encoding $(Get-FileEncoding) -ErrorAction 'Stop' $ProdRegoFailed } catch { $InvokeRegoErrorMessage = "Fatal Error involving the OPA output function. ` Ending ScubaGear execution. Error: $($_.Exception.Message)` `n$($_.ScriptStackTrace)" throw $InvokeRegoErrorMessage } } } function Pluralize { <# .Description This function whether the singular or plural version of the noun is needed and returns the appropriate version. .Functionality Internal #> [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $SingularNoun, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $PluralNoun, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [int] $Count ) process { if ($Count -gt 1) { $PluralNoun } else { $SingularNoun } } } function Format-PlainText { <# .Description This function sanitizes a given string so that it will render properly in Excel (e.g., remove HTML tags). .Functionality Internal #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $RawString ) process { $CleanString = $RawString # Multi-line strings (e.g., the requirment string for MS.EXO.16.1v1) need to be merged into a single # line, otherwise the single control will be split into multiple rows in the CSV output $CleanString = $CleanString.Replace("`n", " ") # The "View all CA policies" link needs to be removed from the spreadsheet as what it links to # does not exist in the spreadsheet $CleanString = $CleanString.Replace("<a href='#caps'>View all CA policies</a>.", "") # Remove HTML tags that won't render properly in the spreadsheet and whose removal won't affect the # overall meaning of the string $CleanString = $CleanString.Replace("<br/>", " ") $CleanString = $CleanString.Replace("<b>", "") $CleanString = $CleanString.Replace("</b>", "") # Strip out HTML comments $CleanString = $CleanString -replace '(.*)(<!--)(.*)(-->)(.*)', '$1$5' # The following regex looks for a string with an anchor tag. If it finds an anchor tag, it reformats # the string so that the anchor is removed. For example: # 'See <a href="https://example.com" target="_blank">this example</a> for more details.' # becomes # 'See this example, https://example.com for more details.' # In-depth interpretation: # Group 1: '(.*)' Matches any number of characters before the opening anchor tag # Group 2: '<a href="' Matches the opening anchor tag, up to and including the opening quote of the href # Group 3: '([\w#./=&?%\-+:;$@,]+)' Matches the href string # Group 4: '(".*>)' Matches the last half of the opening anchor tag # Group 5: '(.*)' Matches the anchor inner html, i.e., the link's display text # Group 6: '(</a>)' Matches the closing anchor tag # Group 7: '(.*)' Matches any number of characters after the closing anchor tag $CleanString = $CleanString -replace '(.*)(<a href=")([\w#./=&?%\-+:;$@,]+)(".*>)(.*)(</a>)(.*)', '$1$5, $3$7' $CleanString } } function Get-FullOutJsonName { <# .Description This function determines the full file name of the SCuBA results file. .Functionality Internal #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $OutJsonFileName, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Guid, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [ValidateSet(0, 13, 18, 36)] [int] $NumberOfUUIDCharactersToTruncate ) process { # Truncate the UUID at the end of the ScubaResults JSON file by the parameter value. # This is is to possibly prevent Windows maximum path length errors that may occur when moving files # with a large number of characters $TruncatedGuid = $Guid.Substring(0, $Guid.Length - $NumberOfUUIDCharactersToTruncate) # If the UUID still exists after truncation if ($TruncatedGuid.Length -gt 0) { $ScubaResultsFileName = "$($OutJsonFileName)_$($TruncatedGuid).json" } else { # Otherwise omit adding it to the resulting file name $ScubaResultsFileName = "$($OutJsonFileName).json" } $ScubaResultsFileName } } function ConvertTo-ResultsCsv { <# .Description This function converts the controls inside the Results section of the json output to a csv. .Functionality Internal #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", '*', IgnoreCase = $false)] [string[]] $ProductNames, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $OutFolderPath, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $FullScubaResultsName, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $OutCsvFileName, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $OutActionPlanFileName ) process { try { $ScubaResultsPath = Join-Path $OutFolderPath -ChildPath $FullScubaResultsName if (Test-Path $ScubaResultsPath -PathType Leaf) { # The ScubaResults file exists, no need to look for the individual json files $ScubaResults = Get-Content (Get-ChildItem $ScubaResultsPath).FullName | ConvertFrom-Json } else { # The ScubaResults file does not exists, so we need to look inside the IndividualReports # folder for the json file specific to each product $ScubaResults = @{"Results" = [PSCustomObject]@{}} $IndividualReportPath = Join-Path -Path $OutFolderPath $IndividualReportFolderName -ErrorAction 'Stop' foreach ($Product in $ProductNames) { $BaselineName = $ArgToProd[$Product] $FileName = Join-Path $IndividualReportPath "$($BaselineName)Report.json" $IndividualResults = Get-Content $FileName | ConvertFrom-Json $ScubaResults.Results | Add-Member -NotePropertyName $BaselineName ` -NotePropertyValue $IndividualResults.Results } } $ActionPlanCsv = @() $ScubaResultsCsv = @() foreach ($Product in $ScubaResults.Results.PSObject.Properties) { foreach ($Group in $Product.Value) { foreach ($Control in $Group.Controls) { $Control.Requirement = Format-PlainText -RawString $Control.Requirement $Control.Details = Format-PlainText -RawString $Control.Details $ScubaResultsCsv += $Control if ($Control.Result -eq "Fail") { # Add blank fields where users can document reasons for failures and timelines # for remediation if they so choose # The space " " instead of empty string makes it so that output from the cells to the # left won't automatically overlap into the space for these columns in Excel $Reason = " " $RemediationDate = " " $Justification = " " $Control | Add-Member -NotePropertyName "Non-Compliance Reason" -NotePropertyValue $Reason $Control | Add-Member -NotePropertyName "Remediation Completion Date" ` -NotePropertyValue $RemediationDate $Control | Add-Member -NotePropertyName "Justification" -NotePropertyValue $Justification $ActionPlanCsv += $Control } } } } $ResultsCsvFileName = Join-Path -Path $OutFolderPath "$OutCsvFileName.csv" $PlanCsvFileName = Join-Path -Path $OutFolderPath "$OutActionPlanFileName.csv" $Encoding = Get-FileEncoding $ScubaResultsCsv | ConvertTo-Csv -NoTypeInformation | Set-Content -Path $ResultsCsvFileName -Encoding $Encoding if ($ActionPlanCsv.Length -eq 0) { # If no tests failed, add the column names to ensure a file is still output $Headers = $ScubaResultsCsv[0].psobject.Properties.Name -Join '","' $Headers = "`"$Headers`"" $Headers += '"Non-Compliance Reason","Remediation Completion Date","Justification"' $Headers | Set-Content -Path $PlanCsvFileName -Encoding $Encoding } else { $ActionPlanCsv | ConvertTo-Csv -NoTypeInformation | Set-Content -Path $PlanCsvFileName -Encoding $Encoding } } catch { Write-Warning "Error creating CSV output file: $($_.Exception.Message)`n$($_.ScriptStackTrace)" } } } function Merge-JsonOutput { <# .Description This function packages all the json output created into a single json file. .Functionality Internal #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", '*', IgnoreCase = $false)] [string[]] $ProductNames, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $OutFolderPath, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $OutProviderFileName, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [object] $TenantDetails, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $ModuleVersion, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $FullScubaResultsName, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $Guid ) process { try { # Files to delete at the end if no errors are encountered $DeletionList = @() # Load the raw provider output $SettingsExportPath = Join-Path $OutFolderPath -ChildPath "$($OutProviderFileName).json" $DeletionList += $SettingsExportPath $SettingsExport = Get-Content $SettingsExportPath -Raw $SettingsExportObject = $(ConvertFrom-Json $SettingsExport) $TimestampZulu = $SettingsExportObject.timestamp_zulu # Get a list and abbreviation mapping of the products assessed $FullNames = @() $ProductAbbreviationMapping = @{} foreach ($ProductName in $ProductNames) { $BaselineName = $ArgToProd[$ProductName] $FullNames += $ProdToFullName[$BaselineName] $ProductAbbreviationMapping[$ProdToFullName[$BaselineName]] = $BaselineName } # Extract the metadata $Results = [pscustomobject]@{} $Summary = [pscustomobject]@{} $MetaData = [pscustomobject]@{ "TenantId" = $TenantDetails.TenantId; "DisplayName" = $TenantDetails.DisplayName; "DomainName" = $TenantDetails.DomainName; "ProductSuite" = "Microsoft 365"; "ProductsAssessed" = $FullNames; "ProductAbbreviationMapping" = $ProductAbbreviationMapping "Tool" = "ScubaGear"; "ToolVersion" = $ModuleVersion; "TimestampZulu" = $TimestampZulu; "ReportUUID" = $Guid; } # Aggregate the report results and summaries $IndividualReportPath = Join-Path -Path $OutFolderPath $IndividualReportFolderName -ErrorAction 'Stop' foreach ($Product in $ProductNames) { $BaselineName = $ArgToProd[$Product] $FileName = Join-Path $IndividualReportPath "$($BaselineName)Report.json" $DeletionList += $FileName $IndividualResults = Get-Content $FileName | ConvertFrom-Json $Results | Add-Member -NotePropertyName $BaselineName ` -NotePropertyValue $IndividualResults.Results # The date is listed under the metadata, no need to include it in the summary as well $IndividualResults.ReportSummary.PSObject.Properties.Remove('Date') $Summary | Add-Member -NotePropertyName $BaselineName ` -NotePropertyValue $IndividualResults.ReportSummary } foreach ($Product in $Results.PSObject.Properties) { foreach ($Group in $Product.Value) { foreach ($Control in $Group.Controls) { $Control.Requirement = Format-PlainText -RawString $Control.Requirement $Control.Details = Format-PlainText -RawString $Control.Details } } } # Convert the output a json string $MetaData = ConvertTo-Json $MetaData -Depth 3 $Results = ConvertTo-Json $Results -Depth 5 $Summary = ConvertTo-Json $Summary -Depth 3 $ReportJson = @" { "MetaData": $MetaData, "Summary": $Summary, "Results": $Results, "Raw": $SettingsExport } "@ # ConvertTo-Json for some reason converts the <, >, and ' characters into unicode escape sequences. # Convert those back to ASCII. $ReportJson = $ReportJson.replace("\u003c", "<") $ReportJson = $ReportJson.replace("\u003e", ">") $ReportJson = $ReportJson.replace("\u0027", "'") $ScubaResultsPath = Join-Path $OutFolderPath -ChildPath $FullScubaResultsName -ErrorAction 'Stop' $ReportJson | Set-Content -Path $ScubaResultsPath -Encoding $(Get-FileEncoding) -ErrorAction 'Stop' # Delete the now redundant files foreach ($File in $DeletionList) { Remove-Item $File } } catch { if ($_.FullyQualifiedErrorId -eq "GetContentWriterPathTooLongError,Microsoft.PowerShell.Commands.SetContentCommand") { $MAX_WINDOWS_PATH_LEN = 256 $PathLengthErrorMessage = "ScubaGear was likely executed in a location where the maximum file path length is greater than the allowable Windows file system limit ` Please execute ScubaGear in a directory where for Windows file path limit is less than $($MAX_WINDOWS_PATH_LEN).` Another option is to change the -NumberOfUUIDCharactersToTruncate, -OutJSONFileName, or -OutFolderName parameters to achieve an acceptable file path length ` See the Invoke-SCuBA parameters documentation for more details. ` Error: $($_.Exception.Message) ` Stacktrace: $($_.ScriptStackTrace)" throw $PathLengthErrorMessage } else { $MergeJsonErrorMessage = "Fatal Error involving the Json reports aggregation. ` Ending ScubaGear execution. Error: $($_.Exception.Message) ` Stacktrace: $($_.ScriptStackTrace)" throw $MergeJsonErrorMessage } } } } function Invoke-ReportCreation { <# .Description This function runs the CreateReport Module which creates an HTML report using the TestResults.json. Output will be stored as various HTML files in the OutPath Folder. The report Home page will be named BaselineReports.html .Functionality Internal #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", '*', IgnoreCase = $false)] [string[]] $ProductNames, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [object] $TenantDetails, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $ModuleVersion, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $OutFolderPath, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $OutProviderFileName, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $OutRegoFileName, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $OutReportName, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [switch] $Quiet, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [switch] $DarkMode ) process { try { $N = 0 $Len = $ProductNames.Length $Fragment = @() $IndividualReportPath = Join-Path -Path $OutFolderPath -ChildPath $IndividualReportFolderName New-Item -Path $IndividualReportPath -ItemType "Directory" -ErrorAction "SilentlyContinue" | Out-Null $ReporterPath = Join-Path -Path $PSScriptRoot -ChildPath "CreateReport" -ErrorAction 'Stop' $Images = Join-Path -Path $ReporterPath -ChildPath "images" -ErrorAction 'Stop' Copy-Item -Path $Images -Destination $IndividualReportPath -Force -Recurse -ErrorAction 'Stop' $SecureBaselines = Import-SecureBaseline -ProductNames $ProductNames foreach ($Product in $ProductNames) { $BaselineName = $ArgToProd[$Product] $N += 1 $Percent = $N*100/$Len $Status = "Running the $($BaselineName) Report creation; $($N) of $($Len) Baselines Reports created"; $ProgressParams = @{ 'Activity' = "Creating the reports for each baseline"; 'Status' = $Status; 'PercentComplete' = $Percent; 'Id' = 1; 'ErrorAction' = 'Stop'; } Write-Progress @ProgressParams $FullName = $ProdToFullName[$BaselineName] $CreateReportParams = @{ 'BaselineName' = $BaselineName; 'FullName' = $FullName; 'IndividualReportPath' = $IndividualReportPath; 'OutPath' = $OutFolderPath; 'OutProviderFileName' = $OutProviderFileName; 'OutRegoFileName' = $OutRegoFileName; 'DarkMode' = $DarkMode; 'SecureBaselines' = $SecureBaselines } $Report = New-Report @CreateReportParams $LinkPath = "$($IndividualReportFolderName)/$($BaselineName)Report.html" $LinkClassName = '"individual_reports"' # uses no escape characters $Link = "<a class=$($LinkClassName) href='$($LinkPath)'>$($FullName)</a>" $PassesSummary = "<div class='summary'></div>" $WarningsSummary = "<div class='summary'></div>" $FailuresSummary = "<div class='summary'></div>" $BaselineURL = "<a href= `"https://github.com/cisagov/ScubaGear/blob/v$($ModuleVersion)/baselines`" target=`"_blank`"><h3 style=`"width: 100px;`">Baseline Documents</h3></a>" $ManualSummary = "<div class='summary'></div>" $OmitSummary = "<div class='summary'></div>" $ErrorSummary = "" if ($Report.Passes -gt 0) { $Noun = Pluralize -SingularNoun "pass" -PluralNoun "passes" -Count $Report.Passes $PassesSummary = "<div class='summary pass'>$($Report.Passes) $($Noun)</div>" } if ($Report.Warnings -gt 0) { $Noun = Pluralize -SingularNoun "warning" -PluralNoun "warnings" -Count $Report.Warnings $WarningsSummary = "<div class='summary warning'>$($Report.Warnings) $($Noun)</div>" } if ($Report.Failures -gt 0) { $Noun = Pluralize -SingularNoun "failure" -PluralNoun "failures" -Count $Report.Failures $FailuresSummary = "<div class='summary failure'>$($Report.Failures) $($Noun)</div>" } if ($Report.Manual -gt 0) { $Noun = Pluralize -SingularNoun "check" -PluralNoun "checks" -Count $Report.Manual $ManualSummary = "<div class='summary manual'>$($Report.Manual) manual $($Noun)</div>" } if ($Report.Omits -gt 0) { $OmitSummary = "<div class='summary manual'>$($Report.Omits) omitted</div>" } if ($Report.Errors -gt 0) { $Noun = Pluralize -SingularNoun "error" -PluralNoun "errors" -Count $Report.Errors $ErrorSummary = "<div class='summary error'>$($Report.Errors) $($Noun)</div>" } $Fragment += [pscustomobject]@{ "Baseline Conformance Reports" = $Link; "Details" = "$($PassesSummary) $($WarningsSummary) $($FailuresSummary) $($ManualSummary) $($OmitSummary) $($ErrorSummary)" } } $TenantMetaData += [pscustomobject]@{ "Tenant Display Name" = $TenantDetails.DisplayName; "Tenant Domain Name" = $TenantDetails.DomainName "Tenant ID" = $TenantDetails.TenantId; "Report Date" = $Report.Date; } $TenantMetaData = $TenantMetaData | ConvertTo-Html -Fragment -ErrorAction 'Stop' $TenantMetaData = $TenantMetaData -replace '^(.*?)<table>','<table class ="tenantdata" style = "text-align:center;">' $Fragment = $Fragment | ConvertTo-Html -Fragment -ErrorAction 'Stop' $ProviderJSONFilePath = Join-Path -Path $OutFolderPath -ChildPath "$($OutProviderFileName).json" -Resolve $ReportUuid = $(Get-Utf8NoBom -FilePath $ProviderJSONFilePath | ConvertFrom-Json).report_uuid $ReportHtmlPath = Join-Path -Path $ReporterPath -ChildPath "ParentReport" -ErrorAction 'Stop' $ReportHTML = (Get-Content $(Join-Path -Path $ReportHtmlPath -ChildPath "ParentReport.html") -ErrorAction 'Stop') -Join "`n" $ReportHTML = $ReportHTML.Replace("{TENANT_DETAILS}", $TenantMetaData) $ReportHTML = $ReportHTML.Replace("{TABLES}", $Fragment) $ReportHTML = $ReportHTML.Replace("{REPORT_UUID}", $ReportUuid) $ReportHTML = $ReportHTML.Replace("{MODULE_VERSION}", "v$ModuleVersion") $ReportHTML = $ReportHTML.Replace("{BASELINE_URL}", $BaselineURL) $CssPath = Join-Path -Path $ReporterPath -ChildPath "styles" -ErrorAction 'Stop' $MainCSS = (Get-Content $(Join-Path -Path $CssPath -ChildPath "main.css") -ErrorAction 'Stop') -Join "`n" $ReportHTML = $ReportHTML.Replace("{MAIN_CSS}", "<style>$($MainCSS)</style>") $ParentCSS = (Get-Content $(Join-Path -Path $CssPath -ChildPath "ParentReportStyle.css") -ErrorAction 'Stop') -Join "`n" $ReportHTML = $ReportHTML.Replace("{PARENT_CSS}", "<style>$($ParentCSS)</style>") $ScriptsPath = Join-Path -Path $ReporterPath -ChildPath "scripts" -ErrorAction 'Stop' $ParentReportJS = (Get-Content $(Join-Path -Path $ScriptsPath -ChildPath "ParentReport.js") -ErrorAction 'Stop') -Join "`n" $UtilsJS = (Get-Content $(Join-Path -Path $ScriptsPath -ChildPath "utils.js") -ErrorAction 'Stop') -Join "`n" $ParentReportJS = "$($ParentReportJS)`n$($UtilsJS)" $ReportHTML = $ReportHTML.Replace("{MAIN_JS}", "<script> let darkMode = $($DarkMode.ToString().ToLower()); $($ParentReportJS) </script>") Add-Type -AssemblyName System.Web -ErrorAction 'Stop' $ReportFileName = Join-Path -Path $OutFolderPath "$($OutReportName).html" -ErrorAction 'Stop' [System.Web.HttpUtility]::HtmlDecode($ReportHTML) | Out-File $ReportFileName -ErrorAction 'Stop' if (-Not $Quiet) { Invoke-Item $ReportFileName } } catch { $InvokeReportErrorMessage = "Fatal Error involving the Report Creation. ` Ending ScubaGear execution. Error: $($_.Exception.Message)` `n$($_.ScriptStackTrace)" throw $InvokeReportErrorMessage } } } function Get-TenantDetail { <# .Description This function gets the details of the M365 Tenant using the various M365 PowerShell modules .Functionality Internal #> [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", IgnoreCase = $false)] [ValidateNotNullOrEmpty()] [string[]] $ProductNames, [Parameter(Mandatory = $true)] [ValidateSet("commercial", "gcc", "gcchigh", "dod", IgnoreCase = $false)] [ValidateNotNullOrEmpty()] [string] $M365Environment ) # organized by best tenant details information if ($ProductNames.Contains("aad")) { Get-AADTenantDetail } elseif ($ProductNames.Contains("sharepoint")) { Get-AADTenantDetail } elseif ($ProductNames.Contains("teams")) { Get-TeamsTenantDetail -M365Environment $M365Environment } elseif ($ProductNames.Contains("powerplatform")) { Get-PowerPlatformTenantDetail -M365Environment $M365Environment } elseif ($ProductNames.Contains("exo")) { Get-EXOTenantDetail -M365Environment $M365Environment } elseif ($ProductNames.Contains("defender")) { Get-EXOTenantDetail -M365Environment $M365Environment } else { $TenantInfo = @{ "DisplayName" = "Orchestrator Error retrieving Display name"; "DomainName" = "Orchestrator Error retrieving Domain name"; "TenantId" = "Orchestrator Error retrieving Tenant ID"; "AdditionalData" = "Orchestrator Error retrieving additional data"; } $TenantInfo = $TenantInfo | ConvertTo-Json -Depth 3 $TenantInfo } } function Invoke-Connection { <# .Description This function uses the Connection.psm1 module which uses the various PowerShell modules to establish a connection to an M365 Tenant associated with provided credentials .Functionality Internal #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [boolean] $LogIn, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", '*', IgnoreCase = $false)] [string[]] $ProductNames, [ValidateSet("commercial", "gcc", "gcchigh", "dod")] [ValidateNotNullOrEmpty()] [string] $M365Environment = "commercial", [Parameter(Mandatory=$true)] [hashtable] $BoundParameters ) $ConnectTenantParams = @{ 'ProductNames' = $ProductNames; 'M365Environment' = $M365Environment } if ($BoundParameters.AppID) { $ServicePrincipalParams = Get-ServicePrincipalParams -BoundParameters $BoundParameters $ConnectTenantParams += @{ServicePrincipalParams = $ServicePrincipalParams;} } if ($LogIn) { $AnyFailedAuth = Connect-Tenant @ConnectTenantParams $AnyFailedAuth } } function Compare-ProductList { <# .Description Compares two ProductNames Lists and returns the Diff between them Used to compare a failed execution list with the original list .Functionality Internal #> param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", '*', IgnoreCase = $false)] [string[]] $ProductNames, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", '*', IgnoreCase = $false)] [string[]] $ProductsFailed, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] $ExceptionMessage ) $Difference = Compare-Object $ProductNames -DifferenceObject $ProductsFailed -PassThru if (-not $Difference) { throw "$($ExceptionMessage); aborting ScubaGear execution" } else { $Difference } } function Get-ServicePrincipalParams { <# .Description Returns a valid a hastable of parameters for authentication via Service Principal. Throws an error if there are none. .Functionality Internal #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [hashtable] $BoundParameters ) $ServicePrincipalParams = @{} $CheckThumbprintParams = ($BoundParameters.CertificateThumbprint) ` -and ($BoundParameters.AppID) -and ($BoundParameters.Organization) if ($CheckThumbprintParams) { $CertThumbprintParams = @{ CertificateThumbprint = $BoundParameters.CertificateThumbprint; AppID = $BoundParameters.AppID; Organization = $BoundParameters.Organization; } $ServicePrincipalParams += @{CertThumbprintParams = $CertThumbprintParams} } else { throw "Missing parameters required for authentication with Service Principal Auth; Run Get-Help Invoke-Scuba for details on correct arguments" } $ServicePrincipalParams } function Import-Resources { <# .Description This function imports all of the various helper Provider, Rego, and Reporter modules to the runtime .Functionality Internal #> [CmdletBinding()] param() try { $ProvidersPath = Join-Path -Path $PSScriptRoot ` -ChildPath "Providers" ` -Resolve ` -ErrorAction 'Stop' $ProviderResources = Get-ChildItem $ProvidersPath -Recurse | Where-Object { $_.Name -like 'Export*.psm1' } if (!$ProviderResources) { throw "Provider files were not found, aborting this run" } foreach ($Provider in $ProviderResources.Name) { $ProvidersPath = Join-Path -Path $PSScriptRoot -ChildPath "Providers" -ErrorAction 'Stop' $ModulePath = Join-Path -Path $ProvidersPath -ChildPath $Provider -ErrorAction 'Stop' Import-Module $ModulePath } @('Connection', 'RunRego', 'CreateReport', 'ScubaConfig', 'Support', 'Utility') | ForEach-Object { $ModulePath = Join-Path -Path $PSScriptRoot -ChildPath $_ -ErrorAction 'Stop' Write-Debug "Importing $_ module" Import-Module -Name $ModulePath } } catch { $ImportResourcesErrorMessage = "Fatal Error involving importing PowerShell modules. ` Ending ScubaGear execution. Error: $($_.Exception.Message) ` `n$($_.ScriptStackTrace)" throw $ImportResourcesErrorMessage } } function Remove-Resources { <# .Description This function cleans up all of the various imported modules Mostly meant for dev work .Functionality Internal #> [CmdletBinding()] $Providers = @("ExportPowerPlatform", "ExportEXOProvider", "ExportAADProvider", "ExportDefenderProvider", "ExportTeamsProvider", "ExportSharePointProvider") foreach ($Provider in $Providers) { Remove-Module $Provider -ErrorAction "SilentlyContinue" } Remove-Module "ScubaConfig" -ErrorAction "SilentlyContinue" Remove-Module "RunRego" -ErrorAction "SilentlyContinue" Remove-Module "CreateReport" -ErrorAction "SilentlyContinue" Remove-Module "Connection" -ErrorAction "SilentlyContinue" } function Invoke-SCuBACached { <# .SYNOPSIS Specially execute the SCuBAGear tool security baselines for specified M365 products. Can be executed on static provider JSON. .Description This is the function for running the tool provider JSON that has already been extracted. This functions comes with the extra ExportProvider parameter to omit exporting the provider if set to $false. The rego will be run on a static provider JSON in the specified OutPath. <# .Parameter ExportProvider This parameter will when set to $true export the provider and act like Invoke-Scuba. When set to $false will instead omit authentication plus pulling the provider and will instead look in OutPath and run just the Rego verification and Report creation. .Parameter ProductNames A list of one or more M365 shortened product names that the tool will assess when it is executed. Acceptable product name values are listed below. To assess Azure Active Directory you would enter the value aad. To assess Exchange Online you would enter exo and so forth. - Azure Active Directory: aad - Defender for Office 365: defender - Exchange Online: exo - MS Power Platform: powerplatform - SharePoint Online: sharepoint - MS Teams: teams. Use '*' to run all baselines. .Parameter M365Environment This parameter is used to authenticate to the different commercial/government environments. Valid values include "commercial", "gcc", "gcchigh", or "dod". For M365 tenants with E3/E5 licenses enter the value **"commercial"**. For M365 Government Commercial Cloud tenants with G3/G5 licenses enter the value **"gcc"**. For M365 Government Commercial Cloud High tenants enter the value **"gcchigh"**. For M365 Department of Defense tenants enter the value **"dod"**. Default is 'commercial'. .Parameter OPAPath The folder location of the OPA Rego executable file. The OPA Rego executable embedded with this project is located in the project's root folder. If you want to execute the tool using a version of OPA Rego located in another folder, then customize the variable value with the full path to the alternative OPA Rego exe file. .Parameter LogIn A `$true` or `$false` variable that if set to `$true` will prompt you to provide credentials if you want to establish a connection to the specified M365 products in the **$ProductNames** variable. For most use cases, leave this variable to be `$true`. A connection is established in the current PowerShell terminal session with the first authentication. If you want to run another verification in the same PowerShell session simply set this variable to be `$false` to bypass the reauthenticating in the same session. Default is $true. .Parameter Version Will output the current ScubaGear version to the terminal without running this cmdlet. .Parameter AppID The application ID of the service principal that's used during certificate based authentication. A valid value is the GUID of the application ID (service principal). .Parameter CertificateThumbprint The thumbprint value specifies the certificate that's used for certificate base authentication. The underlying PowerShell modules retrieve the certificate from the user's certificate store. As such, a copy of the certificate must be located there. .Parameter Organization Specify the organization that's used in certificate based authentication. Use the tenant's tenantname.onmicrosoft.com domain for the parameter value. The folder path where both the output JSON and the HTML report will be created. The folder will be created if it does not exist. Defaults to current directory. .Parameter OutFolderName The name of the folder in OutPath where both the output JSON and the HTML report will be created. Defaults to "M365BaselineConformance". The client's local timestamp will be appended. .Parameter OutProviderFileName The name of the Provider output JSON created in the folder created in OutPath. Defaults to "ProviderSettingsExport". .Parameter OutRegoFileName The name of the Rego output JSON and CSV created in the folder created in OutPath. Defaults to "TestResults". .Parameter OutReportName The name of the main html file page created in the folder created in OutPath. Defaults to "BaselineReports". .Parameter KeepIndividualJSON Keeps ScubaGear legacy output where files are not merged into an all in one JSON. This parameter is for backwards compatibility for those working with the older ScubaGear output files. .Parameter OutJsonFileName If KeepIndividualJSON is set, the name of the consolidated json created in the folder created in OutPath. Defaults to "ScubaResults". The report UUID will be appended to this. .Parameter OutCsvFileName The CSV created in the folder created in OutPath that contains the CSV version of the test results. Defaults to "ScubaResults". .Parameter OutActionPlanFileName The CSV created in the folder created in OutPath that contains a CSV template prepopulated with the failed SHALL controls with fields for documenting failure causes and remediation plans. Defaults to "ActionPlan". .Parameter DarkMode Set switch to enable report dark mode by default. .Parameter NumberOfUUIDCharactersToTruncate Controls how many characters will be truncated from the report UUID when appended to the end of OutJsonFileName. Valid values are 0, 13, 18, 36 .Example Invoke-SCuBACached Run an assessment against by default a commercial M365 Tenant against the Azure Active Directory, Exchange Online, Microsoft Defender, One Drive, SharePoint Online, and Microsoft Teams security baselines. The output will stored in the current directory in a folder called M365BaselineConformaance_*. .Example Invoke-SCuBACached -Version This example returns the version of SCuBAGear. .Example Invoke-SCuBACached -ProductNames aad, defender -OPAPath . -OutPath . The example will run the tool against the Azure Active Directory, and Defender security baselines. .Example Invoke-SCuBACached -ProductNames * -M365Environment dod -OPAPath . -OutPath . This example will run the tool against all available security baselines with the 'dod' teams endpoint. .Example Invoke-SCuBA -ProductNames * -CertificateThumbprint <insert-thumbprint> -AppID <insert-appid> -Organization "tenant.onmicrosoft.com" This example will run the tool against all available security baselines while authenticating using a Service Principal with the CertificateThumprint bundle of parameters. .Functionality Public #> [CmdletBinding()] param ( [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [boolean] $ExportProvider = $true, [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [ValidateSet("teams", "exo", "defender", "aad", "powerplatform", "sharepoint", '*', IgnoreCase = $false)] [string[]] $ProductNames = [ScubaConfig]::ScubaDefault('AllProductNames'), [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateSet("commercial", "gcc", "gcchigh", "dod")] [ValidateNotNullOrEmpty()] [string] $M365Environment = [ScubaConfig]::ScubaDefault('DefaultM365Environment'), [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateScript({Test-Path -PathType Container $_})] [ValidateNotNullOrEmpty()] [string] $OPAPath = [ScubaConfig]::ScubaDefault('DefaultOPAPath'), [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [ValidateSet($true, $false)] [boolean] $LogIn = [ScubaConfig]::ScubaDefault('DefaultLogIn'), [Parameter(ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [switch] $Version, [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $AppID, [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $CertificateThumbprint, [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $Organization, [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutPath = [ScubaConfig]::ScubaDefault('DefaultOutPath'), [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutProviderFileName = [ScubaConfig]::ScubaDefault('DefaultOutProviderFileName'), [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutRegoFileName = [ScubaConfig]::ScubaDefault('DefaultOutRegoFileName'), [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutReportName = [ScubaConfig]::ScubaDefault('DefaultOutReportName'), [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [switch] $KeepIndividualJSON, [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutJsonFileName = [ScubaConfig]::ScubaDefault('DefaultOutJsonFileName'), [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutCsvFileName = [ScubaConfig]::ScubaDefault('DefaultOutCsvFileName'), [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [string] $OutActionPlanFileName = [ScubaConfig]::ScubaDefault('DefaultOutActionPlanFileName'), [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [ValidateSet($true, $false)] [switch] $Quiet, [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [switch] $DarkMode, [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [ValidateNotNullOrEmpty()] [ValidateSet(0, 13, 18, 36)] [int] $NumberOfUUIDCharactersToTruncate = [ScubaConfig]::ScubaDefault('DefaultNumberOfUUIDCharactersToTruncate') ) process { $ParentPath = Split-Path $PSScriptRoot -Parent $ScubaManifest = Import-PowerShellDataFile (Join-Path -Path $ParentPath -ChildPath 'ScubaGear.psd1' -Resolve) $ModuleVersion = $ScubaManifest.ModuleVersion if ($Version) { Write-Output("SCuBA Gear v$ModuleVersion") return } if ($ProductNames -eq '*'){ $ProductNames = "teams", "exo", "defender", "aad", "sharepoint", "powerplatform" } if ($OutCsvFileName -eq $OutActionPlanFileName) { $ErrorMessage = "OutCsvFileName and OutActionPlanFileName cannot be equal to each other. " $ErrorMessage += "Both are set to $($OutCsvFileName). Stopping execution." throw $ErrorMessage } # Create outpath if $Outpath does not exist if(-not (Test-Path -PathType "container" $OutPath)) { New-Item -ItemType "Directory" -Path $OutPath | Out-Null } $OutFolderPath = $OutPath $ProductNames = $ProductNames | Sort-Object -Unique Remove-Resources Import-Resources # Imports Providers, RunRego, CreateReport, Connection, Support, Utility # Authenticate $ConnectionParams = @{ 'LogIn' = $LogIn; 'ProductNames' = $ProductNames; 'M365Environment' = $M365Environment; 'BoundParameters' = $PSBoundParameters; } # Create a failsafe tenant metadata variable in case the # provider cannot retrieve the data. $TenantDetails = @{"DisplayName"="Rego Testing";} $TenantDetails = $TenantDetails | ConvertTo-Json -Depth 3 if ($ExportProvider) { # Check if there is a previous ScubaResults file # delete if found $PreviousResultsFiles = Get-ChildItem -Path $OutPath -Filter "$($OutJsonFileName)*.json" if ($PreviousResultsFiles) { $PreviousResultsFiles | ForEach-Object { Remove-Item $_.FullName -Force } } # authenticate $ProdAuthFailed = Invoke-Connection @ConnectionParams if ($ProdAuthFailed.Count -gt 0) { $Difference = Compare-Object $ProductNames -DifferenceObject $ProdAuthFailed -PassThru if (-not $Difference) { throw "All products were unable to establish a connection aborting execution" } else { $ProductNames = $Difference } } $TenantDetails = Get-TenantDetail -ProductNames $ProductNames -M365Environment $M365Environment # A new GUID needs to be generated if the provider is run $Guid = New-Guid -ErrorAction 'Stop' $ProviderParams = @{ 'ProductNames' = $ProductNames; 'M365Environment' = $M365Environment; 'TenantDetails' = $TenantDetails; 'ModuleVersion' = $ModuleVersion; 'OutFolderPath' = $OutFolderPath; 'OutProviderFileName' = $OutProviderFileName; 'Guid' = $Guid; 'BoundParameters' = $PSBoundParameters; } Invoke-ProviderList @ProviderParams } $ProviderJSONFilePath = Join-Path -Path $OutPath -ChildPath "$($OutProviderFileName).json" if (-not (Test-Path $ProviderJSONFilePath)) { # When running Invoke-ScubaCached, the provider output might not exist as a stand-alone # file depending on what version of ScubaGear created the output. If the provider output # does not exist as a stand-alone file, create it from the ScubaResults file so the other functions # can execute as normal. $ScubaResultsFileName = Join-Path -Path $OutPath -ChildPath "$($OutJsonFileName)*.json" # As there is the possibility that the wildcard will match multiple files, # select the one that was created last if there are multiple. # By default ScubaGear will output the files into their own folder. # The only case this will happen is when someone personally moves multiple files into the # same folder. $SettingsExport = $(Get-Content (Get-ChildItem $ScubaResultsFileName | Sort-Object CreationTime -Descending | Select-Object -First 1).FullName | ConvertFrom-Json).Raw # Uses the custom UTF8 NoBOM function to reoutput the Provider JSON file $ProviderContent = $SettingsExport | ConvertTo-Json -Depth 20 $ActualSavedLocation = Set-Utf8NoBom -Content $ProviderContent ` -Location $OutPath -FileName "$OutProviderFileName.json" Write-Debug $ActualSavedLocation } $SettingsExport = Get-Content $ProviderJSONFilePath | ConvertFrom-Json # Generate a new UUID if the original data doesn't have one if (-not (Get-Member -InputObject $SettingsExport -Name "report_uuid" -MemberType Properties)) { $Guid = New-Guid -ErrorAction 'Stop' $SettingsExport | Add-Member -Name 'report_uuid' -Value $Guid -Type NoteProperty } else { # Otherwise grab the UUID from the JSON itself $Guid = $SettingsExport.report_uuid } $ProviderContent = $SettingsExport | ConvertTo-Json -Depth 20 $ActualSavedLocation = Set-Utf8NoBom -Content $ProviderContent ` -Location $OutPath -FileName "$OutProviderFileName.json" Write-Debug $ActualSavedLocation $TenantDetails = $SettingsExport.tenant_details $RegoParams = @{ 'ProductNames' = $ProductNames; 'OPAPath' = $OPAPath; 'ParentPath' = $ParentPath; 'OutFolderPath' = $OutFolderPath; 'OutProviderFileName' = $OutProviderFileName; 'OutRegoFileName' = $OutRegoFileName; } $ReportParams = @{ 'ProductNames' = $ProductNames; 'TenantDetails' = $TenantDetails; 'ModuleVersion' = $ModuleVersion; 'OutFolderPath' = $OutFolderPath; 'OutProviderFileName' = $OutProviderFileName; 'OutRegoFileName' = $OutRegoFileName; 'OutReportName' = $OutReportName; 'Quiet' = $Quiet; 'DarkMode' = $DarkMode; } Invoke-RunRego @RegoParams Invoke-ReportCreation @ReportParams $FullNameParams = @{ 'OutJsonFileName' = $OutJsonFileName; 'Guid' = $Guid; 'NumberOfUUIDCharactersToTruncate' = $NumberOfUUIDCharactersToTruncate; } $FullScubaResultsName = Get-FullOutJsonName @FullNameParams if (-not $KeepIndividualJSON) { # Craft the complete json version of the output $JsonParams = @{ 'ProductNames' = $ProductNames; 'OutFolderPath' = $OutFolderPath; 'OutProviderFileName' = $OutProviderFileName; 'TenantDetails' = $TenantDetails; 'ModuleVersion' = $ModuleVersion; 'FullScubaResultsName' = $FullScubaResultsName; 'Guid' = $Guid; } Merge-JsonOutput @JsonParams } # Craft the csv version of just the results $CsvParams = @{ 'ProductNames' = $ProductNames; 'OutFolderPath' = $OutFolderPath; 'FullScubaResultsName' = $FullScubaResultsName; 'OutCsvFileName' = $OutCsvFileName; 'OutActionPlanFileName' = $OutActionPlanFileName; } ConvertTo-ResultsCsv @CsvParams } } Export-ModuleMember -Function @( 'Invoke-SCuBA', 'Invoke-SCuBACached' ) # SIG # Begin signature block # MIIuugYJKoZIhvcNAQcCoIIuqzCCLqcCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCC1iJHDoCGU2oaI # DclSQZ4yJ3gvtSmBvMa3H9EfRVyhcKCCE6MwggWQMIIDeKADAgECAhAFmxtXno4h # MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV # BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z # ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB # AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z # G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ # anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s # Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL # 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb # BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3 # JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c # AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx # YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0 # viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL # T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud # EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf # Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk # aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS # PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK # 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB # cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp # 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg # dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri # RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7 # 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5 # nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3 # i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H # EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G # CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz # ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C # 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce # 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da # E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T # SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA # FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh # D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM # 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z # 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05 # huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY # mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP # /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T # AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD # VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG # A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV # HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU # cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN # BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry # sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL # IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf # Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh # OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh # dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV # 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j # wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH # Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC # XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l # /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW # eE4wggdXMIIFP6ADAgECAhANkQ8dPvvR0q3Ytt4H0T3aMA0GCSqGSIb3DQEBCwUA # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz # ODQgMjAyMSBDQTEwHhcNMjQwMTMwMDAwMDAwWhcNMjUwMTI5MjM1OTU5WjBfMQsw # CQYDVQQGEwJVUzEdMBsGA1UECBMURGlzdHJpY3Qgb2YgQ29sdW1iaWExEzARBgNV # BAcTCldhc2hpbmd0b24xDTALBgNVBAoTBENJU0ExDTALBgNVBAMTBENJU0EwggIi # MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCT1y7uJCQax8JfiDEYgpiU9URj # EXCTRqtZbDALM9rPUudiuM3mj6A1SUSAAWYv6DTsvGPvxyMI2Idg0mQunl4Ms9DJ # yVwe5k4+Anj/73Nx1AbOPYP8xRZcD10FkctKGhV0PzvrDcwU15hsQWtiepFgg+bX # fHkGMeu426oc69f43vKE43DiqKTf0/UBX/qgpj3JZvJ3zc1kilBOv4sBCksfCjbW # tLZD0tqAgBsNPo3Oy5mQG31E1eZdTNvrdTnEXacSwb3k615z7mHy7nqBUkOruZ9E # tnvC2qla+uL3ks91O/e/LnKzH9Lj1JmEBf6jwPN/MYR9Dymni4Mi3AQ8mpQMyFmi # XcSHymibSNbtTMavpdBWjFfrcvPETX7krROUOoLzMQmNgHArceSh55tgvDRdSU5c # WK3BTvK3l3mgCdgjre7XGYxV3W8apyxk5+RKfHdbv9cpRwpSuDnI8sHeqmB3fnfo # Cr1PPu4WhKegt20CobhDVybiBdhDVqUdR53ful4N/coQOEHDrIExB5nJf9Pvdrza # DyIGKAMIXD79ba5/rQEo+2cA66oJkPlvB5hEGI/jtDcYwDBgalbwB7Kc8zAAhl6+ # JvHfYpXOkppSfEQbaRXZI+LGXWQAFa5pJDfDEAyZSXprStgw594sWUOysp+UOxFe # kSA4mBr0o1jVpdaulwIDAQABo4ICAzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8R # hvv+YXsIiGX0TkIwHQYDVR0OBBYEFAmyTB5bcWyA+8+rq540jPRLJ1nYMD4GA1Ud # IAQ3MDUwMwYGZ4EMAQQBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNl # cnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMw # gbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp # Z2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5j # cmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0 # ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3JsMIGUBggrBgEF # BQcBAQSBhzCBhDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29t # MFwGCCsGAQUFBzAChlBodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNl # cnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJ # BgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQAh2Jnt9IPoBvOQlYQUlCP9iJ5y # XAvEWe1camOwedqMZsHEPpT2yd6+fMzPZmV3/bYJgaN2OrDS1snf62S7yc+AulVw # PAXSp1lSAiFEbZ6PFEdEBIag9B65Mp/cvRtJsIWQIc//jWqFMHpkU6r3MW9YARRu # vaIf5/0qlM4VEwn3lTf+jJdxhhyoOFTWWd3BrlMPcT06z6F6hFfyycQkZ3Y9wEJ3 # uOU9bCNLZL1HCjlKT+oI0WsgeRdbe2sYrnvv9NmDY9oEi8PEq+DGjiTgLbY5OcAX # uUogPPw6gbcuNn8Hq6FFKPIQxaksB8dF8Gw4m2lQoUWESPRF8Zaq9lmZN3+QzA79 # yskfJtAFqz3gUP5wJBdNfi/u1sGbLI0QnJQkIKfFuz7DfDPldw0gIl05BIYwZBmj # TpFRu1/+gIlP1Ul4L/wt9Lxk6pglObLsdxHP2UQrG30JaUN0gv3xZMBBByHGVVTe # cyU4qwJ0ulMdv/kjHwh+m58uOF8gHXLfyBmOjYpohN3+l0rS0qdArZMNSmLTA7N8 # n3V3AZLKB//1yhPt++gR4pCFdXmgwYDDLRxjlV0cMsG1UeSQUdI0aieh/grg5TQO # CergVXS5h3sz5U0ZQPWND41LJhA0gF2OGZNHdUc9+0dwTsfxAERrjaTdeZp0/rdZ # 9iGBoiRsS4U86S8xkDGCGm0wghppAgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNV # BAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0 # IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENBMQIQDZEPHT770dKt # 2LbeB9E92jANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgACh # AoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAM # BgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCDKenbfTP1FpuGkGaznYwmSLWor # uJg0fW88rePwNJ0zuzANBgkqhkiG9w0BAQEFAASCAgBbkTjroMGE4NhUV899X93W # stHPEDYVYN7s6gzcaRTU5JcBZ/VU5R/b1GCmTzMUM56Mq6yabWak7UPX9bQh8POG # KeFQ2ZANHjCAzsm45iPiwRy83QEMgqBsKqDHJnlk3qZ5xIphT2bSuXJWgUZc2K32 # z2LuX/TlRwlWR8kdTIBQA6Cid5pDwIV/MKFOlKCq10AmBKuc1gbXzcSBR9850PD2 # K7SNDV81KAPY5rGQz4avl2Emznm/Kh6gk9whjyYo/K61cFPs44+XXfSgBGbm48xQ # /3cCr7e92OAGZww+jJW4RSbwi1kg5EN17AKEkNKx7mTLy6nnkS5D3+Wy8sGmQS29 # T3z6evqira2qa5u13I8B0mGjh9yG/abbOWzkHTWDv4AXeCxSdlqZ+tkewakm80R2 # MnMbiua/adWVP4BPprnOf8oGJkKCoUBQK0JSj8Zk8bd1QwVRa3LErSPZ//vym5T3 # fVkNkog8Eyq4vk/gfkVsvKI34bmRe6RDs2MCbQFvOoEzdnfvgSw2+bD/cNQczsTY # Sze/hk4Pru7nxUkRw58suMivYFRvJE9se3tIU0DplypDNF3/NyOl9/1yYSYjTmiJ # 848lsSE7g2RRSTbl/IgD1/Zl7pUON/SHZiZZ1ryD/yb0vyaSkEtBFe7od5Nxrq0Y # x9Y0WBM/Hu7mT43x7AH2BqGCFzowghc2BgorBgEEAYI3AwMBMYIXJjCCFyIGCSqG # SIb3DQEHAqCCFxMwghcPAgEDMQ8wDQYJYIZIAWUDBAIBBQAweAYLKoZIhvcNAQkQ # AQSgaQRnMGUCAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFlAwQCAQUABCD23d5hkxOm # DjM1Zoj64ZxpUq01zYZJEvVI85CimOdOxAIRALaC3sbs4WXYBABAPkz48r8YDzIw # MjQxMjE5MTgzNjMxWqCCEwMwgga8MIIEpKADAgECAhALrma8Wrp/lYfG+ekE4zME # MA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2Vy # dCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2IFNI # QTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjQwOTI2MDAwMDAwWhcNMzUxMTI1MjM1 # OTU5WjBCMQswCQYDVQQGEwJVUzERMA8GA1UEChMIRGlnaUNlcnQxIDAeBgNVBAMT # F0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDI0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A # MIICCgKCAgEAvmpzn/aVIauWMLpbbeZZo7Xo/ZEfGMSIO2qZ46XB/QowIEMSvgjE # dEZ3v4vrrTHleW1JWGErrjOL0J4L0HqVR1czSzvUQ5xF7z4IQmn7dHY7yijvoQ7u # jm0u6yXF2v1CrzZopykD07/9fpAT4BxpT9vJoJqAsP8YuhRvflJ9YeHjes4fduks # THulntq9WelRWY++TFPxzZrbILRYynyEy7rS1lHQKFpXvo2GePfsMRhNf1F41nyE # g5h7iOXv+vjX0K8RhUisfqw3TTLHj1uhS66YX2LZPxS4oaf33rp9HlfqSBePejlY # eEdU740GKQM7SaVSH3TbBL8R6HwX9QVpGnXPlKdE4fBIn5BBFnV+KwPxRNUNK6lY # k2y1WSKour4hJN0SMkoaNV8hyyADiX1xuTxKaXN12HgR+8WulU2d6zhzXomJ2Ple # I9V2yfmfXSPGYanGgxzqI+ShoOGLomMd3mJt92nm7Mheng/TBeSA2z4I78JpwGpT # RHiT7yHqBiV2ngUIyCtd0pZ8zg3S7bk4QC4RrcnKJ3FbjyPAGogmoiZ33c1HG93V # p6lJ415ERcC7bFQMRbxqrMVANiav1k425zYyFMyLNyE1QulQSgDpW9rtvVcIH7Wv # G9sqYup9j8z9J1XqbBZPJ5XLln8mS8wWmdDLnBHXgYly/p1DhoQo5fkCAwEAAaOC # AYswggGHMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB/wQM # MAoGCCsGAQUFBwMIMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsGCWCGSAGG/WwHATAf # BgNVHSMEGDAWgBS6FtltTYUvcyl2mi91jGogj57IbzAdBgNVHQ4EFgQUn1csA3cO # KBWQZqVjXu5Pkh92oFswWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDovL2NybDMuZGln # aWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNIQTI1NlRpbWVTdGFt # cGluZ0NBLmNybDCBkAYIKwYBBQUHAQEEgYMwgYAwJAYIKwYBBQUHMAGGGGh0dHA6 # Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBYBggrBgEFBQcwAoZMaHR0cDovL2NhY2VydHMu # ZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNIQTI1NlRpbWVT # dGFtcGluZ0NBLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAPa0eH3aZW+M4hBJH2UOR # 9hHbm04IHdEoT8/T3HuBSyZeq3jSi5GXeWP7xCKhVireKCnCs+8GZl2uVYFvQe+p # PTScVJeCZSsMo1JCoZN2mMew/L4tpqVNbSpWO9QGFwfMEy60HofN6V51sMLMXNTL # fhVqs+e8haupWiArSozyAmGH/6oMQAh078qRh6wvJNU6gnh5OruCP1QUAvVSu4kq # VOcJVozZR5RRb/zPd++PGE3qF1P3xWvYViUJLsxtvge/mzA75oBfFZSbdakHJe2B # VDGIGVNVjOp8sNt70+kEoMF+T6tptMUNlehSR7vM+C13v9+9ZOUKzfRUAYSyyEmY # tsnpltD/GWX8eM70ls1V6QG/ZOB6b6Yum1HvIiulqJ1Elesj5TMHq8CWT/xrW7tw # ipXTJ5/i5pkU5E16RSBAdOp12aw8IQhhA/vEbFkEiF2abhuFixUDobZaA0VhqAsM # HOmaT3XThZDNi5U2zHKhUs5uHHdG6BoQau75KiNbh0c+hatSF+02kULkftARjsyE # pHKsF7u5zKRbt5oK5YGwFvgc4pEVUNytmB3BpIiowOIIuDgP5M9WArHYSAR16gc0 # dP2XdkMEP5eBsX7bf/MGN4K3HP50v/01ZHo/Z5lGLvNwQ7XHBx1yomzLP8lx4Q1z # ZKDyHcp4VQJLu2kWTsKsOqQwggauMIIElqADAgECAhAHNje3JFR82Ees/ShmKl5b # MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD # ZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0yMjAzMjMwMDAwMDBaFw0zNzAzMjIyMzU5 # NTlaMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkG # A1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3Rh # bXBpbmcgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDGhjUGSbPB # PXJJUVXHJQPE8pE3qZdRodbSg9GeTKJtoLDMg/la9hGhRBVCX6SI82j6ffOciQt/ # nR+eDzMfUBMLJnOWbfhXqAJ9/UO0hNoR8XOxs+4rgISKIhjf69o9xBd/qxkrPkLc # Z47qUT3w1lbU5ygt69OxtXXnHwZljZQp09nsad/ZkIdGAHvbREGJ3HxqV3rwN3mf # XazL6IRktFLydkf3YYMZ3V+0VAshaG43IbtArF+y3kp9zvU5EmfvDqVjbOSmxR3N # Ng1c1eYbqMFkdECnwHLFuk4fsbVYTXn+149zk6wsOeKlSNbwsDETqVcplicu9Yem # j052FVUmcJgmf6AaRyBD40NjgHt1biclkJg6OBGz9vae5jtb7IHeIhTZgirHkr+g # 3uM+onP65x9abJTyUpURK1h0QCirc0PO30qhHGs4xSnzyqqWc0Jon7ZGs506o9UD # 4L/wojzKQtwYSH8UNM/STKvvmz3+DrhkKvp1KCRB7UK/BZxmSVJQ9FHzNklNiyDS # LFc1eSuo80VgvCONWPfcYd6T/jnA+bIwpUzX6ZhKWD7TA4j+s4/TXkt2ElGTyYwM # O1uKIqjBJgj5FBASA31fI7tk42PgpuE+9sJ0sj8eCXbsq11GdeJgo1gJASgADoRU # 7s7pXcheMBK9Rp6103a50g5rmQzSM7TNsQIDAQABo4IBXTCCAVkwEgYDVR0TAQH/ # BAgwBgEB/wIBADAdBgNVHQ4EFgQUuhbZbU2FL3MpdpovdYxqII+eyG8wHwYDVR0j # BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud # JQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0 # cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0 # cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E # PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz # dGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEw # DQYJKoZIhvcNAQELBQADggIBAH1ZjsCTtm+YqUQiAX5m1tghQuGwGC4QTRPPMFPO # vxj7x1Bd4ksp+3CKDaopafxpwc8dB+k+YMjYC+VcW9dth/qEICU0MWfNthKWb8RQ # TGIdDAiCqBa9qVbPFXONASIlzpVpP0d3+3J0FNf/q0+KLHqrhc1DX+1gtqpPkWae # LJ7giqzl/Yy8ZCaHbJK9nXzQcAp876i8dU+6WvepELJd6f8oVInw1YpxdmXazPBy # oyP6wCeCRK6ZJxurJB4mwbfeKuv2nrF5mYGjVoarCkXJ38SNoOeY+/umnXKvxMfB # wWpx2cYTgAnEtp/Nh4cku0+jSbl3ZpHxcpzpSwJSpzd+k1OsOx0ISQ+UzTl63f8l # Y5knLD0/a6fxZsNBzU+2QJshIUDQtxMkzdwdeDrknq3lNHGS1yZr5Dhzq6YBT70/ # O3itTK37xJV77QpfMzmHQXh6OOmc4d0j/R0o08f56PGYX/sr2H7yRp11LB4nLCbb # bxV7HhmLNriT1ObyF5lZynDwN7+YAN8gFk8n+2BnFqFmut1VwDophrCYoCvtlUG3 # OtUVmDG0YgkPCr2B2RP+v6TR81fZvAT6gt4y3wSJ8ADNXcL50CN/AAvkdgIm2fBl # dkKmKYcJRyvmfxqkhQ/8mJb2VVQrH4D6wPIOK+XW+6kvRBVK5xMOHds3OBqhK/bt # 1nz8MIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAYWjANBgkqhkiG9w0BAQwF # ADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL # ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElE # IFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5MjM1OTU5WjBiMQswCQYD # VQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGln # aWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIi # MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKn # JS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/W # BTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHi # LQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhm # V1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHE # tWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6 # MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mX # aXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZ # xd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfh # vbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvl # EFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn1 # 5GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV # HQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0jBBgwFoAUReuir/SSy4Ix # LVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsGAQUFBwEBBG0wazAkBggr # BgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMGCCsGAQUFBzAChjdo # dHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290 # Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNv # bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYDVR0gBAowCDAGBgRVHSAA # MA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3QbPbYW1/e/Vwe9mqyhhyzs # hV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5+KH38nLeJLxSA8hO0Cre # +i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+nBgMTdydE1Od/6Fmo8L8v # C6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc/RzY9HdaXFSMb++hUD38 # dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVrzyerbHbObyMt9H5xaiNr # Iv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o4rmUMYIDdjCCA3ICAQEw # dzBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xOzA5BgNV # BAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEyNTYgVGltZVN0YW1w # aW5nIENBAhALrma8Wrp/lYfG+ekE4zMEMA0GCWCGSAFlAwQCAQUAoIHRMBoGCSqG # SIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcNMjQxMjE5MTgz # NjMxWjArBgsqhkiG9w0BCRACDDEcMBowGDAWBBTb04XuYtvSPnvk9nFIUIck1YZb # RTAvBgkqhkiG9w0BCQQxIgQgxFwcuaRw09lBPXMrNPO44gvV3br+7Z0hoYAYiBtg # 2h4wNwYLKoZIhvcNAQkQAi8xKDAmMCQwIgQgdnafqPJjLx9DCzojMK7WVnX+13Pb # BdZluQWTmEOPmtswDQYJKoZIhvcNAQEBBQAEggIAUepfsELsK78+krndSPiOfBT5 # 1ZFFwKxNBAYoYGRW/6hLy4eklayLxpe+g4MxFACzOAaF6WTUn2c6Q4Vd5l/BxWia # FZCqQx9npOCyYB0uKwNgBjz4OQzjxR+nCLt+TTKZ/j09+TPLJOWsduBWt/sRs9uz # m/sweI2XMOS6n7pJumYO2wePXnRcTzRqciLkeM0oUqII4S0iPotlm7T+o7P5kATH # t9H8XLElfLZQxWK5HakYQ2Awv9ZEdM5UTMnLYd+bpxG9S/lICAMbxT/FlFw4wPrd # SHBf/Nm3lh6QX771Z+N+OYWXyxNqMhayZW+xyg6wbWLI4S25X6S9lIeUFvosuSYm # 0xukU28Yi3Tg++fTJHvw95clI2+AKKSaljH31D5Yhy16PknAGhYqAL0oF0pMgcZV # Mfxmt1oaMKwxS+t+faFsipjSPcag4IkZ+x2fyb9nPXlKQPBtLTnNyy3k/0awSiEk # LSlxq+NxhTF6HXGL9pxlGidkwXbqpAokAREkKUmnwqXq2qqQSomZKwykixgl46jy # Z3nr/2aNRaET1az8LmoeVHYuhXvuQg/t2sStC0ezXP6VbGxCNe3EsqQJHtF8k89b # p4vZwVi1Lju9/2CWuHkkbLja1l34JC6A9J7b2grc8UoqBumpqFu4sFD+iJNLcsrj # YnKqpN5dqeyJDWgcEF8= # SIG # End signature block |