diff --git a/src/powershell/tests/Test-Assessment.25420.md b/src/powershell/tests/Test-Assessment.25420.md new file mode 100644 index 000000000..637836b48 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25420.md @@ -0,0 +1,11 @@ +Without extended retention for Global Secure Access audit and traffic logs, threat actors can operate beyond the default 30-day retention window knowing their activities will be automatically purged, eliminating security evidence before detection occurs. When security teams detect suspicious patterns or receive breach notifications from external sources, investigations often require historical analysis spanning weeks or months to identify initial compromise vectors, lateral movement patterns, and data exfiltration channels. The default 30-day retention period in Microsoft Entra proves insufficient for most enterprise investigations, particularly when threat actors employ low-and-slow techniques or maintain persistent access over extended periods. Organizations subject to regulatory frameworks including GDPR, HIPAA, PCI DSS, and SOX face compliance violations when unable to produce audit trails for mandated retention periods, typically ranging from 90 days to multiple years. Without accessible historical logs, security operations teams cannot establish baseline behavior patterns for users and devices, perform retrospective threat hunting when new indicators of compromise emerge, or correlate network access events with identity signals and endpoint telemetry across extended timeframes. Inadequate retention eliminates the organization's ability to conduct thorough root cause analysis during incident response, potentially allowing threat actors to maintain undetected persistence mechanisms while organizations focus only on visible symptoms rather than underlying compromise patterns. + +**Remediation action** +- Configure diagnostic settings with Log Analytics workspace for extended retention (90-730 days) with query capabilities. + - [Integrate Microsoft Entra logs with Azure Monitor logs](https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/DiagnosticSettings) +- Configure Log Analytics workspace retention to meet organizational security and compliance requirements (minimum 90 days recommended) + - [Manage data retention in a Log Analytics workspace](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/data-retention-archive) +- Enable table-level retention for specific Global Secure Access tables to extend beyond workspace defaults + - [Configure table-level retention](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/data-retention-archive#configure-table-level-retention) + +%TestResult% diff --git a/src/powershell/tests/Test-Assessment.25420.ps1 b/src/powershell/tests/Test-Assessment.25420.ps1 new file mode 100644 index 000000000..8856d66e7 --- /dev/null +++ b/src/powershell/tests/Test-Assessment.25420.ps1 @@ -0,0 +1,373 @@ +<# +.SYNOPSIS + Validates that network access logs are retained for security analysis + and compliance requirements. + +.DESCRIPTION + This test evaluates diagnostic settings for Microsoft Entra to ensure + Global Secure Access log categories are enabled with appropriate + retention periods (minimum 90 days) in Log Analytics workspaces. + +.NOTES + Test ID: 25420 + Category: Global Secure Access + Required APIs: Azure Management REST API (diagnostic settings, workspaces) +#> + +function Test-Assessment-25420 { + + [ZtTest( + Category = 'Global Secure Access', + ImplementationCost = 'Low', + MinimumLicense = 'AAD_PREMIUM, Entra_Premium_Internet_Access, Entra_Premium_Private_Access', + Pillar = 'Network', + RiskLevel = 'High', + SfiPillar = 'Monitor and detect cyberthreats', + TenantType = 'Workforce', + TestId = 25420, + Title = 'Network access logs are retained for security analysis and compliance requirements', + UserImpact = 'Low' + )] + [CmdletBinding()] + param() + + # Minimum retention period in days for compliance + $MINIMUM_RETENTION_DAYS = 90 + + # Required Global Secure Access log categories + $REQUIRED_LOG_CATEGORIES = @( + 'AuditLogs', # Audit logs for configuration changes to Global Secure Access + 'NetworkAccessTrafficLogs', # Network traffic logs + 'EnrichedOffice365AuditLogs', # Enriched Office 365 audit logs with network context + 'RemoteNetworkHealthLogs', # Remote network health logs + 'NetworkAccessAlerts', # Security alerts + 'NetworkAccessConnectionEvents', # Connection event logs + 'NetworkAccessGenerativeAIInsights' # AI-generated insights and security recommendations + ) + + #region Data Collection + + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose + $activity = 'Evaluating network access log retention configuration' + Write-ZtProgress -Activity $activity -Status 'Checking Azure connection' + + # Check for Azure authentication + try { + $accessToken = Get-AzAccessToken -AsSecureString -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + } + catch [Management.Automation.CommandNotFoundException] { + Write-PSFMessage $_.Exception.Message -Tag Test -Level Error + } + + if (!$accessToken) { + Write-PSFMessage "Azure authentication token not found." -Level Warning + Add-ZtTestResultDetail -SkippedBecause NotConnectedAzure + return + } + + Write-ZtProgress -Activity $activity -Status 'Querying diagnostic settings' + + # Query Q1: Retrieve diagnostic settings for Microsoft Entra + $diagnosticSettings = $null + try { + $uri = 'https://management.azure.com/providers/microsoft.aadiam/diagnosticsettings?api-version=2017-04-01-preview' + $response = Invoke-AzRestMethod -Uri $uri -Method GET -ErrorAction Stop + $diagnosticSettings = ($response.Content | ConvertFrom-Json).value + } + catch { + Write-PSFMessage "Error querying diagnostic settings: $_" -Level Error + } + + # Query Q2: Retrieve Log Analytics workspace retention settings for each configured workspace + $workspaceDetails = @{} + + if ($null -ne $diagnosticSettings -and $diagnosticSettings.Count -gt 0) { + + Write-ZtProgress -Activity $activity -Status 'Checking workspace retention settings' + + $workspaceIds = $diagnosticSettings | + Where-Object { $_.properties.workspaceId } | + Select-Object -ExpandProperty properties | + Select-Object -ExpandProperty workspaceId -Unique + + foreach ($workspaceId in $workspaceIds) { + try { + $workspaceUri = "https://management.azure.com$workspaceId`?api-version=2023-09-01" + $workspaceResponse = Invoke-AzRestMethod -Uri $workspaceUri -Method GET -ErrorAction Stop + $workspaceDetails[$workspaceId] = ($workspaceResponse.Content | ConvertFrom-Json) + } + catch { + Write-PSFMessage "Error querying workspace $workspaceId : $_" -Level Warning + $workspaceDetails[$workspaceId] = $null + } + } + } + + #endregion Data Collection + + #region Assessment Logic + + # Initialize evaluation containers + $passed = $false + $testResultMarkdown = '' + $diagResults = @() + $logCategoryStatus = @{} + $hasAdequateRetention = $false + $hasAllRequiredCategories = $false + $passingSettingFound = $false + + # Step 1: Check if any diagnostic settings exist + if ($null -eq $diagnosticSettings -or $diagnosticSettings.Count -eq 0) { + + $passed = $false + $testResultMarkdown = "❌ No diagnostic settings are configured for Microsoft Entra. Global Secure Access logs are retained for only 30 days (default in-portal retention) which is insufficient for security investigations.`n`n%TestResult%" + + } + else { + + Write-ZtProgress -Activity $activity -Status 'Evaluating log categories and retention' + + # Initialize log category tracking + foreach ($category in $REQUIRED_LOG_CATEGORIES) { + $logCategoryStatus[$category] = @{ + Enabled = $false + DestinationType = 'None' + RetentionDays = $null + MeetsMinimum = $false + } + } + + foreach ($setting in $diagnosticSettings) { + + $workspaceId = $setting.properties.workspaceId + $storageAccountId = $setting.properties.storageAccountId + $logs = $setting.properties.logs + + # Step 2: Determine destination type (Workspace, Storage, or both) + $destinationType = 'None' + if ($workspaceId -and $storageAccountId) { + $destinationType = 'Workspace & Storage' + } + elseif ($workspaceId) { + $destinationType = 'Workspace' + } + elseif ($storageAccountId) { + $destinationType = 'Storage' + } + + # Step 3: Get workspace retention if applicable + $retentionDays = $null + $workspaceName = $null + if ($workspaceId -and $workspaceDetails.ContainsKey($workspaceId) -and $workspaceDetails[$workspaceId]) { + $workspace = $workspaceDetails[$workspaceId] + $retentionDays = $workspace.properties.retentionInDays + $workspaceName = $workspace.name + } + + # Step 4: Evaluate if this setting meets minimum retention criteria + $meetsMinimum = $false + if ($retentionDays -ge $MINIMUM_RETENTION_DAYS) { + $meetsMinimum = $true + } + elseif ($storageAccountId) { + # Storage account retention cannot be queried via this API; assume meets minimum, flag for manual review + $meetsMinimum = $true + } + + $enabledCategories = @() + + # Step 5: Process log categories for this setting and track best configuration + foreach ($log in $logs) { + $categoryName = $log.category + $isEnabled = $log.enabled + + if ($categoryName -in $REQUIRED_LOG_CATEGORIES -and $isEnabled) { + $enabledCategories += $categoryName + + # Update global category status if this is a better configuration + # Prioritize: 1) configs that meet minimum, 2) higher retention days + $currentStatus = $logCategoryStatus[$categoryName] + $shouldUpdate = $false + + if (-not $currentStatus.Enabled) { + $shouldUpdate = $true + } + elseif ($meetsMinimum -and -not $currentStatus.MeetsMinimum) { + # New config meets minimum but current doesn't - always prefer + $shouldUpdate = $true + } + elseif ($meetsMinimum -eq $currentStatus.MeetsMinimum) { + # Both meet or both don't meet minimum - compare retention days + if ($retentionDays -and (-not $currentStatus.RetentionDays -or $retentionDays -gt $currentStatus.RetentionDays)) { + $shouldUpdate = $true + } + } + + if ($shouldUpdate) { + $logCategoryStatus[$categoryName] = @{ + Enabled = $true + DestinationType = $destinationType + RetentionDays = $retentionDays + MeetsMinimum = $meetsMinimum + } + } + } + } + + # Step 6: Check if this setting has all required categories AND meets retention criteria + $settingHasAllCategories = ($enabledCategories.Count -eq $REQUIRED_LOG_CATEGORIES.Count) + if ($settingHasAllCategories -and $meetsMinimum) { + $passingSettingFound = $true + } + + # Determine per-setting status + # Storage-only settings require manual review since retention policies cannot be queried via this API + $settingStatus = if ($storageAccountId -and -not $workspaceId) { + 'Manual review' + } elseif ($settingHasAllCategories -and $meetsMinimum) { + 'Adequate' + } else { + 'Insufficient' + } + + $diagResults += [PSCustomObject]@{ + SettingName = $setting.name + WorkspaceId = $workspaceId + WorkspaceName = $workspaceName + StorageAccountId = $storageAccountId + DestinationType = $destinationType + RetentionDays = $retentionDays + MeetsMinimum = $meetsMinimum + EnabledCategories = $enabledCategories + Status = $settingStatus + } + } + + # Step 7: Verify all required Global Secure Access log categories are enabled (across all settings) + $enabledCategoryCount = ($logCategoryStatus.GetEnumerator() | Where-Object { $_.Value.Enabled }).Count + $hasAllRequiredCategories = ($enabledCategoryCount -eq $REQUIRED_LOG_CATEGORIES.Count) + + # Step 8: Check if any category meets minimum retention (used for summary reporting) + # Note: Pass/fail is determined by $passingSettingFound which requires ALL categories in ONE setting + $hasAdequateRetention = ($logCategoryStatus.GetEnumerator() | Where-Object { $_.Value.MeetsMinimum }).Count -gt 0 + + # Step 9: Determine overall test result + if ($passingSettingFound) { + + $passed = $true + $testResultMarkdown = "✅ Global Secure Access logs are retained for at least $MINIMUM_RETENTION_DAYS days, supporting security analysis and compliance requirements`n`n%TestResult%" + + } + else { + + $passed = $false + $testResultMarkdown = "❌ Global Secure Access logs are not retained for adequate duration to support security investigations and compliance obligations`n`n%TestResult%" + + } + } + + #endregion Assessment Logic + + #region Report Generation + + # Calculate summary metrics + $settingsWithLongTermDest = ($diagResults | Where-Object { $_.WorkspaceId -or $_.StorageAccountId }).Count + $workspaceRetentions = $diagResults | Where-Object { $_.RetentionDays } | Select-Object -ExpandProperty RetentionDays + $avgRetention = if ($workspaceRetentions.Count -gt 0) { + [math]::Round(($workspaceRetentions | Measure-Object -Average).Average, 0) # Round to whole days for compliance reporting + } else { $null } + $minRetention = if ($workspaceRetentions.Count -gt 0) { + ($workspaceRetentions | Measure-Object -Minimum).Minimum + } else { $null } + + $mdInfo = "`n## [Diagnostic settings configuration](https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/DiagnosticSettings)`n`n" + + # Log Retention Status table + if ($logCategoryStatus.Count -gt 0) { + $tableRows = "" + $formatTemplate = @' +### Log retention status + +| Log category | Enabled | Destination type | Retention period | Meets minimum (90 days) | +| :--- | :--- | :--- | :--- | :--- | +{0} + +'@ + foreach ($category in $REQUIRED_LOG_CATEGORIES) { + $status = $logCategoryStatus[$category] + $enabledText = if ($status.Enabled) { 'Yes' } else { 'No' } + $destType = if ($status.Enabled) { $status.DestinationType } else { 'None' } + + # For storage-only destinations, retention cannot be queried via API + $isStorageOnly = $status.DestinationType -eq 'Storage' + $retention = if ($status.RetentionDays) { + "$($status.RetentionDays) days" + } elseif ($isStorageOnly) { + 'Manual verification required' + } else { + 'Not configured' + } + $meetsMinText = if ($status.MeetsMinimum) { 'Yes' } else { 'No' } + + $tableRows += "| $category | $enabledText | $destType | $retention | $meetsMinText |`n" + } + $mdInfo += $formatTemplate -f $tableRows + } + + # Destination Details table + if ($diagResults.Count -gt 0) { + $tableRows = "" + $formatTemplate = @' +### Destination details + +| Destination type | Resource name | Default retention | Status | +| :--- | :--- | :--- | :--- | +{0} + +'@ + foreach ($diag in $diagResults) { + # Add hyperlink to destination type based on type + $destType = if ($diag.WorkspaceName -and $diag.StorageAccountId) { + "[Log Analytics workspace](https://portal.azure.com/?feature.msaljs=true#browse/Microsoft.OperationalInsights%2Fworkspaces) & [Storage Account](https://portal.azure.com/?feature.msaljs=true#view/Microsoft_Azure_StorageHub/StorageHub.MenuView/~/StorageAccountsBrowse)" + } + elseif ($diag.WorkspaceName) { + "[Log Analytics workspace](https://portal.azure.com/?feature.msaljs=true#browse/Microsoft.OperationalInsights%2Fworkspaces)" + } + elseif ($diag.StorageAccountId) { + "[Storage account](https://portal.azure.com/?feature.msaljs=true#view/Microsoft_Azure_StorageHub/StorageHub.MenuView/~/StorageAccountsBrowse)" + } + else { 'None' } + $resourceName = if ($diag.WorkspaceName) { $diag.WorkspaceName } + elseif ($diag.StorageAccountId) { $diag.StorageAccountId.Split('/')[-1] } + else { 'N/A' } + $retention = if ($diag.RetentionDays) { "$($diag.RetentionDays) days" } + elseif ($diag.StorageAccountId) { 'Manual verification required' } + else { 'N/A' } + $tableRows += "| $destType | $resourceName | $retention | $($diag.Status) |`n" + } + $mdInfo += $formatTemplate -f $tableRows + } + + # Summary table (per spec format) + $mdInfo += "### Summary`n`n" + $mdInfo += "| Metric | Value |`n| :--- | :--- |`n" + $mdInfo += "| Total diagnostic settings | $($diagnosticSettings.Count) |`n" + $mdInfo += "| Settings with long-term destination | $settingsWithLongTermDest |`n" + $mdInfo += "| Average retention period | $(if ($avgRetention) { "$avgRetention days" } else { 'N/A' }) |`n" + $mdInfo += "| Minimum retention found | $(if ($minRetention) { "$minRetention days" } else { 'N/A' }) |`n" + $mdInfo += "| Meets 90-day minimum | $(if ($hasAdequateRetention) { 'Yes' } else { 'No' }) |`n" + + # Replace the placeholder with detailed information + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo + + #endregion Report Generation + + $params = @{ + TestId = '25420' + Title = 'Network access logs are retained for security analysis and compliance requirements' + Status = $passed + Result = $testResultMarkdown + } + + Add-ZtTestResultDetail @params +}