diff --git a/PowerShell/ScubaGear/Modules/Orchestrator.psm1 b/PowerShell/ScubaGear/Modules/Orchestrator.psm1 index f762231122..66f2cb4f03 100644 --- a/PowerShell/ScubaGear/Modules/Orchestrator.psm1 +++ b/PowerShell/ScubaGear/Modules/Orchestrator.psm1 @@ -71,7 +71,7 @@ function Invoke-SCuBA { 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". + 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". @@ -90,11 +90,14 @@ function Invoke-SCuBA { 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 M365BaselineConformaance_*. + 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. @@ -254,10 +257,17 @@ function Invoke-SCuBA { [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [switch] - $Quiet + $Quiet, + + [Parameter(Mandatory = $false, ParameterSetName = 'Configuration')] + [Parameter(Mandatory = $false, ParameterSetName = 'Report')] + [ValidateNotNullOrEmpty()] + [ValidateSet(0, 13, 18, 36)] + [int] + $NumberOfUUIDCharactersToTruncate = [ScubaConfig]::ScubaDefault('DefaultNumberOfUUIDCharactersToTruncate') ) process { - # Retrive ScubaGear Module versions + # 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 @@ -290,6 +300,7 @@ function Invoke-SCuBA { 'OutJsonFileName' = $OutJsonFileName 'OutCsvFileName' = $OutCsvFileName 'OutActionPlanFileName' = $OutActionPlanFileName + 'NumberOfUUIDCharactersToTruncate' = $NumberOfUUIDCharactersToTruncate } $ScubaConfig = New-Object -Type PSObject -Property $ProvidedParameters @@ -374,22 +385,26 @@ function Invoke-SCuBA { # 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; + 'ProductNames' = $ScubaConfig.ProductNames; + 'M365Environment' = $ScubaConfig.M365Environment; + 'TenantDetails' = $TenantDetails; + 'ModuleVersion' = $ModuleVersion; + 'OutFolderPath' = $OutFolderPath; 'OutProviderFileName' = $ScubaConfig.OutProviderFileName; - 'BoundParameters' = $PSBoundParameters; + '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' + -ProductsFailed $ProdProviderFailed ` + -ExceptionMessage 'All indicated Product Providers failed to execute' } # OPA Rego invocation @@ -424,24 +439,33 @@ function Invoke-SCuBA { } 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; - 'OutJsonFileName' = $ScubaConfig.OutJsonFileName; + '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; - 'OutJsonFileName' = $ScubaConfig.OutJsonFileName; - 'OutCsvFileName' = $ScubaConfig.OutCsvFileName; + 'ProductNames' = $ScubaConfig.ProductNames; + 'OutFolderPath' = $OutFolderPath; + 'FullScubaResultsName' = $FullScubaResultsName; + 'OutCsvFileName' = $ScubaConfig.OutCsvFileName; 'OutActionPlanFileName' = $ScubaConfig.OutActionPlanFileName; } ConvertTo-ResultsCsv @CsvParams @@ -540,6 +564,11 @@ function Invoke-ProviderList { [string] $OutProviderFileName, + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Guid, + [Parameter(Mandatory = $true)] [hashtable] $BoundParameters @@ -632,15 +661,6 @@ function Invoke-ProviderList { $ConfigDetails = "{}" } - try { - $Guid = New-Guid -ErrorAction 'Stop' - } - catch { - $Guid = "00000000-0000-0000-0000-000000000000" - $Warning = "Error generating new UUID. See the exception message for more details: $($_)" - Write-Warning $Warning - } - $BaselineSettingsExport = @" { "baseline_version": "1", @@ -851,6 +871,50 @@ function Format-PlainText { } } +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 @@ -874,7 +938,7 @@ function ConvertTo-ResultsCsv { [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] - $OutJsonFileName, + $FullScubaResultsName, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] @@ -888,10 +952,11 @@ function ConvertTo-ResultsCsv { ) process { try { - $ScubaResultsFileName = Join-Path $OutFolderPath -ChildPath "$OutJsonFileName.json" - if (Test-Path $ScubaResultsFileName -PathType Leaf) { + $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 $ScubaResultsFileName | ConvertFrom-Json + $ScubaResults = Get-Content (Get-ChildItem $ScubaResultsPath).FullName | ConvertFrom-Json } else { # The ScubaResults file does not exists, so we need to look inside the IndividualReports @@ -992,7 +1057,12 @@ function Merge-JsonOutput { [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] - $OutJsonFileName + $FullScubaResultsName, + + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Guid ) process { try { @@ -1005,7 +1075,6 @@ function Merge-JsonOutput { $SettingsExport = Get-Content $SettingsExportPath -Raw $SettingsExportObject = $(ConvertFrom-Json $SettingsExport) $TimestampZulu = $SettingsExportObject.timestamp_zulu - $ReportUuid = $SettingsExportObject.report_uuid # Get a list and abbreviation mapping of the products assessed $FullNames = @() @@ -1029,7 +1098,7 @@ function Merge-JsonOutput { "Tool" = "ScubaGear"; "ToolVersion" = $ModuleVersion; "TimestampZulu" = $TimestampZulu; - "ReportUUID" = $ReportUuid; + "ReportUUID" = $Guid; } @@ -1070,9 +1139,8 @@ function Merge-JsonOutput { $ReportJson = $ReportJson.replace("\u003e", ">") $ReportJson = $ReportJson.replace("\u0027", "'") - # Save the file - $JsonFileName = Join-Path -Path $OutFolderPath "$($OutJsonFileName).json" -ErrorAction 'Stop' - $ReportJson | Set-Content -Path $JsonFileName -Encoding $(Get-FileEncoding) -ErrorAction 'Stop' + $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) { @@ -1080,9 +1148,19 @@ function Merge-JsonOutput { } } catch { - $MergeJsonErrorMessage = "Fatal Error involving the Json reports aggregation. ` - Ending ScubaGear execution. See the exception message for more details: $($_)" - throw $MergeJsonErrorMessage + 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. $($_)" + throw $PathLengthErrorMessage + } + else { + $MergeJsonErrorMessage = "Fatal Error involving the Json reports aggregation. ` + Ending ScubaGear execution. See the exception message for more details: $($_)" + throw $MergeJsonErrorMessage + } } } } @@ -1598,7 +1676,7 @@ function Invoke-SCuBACached { 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". + 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". @@ -1607,6 +1685,9 @@ function Invoke-SCuBACached { 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 @@ -1728,7 +1809,13 @@ function Invoke-SCuBACached { [Parameter(Mandatory = $false, ParameterSetName = 'Report')] [switch] - $DarkMode + $DarkMode, + + [Parameter(Mandatory = $false, ParameterSetName = 'Report')] + [ValidateNotNullOrEmpty()] + [ValidateSet(0, 13, 18, 36)] + [int] + $NumberOfUUIDCharactersToTruncate = [ScubaConfig]::ScubaDefault('DefaultNumberOfUUIDCharactersToTruncate') ) process { $ParentPath = Split-Path $PSScriptRoot -Parent @@ -1769,10 +1856,22 @@ function Invoke-SCuBACached { 'BoundParameters' = $PSBoundParameters; } - # Rego Testing failsafe + # 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 @@ -1784,6 +1883,10 @@ function Invoke-SCuBACached { } } $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; @@ -1791,6 +1894,7 @@ function Invoke-SCuBACached { 'ModuleVersion' = $ModuleVersion; 'OutFolderPath' = $OutFolderPath; 'OutProviderFileName' = $OutProviderFileName; + 'Guid' = $Guid; 'BoundParameters' = $PSBoundParameters; } Invoke-ProviderList @ProviderParams @@ -1802,8 +1906,13 @@ function Invoke-SCuBACached { # 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" - $SettingsExport = $(Get-Content $ScubaResultsFileName | ConvertFrom-Json).Raw + $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 @@ -1815,20 +1924,18 @@ function Invoke-SCuBACached { # Generate a new UUID if the original data doesn't have one if (-not (Get-Member -InputObject $SettingsExport -Name "report_uuid" -MemberType Properties)) { - try { - $Guid = New-Guid -ErrorAction 'Stop' - } - catch { - $Guid = "00000000-0000-0000-0000-000000000000" - $Warning = "Error generating new UUID. See the exception message for more details: $($_)" - Write-Warning $Warning - } + $Guid = New-Guid -ErrorAction 'Stop' $SettingsExport | Add-Member -Name 'report_uuid' -Value $Guid -Type NoteProperty - $ProviderContent = $SettingsExport | ConvertTo-Json -Depth 20 - $ActualSavedLocation = Set-Utf8NoBom -Content $ProviderContent ` - -Location $OutPath -FileName "$OutProviderFileName.json" - Write-Debug $ActualSavedLocation } + 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 = @{ @@ -1853,6 +1960,13 @@ function Invoke-SCuBACached { 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 = @{ @@ -1861,7 +1975,8 @@ function Invoke-SCuBACached { 'OutProviderFileName' = $OutProviderFileName; 'TenantDetails' = $TenantDetails; 'ModuleVersion' = $ModuleVersion; - 'OutJsonFileName' = $OutJsonFileName; + 'FullScubaResultsName' = $FullScubaResultsName; + 'Guid' = $Guid; } Merge-JsonOutput @JsonParams } @@ -1869,12 +1984,11 @@ function Invoke-SCuBACached { $CsvParams = @{ 'ProductNames' = $ProductNames; 'OutFolderPath' = $OutFolderPath; - 'OutJsonFileName' = $OutJsonFileName; + 'FullScubaResultsName' = $FullScubaResultsName; 'OutCsvFileName' = $OutCsvFileName; 'OutActionPlanFileName' = $OutActionPlanFileName; } ConvertTo-ResultsCsv @CsvParams - } } diff --git a/PowerShell/ScubaGear/Modules/ScubaConfig/ScubaConfig.psm1 b/PowerShell/ScubaGear/Modules/ScubaConfig/ScubaConfig.psm1 index 87a3391e04..7e0a89a536 100644 --- a/PowerShell/ScubaGear/Modules/ScubaConfig/ScubaConfig.psm1 +++ b/PowerShell/ScubaGear/Modules/ScubaConfig/ScubaConfig.psm1 @@ -31,6 +31,7 @@ class ScubaConfig { DefaultOutJsonFileName = "ScubaResults" DefaultOutCsvFileName = "ScubaResults" DefaultOutActionPlanFileName = "ActionPlan" + DefaultNumberOfUUIDCharactersToTruncate = 18 DefaultPrivilegedRoles = @( "Global Administrator", "Privileged Role Administrator", @@ -156,6 +157,10 @@ class ScubaConfig { $this.Configuration.OutActionPlanFileName = [ScubaConfig]::ScubaDefault('DefaultOutActionPlanFileName') } + if (-Not $this.Configuration.NumberOfUUIDCharactersToTruncate){ + $this.Configuration.NumberOfUUIDCharactersToTruncate = [ScubaConfig]::ScubaDefault('DefaultNumberOfUUIDCharactersToTruncate') + } + return } diff --git a/PowerShell/ScubaGear/Sample-Reports/ScubaResults.json b/PowerShell/ScubaGear/Sample-Reports/ScubaResults_21189b0e-f045-43ee-b9ba-653b32744e45.json similarity index 99% rename from PowerShell/ScubaGear/Sample-Reports/ScubaResults.json rename to PowerShell/ScubaGear/Sample-Reports/ScubaResults_21189b0e-f045-43ee-b9ba-653b32744e45.json index dbe1d69282..2296d0a869 100644 --- a/PowerShell/ScubaGear/Sample-Reports/ScubaResults.json +++ b/PowerShell/ScubaGear/Sample-Reports/ScubaResults_21189b0e-f045-43ee-b9ba-653b32744e45.json @@ -22,7 +22,8 @@ }, "Tool": "ScubaGear", "ToolVersion": "1.4.0", - "TimestampZulu": "2024-08-02T19:25:11.166Z" + "TimestampZulu": "2024-08-02T19:25:11.166Z", + "ReportUUID": "21189b0e-f045-43ee-b9ba-653b32744e45" }, "Summary": { "AAD": { @@ -1339,6 +1340,7 @@ "module_version": "1.4.0", "date": "08/02/2024 14:25:11 Central Daylight Time", "timestamp_zulu": "2024-08-02T19:25:11.166Z", + "report_uuid": "21189b0e-f045-43ee-b9ba-653b32744e45", "tenant_details": [ { "AADAdditionalData": { diff --git a/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/ConvertTo-ResultsCsv.Tests.ps1 b/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/ConvertTo-ResultsCsv.Tests.ps1 index c9563a2557..3d1bf47fbd 100644 --- a/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/ConvertTo-ResultsCsv.Tests.ps1 +++ b/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/ConvertTo-ResultsCsv.Tests.ps1 @@ -15,6 +15,9 @@ InModuleScope Orchestrator { Mock -CommandName Get-FileEncoding Mock -CommandName ConvertTo-Csv { "" } Mock -CommandName Write-Warning {} + Mock -CommandName Get-ChildItem { + [pscustomobject]@{"FullName"="ScubaResults_00000000-0000-0000-0000-000000000000.json"; "CreationTime"=[DateTime]"2024-01-01"} + } } It 'Handles multiple products, control groups, and controls' { @@ -56,10 +59,10 @@ InModuleScope Orchestrator { }} } $CsvParameters = @{ - ProductNames = @("exo", "aad"); - OutFolderPath = "."; - OutJsonFileName = "ScubaResults"; - OutCsvFileName = "ScubaResults"; + ProductNames = @("exo", "aad"); + OutFolderPath = "."; + FullScubaResultsName = "ScubaResults"; + OutCsvFileName = "ScubaResults"; OutActionPlanFileName = "ActionPlan"; } { ConvertTo-ResultsCsv @CsvParameters} | Should -Not -Throw @@ -74,10 +77,10 @@ InModuleScope Orchestrator { Mock -CommandName ConvertFrom-Json {} Mock -CommandName Get-Content { throw "File not found" } $CsvParameters = @{ - ProductNames = @("exo", "aad"); - OutFolderPath = "."; - OutJsonFileName = "ScubaResults"; - OutCsvFileName = "ScubaResults"; + ProductNames = @("exo", "aad"); + OutFolderPath = "."; + FullScubaResultsName = "ScubaResults"; + OutCsvFileName = "ScubaResults"; OutActionPlanFileName = "ActionPlan"; } { ConvertTo-ResultsCsv @CsvParameters} | Should -Not -Throw diff --git a/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Get-FullOutJsonName.Tests.ps1 b/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Get-FullOutJsonName.Tests.ps1 new file mode 100644 index 0000000000..e75ce73b31 --- /dev/null +++ b/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Get-FullOutJsonName.Tests.ps1 @@ -0,0 +1,43 @@ +$OrchestratorPath = '../../../../Modules/Orchestrator.psm1' +Import-Module (Join-Path -Path $PSScriptRoot -ChildPath $OrchestratorPath) -Function 'Get-FullOutJsonName' + +Describe -Tag 'Orchestrator' -Name 'Get-FullOutJsonName' { + InModuleScope Orchestrator { + It 'Adds the full UUID' { + $FullNameParams = @{ + 'OutJsonFileName' = "ScubaResults"; + 'Guid' = "30ebce05-f8f0-4a09-8ec2-589efbbd0e72"; + 'NumberOfUUIDCharactersToTruncate' = 0; + } + (Get-FullOutJsonName @FullNameParams) | Should -eq "ScubaResults_30ebce05-f8f0-4a09-8ec2-589efbbd0e72.json" + } + It 'Handles partial truncation' { + $FullNameParams = @{ + 'OutJsonFileName' = "ScubaResults"; + 'Guid' = "30ebce05-f8f0-4a09-8ec2-589efbbd0e72"; + 'NumberOfUUIDCharactersToTruncate' = 18; + } + (Get-FullOutJsonName @FullNameParams) | Should -eq "ScubaResults_30ebce05-f8f0-4a09.json" + } + It 'Handles full truncation' { + $FullNameParams = @{ + 'OutJsonFileName' = "ScubaResults"; + 'Guid' = "30ebce05-f8f0-4a09-8ec2-589efbbd0e72"; + 'NumberOfUUIDCharactersToTruncate' = 36; + } + (Get-FullOutJsonName @FullNameParams) | Should -eq "ScubaResults.json" + } + It 'Handles non-default names' { + $FullNameParams = @{ + 'OutJsonFileName' = "my_results"; + 'Guid' = "30ebce05-f8f0-4a09-8ec2-589efbbd0e72"; + 'NumberOfUUIDCharactersToTruncate' = 18; + } + (Get-FullOutJsonName @FullNameParams) | Should -eq "my_results_30ebce05-f8f0-4a09.json" + } + } +} + +AfterAll { + Remove-Module Orchestrator -ErrorAction SilentlyContinue +} \ No newline at end of file diff --git a/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Invoke-ProviderList.Tests.ps1 b/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Invoke-ProviderList.Tests.ps1 index 5f745dc59a..edaa739e6d 100644 --- a/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Invoke-ProviderList.Tests.ps1 +++ b/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Invoke-ProviderList.Tests.ps1 @@ -30,12 +30,13 @@ Describe -Tag 'Orchestrator' -Name 'Invoke-ProviderList' { BeforeAll { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'ProviderParameters')] $ProviderParameters = @{ - OutFolderPath = "./output"; - OutProviderFileName = "ProviderSettingsExport"; - M365Environment = "commercial"; - TenantDetails = '{"DisplayName": "displayName"}'; - ModuleVersion = '1.0'; - BoundParameters = @{}; + OutFolderPath = "./output"; + OutProviderFileName = "ProviderSettingsExport"; + M365Environment = "commercial"; + TenantDetails = '{"DisplayName": "displayName"}'; + ModuleVersion = '1.0'; + BoundParameters = @{}; + Guid = "00000000-0000-0000-0000-000000000000" } } It 'With -ProductNames "aad", should not throw' { diff --git a/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Invoke-ScubaCached.Tests.ps1 b/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Invoke-ScubaCached.Tests.ps1 index 89e82742fe..c32a8d989f 100644 --- a/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Invoke-ScubaCached.Tests.ps1 +++ b/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Invoke-ScubaCached.Tests.ps1 @@ -27,9 +27,16 @@ InModuleScope Orchestrator { Mock -CommandName Write-Debug {} Mock -CommandName New-Item {} - Mock -CommandName Get-Content {} + Mock -CommandName Get-Content { "" } Mock -CommandName Get-Member { $true } Mock -CommandName New-Guid { "00000000-0000-0000-0000-000000000000" } + Mock -CommandName Get-ChildItem { + [pscustomobject]@{"FullName"="ScubaResults.json"; "CreationTime"=[DateTime]"2024-01-01"} + } + Mock -CommandName Remove-Item {} + Mock -CommandName ConvertFrom-Json { + [PSCustomObject]@{"report_uuid"="00000000-0000-0000-0000-000000000000"} + } } Context 'When checking the conformance of commercial tenants' { BeforeAll { @@ -126,6 +133,7 @@ InModuleScope Orchestrator { Should -Invoke -CommandName New-Guid -Exactly -Times 0 } It 'Given output without a UUID should generate a new one' { + Mock -CommandName ConvertFrom-Json { [PSCustomObject]@{} } Mock -CommandName Get-Member { $false } # Now Get-Member will return False so as far as the provider # can tell, the existing output does not have a UUID @@ -166,6 +174,27 @@ InModuleScope Orchestrator { {Invoke-SCuBACached @SplatParams} | Should -Throw } } + Context "When there are multiple ScubaResults*.json files" { + # It's possible (but not expected) that there are multiple files matching + # "ScubaResults*.json". In this case, ScubaGear should choose the file + # created most recently. + It 'Should select the most recently created' { + Mock -CommandName Get-ChildItem { @( + [pscustomobject]@{"FullName"="ScubaResultsOld.json"; "CreationTime"=[DateTime]"2023-01-01"}, + [pscustomobject]@{"FullName"="ScubaResultsNew.json"; "CreationTime"=[DateTime]"2024-01-01"}, + [pscustomobject]@{"FullName"="ScubaResultsOldest.json"; "CreationTime"=[DateTime]"2022-01-01"} + ) } + + Mock -CommandName Get-Content { + if ($Path -ne "ScubaResultsNew.json") { + # Should be the new one, throw if not + throw + } + } + + {Invoke-SCuBACached @SplatParams} | Should -Throw + } + } } } diff --git a/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Merge-JsonOutput.Tests.ps1 b/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Merge-JsonOutput.Tests.ps1 index 3cb7d9ef91..fb1dfdbe1e 100644 --- a/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Merge-JsonOutput.Tests.ps1 +++ b/PowerShell/ScubaGear/Testing/Unit/PowerShell/Orchestrator/Merge-JsonOutput.Tests.ps1 @@ -4,16 +4,16 @@ Import-Module (Join-Path -Path $PSScriptRoot -ChildPath $OrchestratorPath) -Func InModuleScope Orchestrator { Describe -Tag 'Orchestrator' -Name 'Merge-JsonOutput' { BeforeAll { - Mock -CommandName Join-Path { "." } Mock -CommandName Out-File {} Mock -CommandName Set-Content {} Mock -CommandName Remove-Item {} Mock -CommandName Get-Content { "" } Mock -CommandName ConvertFrom-Json { @{ - "ReportSummary"=@{"Date"=""} - "Results"=@(); - "timestamp_zulu"=""; - } + "ReportSummary" = @{"Date" = "" } + "Results" = @(); + "timestamp_zulu" = ""; + "report_uuid" = "00000000-0000-0000-0000-000000000000" + } } Mock -CommandName Add-Member {} Mock -CommandName ConvertTo-Json { "" } @@ -22,34 +22,38 @@ InModuleScope Orchestrator { BeforeAll { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'JsonParameters')] $JsonParameters = @{ - TenantDetails = @{"DisplayName" = "displayName"; "TenantId" = "tenantId"; "DomainName" = "domainName"}; - ModuleVersion = '1.0'; - OutFolderPath = "./" - OutProviderFileName = "ProviderSettingsExport" - OutJsonFileName = "ScubaResults" + TenantDetails = @{"DisplayName" = "displayName"; "TenantId" = "tenantId"; "DomainName" = "domainName" }; + ModuleVersion = '1.0'; + OutFolderPath = "./"; + OutProviderFileName = "ProviderSettingsExport"; + FullScubaResultsName = "ScubaResults.json"; + Guid = "00000000-0000-0000-0000-000000000000"; } } It 'Merge single result' { + Mock -CommandName Join-Path { "." } $JsonParameters += @{ - ProductNames = @("aad") + ProductNames = @("aad"); } - { Merge-JsonOutput @JsonParameters} | Should -Not -Throw + { Merge-JsonOutput @JsonParameters } | Should -Not -Throw Should -Invoke -CommandName ConvertFrom-Json -Exactly -Times 2 $JsonParameters.ProductNames = @() } It 'Merge multiple results' { + Mock -CommandName Join-Path { "." } $JsonParameters += @{ - ProductNames = @("aad", "teams") + ProductNames = @("aad", "teams"); } - { Merge-JsonOutput @JsonParameters} | Should -Not -Throw + { Merge-JsonOutput @JsonParameters } | Should -Not -Throw Should -Invoke -CommandName ConvertFrom-Json -Exactly -Times 3 $JsonParameters.ProductNames = @() } It 'Delete redundant files' { + Mock -CommandName Join-Path { "." } $JsonParameters += @{ - ProductNames = @("aad", "teams") + ProductNames = @("aad", "teams"); } - { Merge-JsonOutput @JsonParameters} | Should -Not -Throw + { Merge-JsonOutput @JsonParameters } | Should -Not -Throw Should -Invoke -CommandName Remove-Item -Exactly -Times 3 $JsonParameters.ProductNames = @() } diff --git a/docs/configuration/parameters.md b/docs/configuration/parameters.md index e8cadbaa0a..5152f4eb2d 100644 --- a/docs/configuration/parameters.md +++ b/docs/configuration/parameters.md @@ -2,7 +2,7 @@ The `Invoke-SCuBA` cmdlet has several command-line parameters, which are described below. -> **Note**: Some parameters can also be specified in a [configuration file](configuration.md). If specified in both, command-line parameters have precedence over the config file. +> **Note**: Some parameters can also be specified in a [configuration file](configuration.md). If specified in both, command-line parameters have precedence over the config file. > **Note**: Parameters use the Pascal case convention, and their names are consistent with those in the configuration file. @@ -15,7 +15,7 @@ The `Invoke-SCuBA` cmdlet has several command-line parameters, which are describ | Optional | Yes | | Datatype | String | | Default | n/a | -| Config File | Yes | +| Config File | Yes | Here is an example using `-AppID`: @@ -27,7 +27,7 @@ Invoke-SCuBA -ProductNames teams ` -Organization contoso.onmicrosoft.com ``` -> **Note**: AppID, CertificateThumbprint, and Organization are part of a parameter set used for authentication; if one is specified, all three must be specified. +> **Note**: AppID, CertificateThumbprint, and Organization are part of a parameter set used for authentication; if one is specified, all three must be specified. ## CertificateThumbprint @@ -38,7 +38,7 @@ Invoke-SCuBA -ProductNames teams ` | Optional | Yes | | Datatype | String | | Default | n/a | -| Config File | Yes | +| Config File | Yes | Here is an example using `-CertificateThumbprint`: @@ -50,11 +50,11 @@ Invoke-SCuBA -ProductNames teams ` -Organization contoso.onmicrosoft.com ``` -> **Note**: AppID, CertificateThumbprint, and Organization are part of a parameter set used for authentication; if one is specified, all three must be specified. +> **Note**: AppID, CertificateThumbprint, and Organization are part of a parameter set used for authentication; if one is specified, all three must be specified. ## ConfigFilePath -**ConfigFilePath** is the path of a [configuration file](configuration.md) that ScubaGear parses for input parameters. +**ConfigFilePath** is the path of a [configuration file](configuration.md) that ScubaGear parses for input parameters. | Parameter | Value | |-------------|---------------------------------------| @@ -71,7 +71,7 @@ Invoke-SCuBA -ProductNames teams ` -ConfigFilePath C:\users\johndoe\Documents\scuba\config.json ``` -If `-ConfigFilePath` is specified, default values will be used for any parameters that are not added to the config file. These default values are shown in the [full config file](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Sample-Config-Files/full_config.yaml). +If `-ConfigFilePath` is specified, default values will be used for any parameters that are not added to the config file. These default values are shown in the [full config file](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Sample-Config-Files/full_config.yaml). More information about the configuration file can be found on the [configuration page](configuration.md). @@ -86,7 +86,7 @@ More information about the configuration file can be found on the [configuration | Optional | Yes | | Datatype | Switch | | Default | n/a | -| Config File | No | +| Config File | No | ```powershell # View the HTML report in dark mode @@ -103,7 +103,7 @@ Invoke-SCuBA -ProductNames teams ` | Optional | Yes | | Datatype | Switch | | Default | n/a | -| Config File | Yes | +| Config File | Yes | ```powershell # Delete the auth tokens @@ -111,6 +111,24 @@ Invoke-SCuBA -ProductNames teams ` -DisconnectOnExit ``` +## KeepIndividualJSON + +**KeepIndividualJSON** Keeps the individual JSON files (e.g., `TeamsReport.json`) in the `IndividualReports` folder along with `ProviderSettingsExport.json` without combining the results in to one uber JSON file named the `ScubaResults.json`. The parameter is for backwards compatibility with older versions of ScubaGear. + +| Parameter | Value | +|-------------|--------| +| Optional | Yes | +| Datatype | Switch | +| Default | n/a | +| Config File | No | + +```powershell +# Outputs legacy ScubaGear individual JSON output +Invoke-SCuBA -ProductNames teams ` + -KeepIndividualJSON +``` + + ## LogIn **LogIn** enforces or bypasses authentication. If `$true`, ScubaGear will prompt the user to provide credentials to establish a connection to the specified M365 products in the `ProductNames` variable. If `$false`, it will use the previously issued authentication token, if it has not expired. @@ -122,7 +140,7 @@ Invoke-SCuBA -ProductNames teams ` | Default | `$true` | | Config File | Yes | -This variable should typically be `$true`, as a connection is established in the current PowerShell terminal session with the first authentication. If another verification is run in the same PowerShell session, then this variable can be set to false to bypass a second authenticate. +This variable should typically be `$true`, as a connection is established in the current PowerShell terminal session with the first authentication. If another verification is run in the same PowerShell session, then this variable can be set to false to bypass a second authenticate. ```powershell # Reuse previous authentication @@ -141,7 +159,7 @@ Invoke-SCuBA -ProductNames teams ` | Optional | Yes | | Datatype | String | | Default | `commercial` | -| Config File | Yes | +| Config File | Yes | > **Note**: This parameter is required if authenticating to Power Platform. It is also required if executing the tool against GCC High or DoD tenants. @@ -160,21 +178,32 @@ The list of acceptable values are: | Government cloud tenants (high) | gcchigh | | Department of Defense tenants | dod | -## KeepIndividualJSON -**KeepIndividualJSON** Keeps the individual JSON files (e.g., `TeamsReport.json`) in the `IndividualReports` folder along with `ProviderSettingsExport.json` without combining the results in to one uber JSON file named the `ScubaResults.json`. The parameter is for backwards compatibility with older versions of ScubaGear. +## NumberOfUUIDCharactersToTruncate -| Parameter | Value | -|-------------|--------| -| Optional | Yes | -| Datatype | Switch | -| Default | n/a | -| Config File | No | +**NumberOfUUIDCharactersToTruncate** controls how many characters will be truncated from the report UUID when appended to the end of **OutJsonFileName**. + +| Parameter | Value | +|-------------|--------------------| +| Optional | Yes | +| Datatype | Integer | +| Default | 18 | +| Config File | Yes | + + +The list of acceptable values are: + +| Description | Value | +|----------------------------------------|------------| +| Do no truncation of the appended UUID | 0 | +| Remove one octet of the appended UUID | 13 | +| Remove two octets of the appended UUID | 18 | +| Remove the appended UUID completely | 36 | ```powershell -# Outputs legacy ScubaGear individual JSON output -Invoke-SCuBA -ProductNames teams ` - -KeepIndividualJSON +# Truncate the UUID at the end of OutJsonFileName by 18 characters +Invoke-SCuBA -ProductNames exo ` + -NumberOfUUIDCharactersToTruncate 18 ``` ## OPAPath @@ -205,7 +234,7 @@ Invoke-SCuBA -ProductNames teams ` | Optional | Yes | | Datatype | String | | Default | n/a | -| Config File | Yes | +| Config File | Yes | Here is an example using Organization: @@ -217,36 +246,54 @@ Invoke-SCuBA -ProductNames teams ` -Organization contoso.onmicrosoft.com ``` -> **Note**: AppID, CertificateThumbprint, and Organization are part of a parameter set used for authentication; if one is specified, all three must be specified. +> **Note**: AppID, CertificateThumbprint, and Organization are part of a parameter set used for authentication; if one is specified, all three must be specified. ## OutActionPlanFileName **OutActionPlanFileName** renames the file for the action plan template for the test results. This should only be the base file name, as the extension `.csv` will automatically be added. -| Parameter | Value | -|-------------|--------------------| -| Optional | Yes | -| Datatype | String | -| Default | `ActionPlan` | -| Config File | Yes | +| Parameter | Value | +|-------------|--------------| +| Optional | Yes | +| Datatype | String | +| Default | `ActionPlan` | +| Config File | Yes | ```powershell # Change the output action plan file Invoke-SCuBA -ProductNames teams ` - -OutActionPlanFileName myplan ` + -OutActionPlanFileName myplan +``` + +## OutCsvFileName + +**OutCsvFileName** renames the file for the CSV version of the test results. This should only be the base file name, as the extension `.csv` will automatically be added. + +| Parameter | Value | +|-------------|----------------| +| Optional | Yes | +| Datatype | String | +| Default | `ScubaResults` | +| Config File | Yes | + + +```powershell +# Change the output CSV file +Invoke-SCuBA -ProductNames teams ` + -OutCsvFileName myresults ``` ## OutFolderName -**OutFolderName** is the first half of the name of the folder where the [report files](../execution/reports.md) will be created. The second half is a timedate stamp. The location of this folder is determined by the [OutPath](#outpath) parameter. +**OutFolderName** is the first half of the name of the folder where the [report files](../execution/reports.md) will be created. The second half is a timedate stamp. The location of this folder is determined by the [OutPath](#outpath) parameter. | Parameter | Value | |-------------|---------------------------| | Optional | Yes | | Datatype | String | | Default | `M365BaselineConformance` | -| Config File | Yes | +| Config File | Yes | ```powershell # Change the output folder @@ -256,14 +303,14 @@ Invoke-SCuBA -ProductNames teams ` ## OutJsonFileName -**OutJsonFileName** renames the uber output JSON file that is created after a ScubaGear run. This should only be the base file name, as the extension `.json` will automatically be added. +**OutJsonFileName** specifies the base file name of the uber output JSON file that is created after a ScubaGear run. This should only be the base file name; the report UUID as well as the extension, `.json`, will automatically be added. -| Parameter | Value | -|-------------|--------------------| -| Optional | Yes | -| Datatype | String | -| Default | `ScubaResults.json` | -| Config File | No | +| Parameter | Value | +|-------------|----------------| +| Optional | Yes | +| Datatype | String | +| Default | `ScubaResults` | +| Config File | Yes | > **Note**: This parameter does not work if the `-KeepIndividualJSON` parameter is present. @@ -272,24 +319,7 @@ Invoke-SCuBA -ProductNames teams ` Invoke-SCuBA -ProductNames teams ` -OutJsonFileName myresults ``` - -## OutCsvFileName - -**OutCsvFileName** renames the file for the CSV version of the test results. This should only be the base file name, as the extension `.csv` will automatically be added. - -| Parameter | Value | -|-------------|--------------------| -| Optional | Yes | -| Datatype | String | -| Default | `ScubaResults.csv` | -| Config File | Yes | - - -```powershell -# Change the output CSV file -Invoke-SCuBA -ProductNames teams ` - -OutCsvFileName myresults ` -``` +In the above example, the resulting JSON file name would be `myresults_21189b0e-f045-43ee-b9ba-653b32744e45.json` (substituting in the actual report UUID.) ## OutPath @@ -319,7 +349,7 @@ Invoke-SCuBA -ProductNames teams ` | Optional | Yes | | Datatype | String | | Default | `ProviderSettingsExport` | -| Config File | Yes | +| Config File | Yes | ```powershell # Change the provider settings file @@ -338,7 +368,7 @@ Invoke-SCuBA -ProductNames teams ` | Optional | Yes | | Datatype | String | | Default | `TestResults` | -| Config File | Yes | +| Config File | Yes | ```powershell # Change the rego file @@ -357,7 +387,7 @@ Invoke-SCuBA -ProductNames teams ` | Optional | Yes | | Datatype | String | | Default | `BaselineReports` | -| Config File | Yes | +| Config File | Yes | ```powershell # Change the HTML report file @@ -376,7 +406,7 @@ Invoke-SCuBA -ProductNames teams ` | Optional | Yes | | Datatype | List of Strings | | Default | ["aad", "defender", "exo", "sharepoint", "teams"] | -| Config File | Yes | +| Config File | Yes | The list of acceptable values are: @@ -405,7 +435,7 @@ Invoke-SCuBA -ProductNames teams, exo | Optional | Yes | | Datatype | Switch | | Default | n/a | -| Config File | No | +| Config File | No | ```powershell # Do not open the browser @@ -422,7 +452,7 @@ Invoke-SCuBA -ProductNames teams ` | Optional | Yes | | Datatype | Switch | | Default | n/a | -| Config File | No | +| Config File | No | ```powershell # Check the version diff --git a/docs/execution/reports.md b/docs/execution/reports.md index 1fc39b97da..700f9538c9 100644 --- a/docs/execution/reports.md +++ b/docs/execution/reports.md @@ -7,7 +7,7 @@ When ScubaGear runs, it creates a new time-stamped subdirectory wherein it will | `IndividualReports` | This directory contains the detailed reports for each product tested. | | `BaselineReports.html` | This HTML file is a summary of the detailed reports. By default, this file is automatically opened in a web browser after running ScubaGear. | | `ProvideSettingsExport.json` | This JSON file contains all of the information that ScubaGear extracted from the products. A highly-motivated admin might find this useful for understanding how ScubaGear arrived at its results. Only present if ScubaGear is run with the `KeepIndividualJson` flag; if run without the `KeepIndividualJSON` parameter, the contents of this file will be merged into the ScubaResults.json file. | -| `ScubaResults.json` | This JSON file encapsulates all ScubaGear output in a format that is automatically parsed by a downstream system. It contains metadata about the run and the tenant, summary counts of the test results, the test results, and the raw provider output. Not present if ScubaGear is run with the `KeepIndividualJSON` flag. | +| `ScubaResults_{UUID}.json` | This JSON file encapsulates all ScubaGear output in a format that is automatically parsed by a downstream system. It contains metadata about the run and the tenant, summary counts of the test results, the test results, and the raw provider output. Not present if ScubaGear is run with the `KeepIndividualJSON` flag. | | `ScubaResults.csv` | This CSV file contains the test results in a format that could be automatically parsed by a downstream system. Note that this CSV file only contains the results (i.e., the control ID, requirement string, etc.). It does not contain all data contained in the HTML or JSON versions of the output (i.e., the metadata, summary counts, or raw provider output) due to the limitations of CSV files. | | `ActionPlan.csv` | This CSV file contains the test results in a format that could be automatically parsed by a downstream system, filtered down to just failing "SHALL" controls. For each failing test, it includes fields where users can document reasons for failures and timelines for remediation, if they so choose. |