From 857cc1da7c0c5c1ce7457941e24070bb77380500 Mon Sep 17 00:00:00 2001 From: "Christopher Scott (CJ)" Date: Tue, 4 Aug 2026 15:46:23 -0400 Subject: [PATCH] Add Defender modules for service connections, logging, and setup operations - Introduced Connect-DefenderServices.ps1 for establishing Defender service connections and verifying tenant scope. - Added DefenderRunLog.ps1 for structured logging of Defender deployments, including JSON output and sensitive data protection. - Implemented Invoke-WithTransientRetry.ps1 to handle transient errors during API operations with retry logic. - Created Setup-DefenderForBusiness.ps1, Setup-DefenderForCloudApps.ps1, Setup-DefenderPreflight.ps1, Setup-MdeAdvanced.ps1, Setup-MdoEopBaseline.ps1 for various Defender setup operations with logging. - Developed Write-DefenderHtmlReport.ps1 to generate HTML reports from logging entries, enhancing visibility into deployment outcomes. --- .gitignore | 1 + CHANGELOG.md | 19 ++ Products/Defender/Config/DefenderConfig.psd1 | 80 ++++++ .../Defender/Deploy-DefenderBestPractice.ps1 | 238 ++++++++++++++++++ .../Modules/Connect-DefenderServices.ps1 | 148 +++++++++++ Products/Defender/Modules/DefenderRunLog.ps1 | 167 ++++++++++++ .../Modules/Invoke-WithTransientRetry.ps1 | 46 ++++ .../Modules/Setup-DefenderForBusiness.ps1 | 16 ++ .../Modules/Setup-DefenderForCloudApps.ps1 | 16 ++ .../Modules/Setup-DefenderPreflight.ps1 | 183 ++++++++++++++ .../Defender/Modules/Setup-MdeAdvanced.ps1 | 15 ++ .../Defender/Modules/Setup-MdoEopBaseline.ps1 | 17 ++ .../Modules/Write-DefenderHtmlReport.ps1 | 115 +++++++++ scripts/verify.ps1 | 102 ++++++++ 14 files changed, 1163 insertions(+) create mode 100644 Products/Defender/Config/DefenderConfig.psd1 create mode 100644 Products/Defender/Deploy-DefenderBestPractice.ps1 create mode 100644 Products/Defender/Modules/Connect-DefenderServices.ps1 create mode 100644 Products/Defender/Modules/DefenderRunLog.ps1 create mode 100644 Products/Defender/Modules/Invoke-WithTransientRetry.ps1 create mode 100644 Products/Defender/Modules/Setup-DefenderForBusiness.ps1 create mode 100644 Products/Defender/Modules/Setup-DefenderForCloudApps.ps1 create mode 100644 Products/Defender/Modules/Setup-DefenderPreflight.ps1 create mode 100644 Products/Defender/Modules/Setup-MdeAdvanced.ps1 create mode 100644 Products/Defender/Modules/Setup-MdoEopBaseline.ps1 create mode 100644 Products/Defender/Modules/Write-DefenderHtmlReport.ps1 diff --git a/.gitignore b/.gitignore index ff64cce..55735b7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ Gemfile.lock vendor/bundle/ # OS/editor machine artifacts +.impeccable/ .idea/ .vs/ *.code-workspace diff --git a/CHANGELOG.md b/CHANGELOG.md index 8baf9d7..43e43f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,25 @@ landed but not yet been tagged in a release appear under **Unreleased**. --- +## [Unreleased] + +### Added + +- **[Defender] Initial low-touch product foundation.** Adds the independent + PowerShell Defender product boundary, configuration contract, tenant/API + preflight, license capability classification, safety gates, and + Purview-aligned structured JSON/HTML evidence. The current implementation is + pre-release and does not yet claim production-ready tenant enforcement. + +### Security + +- **[Defender] Safe-by-default readiness checks.** Noninteractive runs require + certificate authentication parameters; audit readiness and emergency-access + safety remain explicit guided-only decisions when no stable unattended check is + claimed; secrets and operator UPNs are redacted from evidence. + +--- + ## [1.2.0] - 2026-07-01 > **Note — significant redesign (within Purview).** This reworks Purview sensitivity-label diff --git a/Products/Defender/Config/DefenderConfig.psd1 b/Products/Defender/Config/DefenderConfig.psd1 new file mode 100644 index 0000000..93d6052 --- /dev/null +++ b/Products/Defender/Config/DefenderConfig.psd1 @@ -0,0 +1,80 @@ +@{ + ProductName = 'Microsoft Defender Best Practice Toolkit' + ProductVersion = '0.1.0' + ManagedByTag = '[Managed by SMBTool Defender Toolkit]' + + # Keep policy names, application-internal keys, and risk classifications in + # configuration. Keys are not Microsoft recommendation identifiers. + BestPracticeItems = @( + @{ + Key = 'mdo-auto-forward' + Name = 'Block outbound auto-forwarding' + Module = 'Setup-MdoEopBaseline' + LicenseCapability = 'mdo' + Risk = 'High' + RequiresHighRiskGate = $true + } + @{ + Key = 'mdo-safe-attachments' + Name = 'Enable Safe Attachments for SPO, OneDrive, and Teams' + Module = 'Setup-MdoEopBaseline' + LicenseCapability = 'mdo' + Risk = 'Standard' + RequiresHighRiskGate = $false + } + @{ + Key = 'mde-asr-audit' + Name = 'Deploy ASR rules in audit mode' + Module = 'Setup-DefenderForBusiness' + LicenseCapability = 'mde' + Risk = 'Standard' + RequiresHighRiskGate = $false + } + ) + + # These identifiers are configuration candidates, not an assertion that a + # tenant has the capability. Verify current SKU/service-plan metadata + # during F6 implementation and update this data file as Microsoft changes + # catalog names. + LicenseCapabilities = @{ + mdo = @{ + SkuPartNumbers = @('ATP_ENTERPRISE', 'THREAT_INTELLIGENCE') + ServicePlanNames = @('ATP_ENTERPRISE', 'THREAT_INTELLIGENCE') + } + mde = @{ + SkuPartNumbers = @('WIN10_ENT_E5', 'MDE_SENSE') + ServicePlanNames = @('MDE_SENSE') + } + mdca = @{ + SkuPartNumbers = @('ADALLOM_S_STANDALONE') + ServicePlanNames = @('ADALLOM_S_STANDALONE') + } + } + + # Placeholder for Graph resource IDs, recipient IDs, policy templates, and + # other tenant-specific values. Do not place secrets or tokens here. + Api = @{ + GraphBaseUri = 'https://graph.microsoft.com/v1.0' + GraphScopes = @( + 'Organization.Read.All' + ) + RequiredCommands = @( + 'Connect-MgGraph' + 'Invoke-MgGraphRequest' + 'Get-MgContext' + ) + } + + # These checks are deliberately explicit where Microsoft does not expose a + # stable, least-privilege read API suitable for unattended verification. + Preflight = @{ + AuditReadinessDisposition = 'GuidedOnly' + EmergencyAccessDisposition = 'GuidedOnly' + } + + Report = @{ + OutputDirectory = '.\Reports' + JsonLogFileName = 'defender-run-log.json' + HtmlReportFileName = 'defender-run-report.html' + } +} diff --git a/Products/Defender/Deploy-DefenderBestPractice.ps1 b/Products/Defender/Deploy-DefenderBestPractice.ps1 new file mode 100644 index 0000000..9e441fe --- /dev/null +++ b/Products/Defender/Deploy-DefenderBestPractice.ps1 @@ -0,0 +1,238 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Scaffolding entry point for the Microsoft Defender Best Practice Toolkit. + +.DESCRIPTION + Applies verified Defender recommendations using Purview-compatible + orchestration semantics. The scaffold is intentionally read-only until + each BP item has a verified supported API, permission model, license gate, + readback projection, and rollback decision. +#> + +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [ValidatePattern('^[^@\s]+@[^@\s]+\.[^@\s]+$')] [string] $TenantAdminUpn, + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$')] [string] $TenantId, + [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$')] [string] $ClientId, + [ValidatePattern('^[0-9a-fA-F]{40}$')] [string] $CertificateThumbprint, + [ValidatePattern('^[^@\s]+\.[^@\s]+$')] [string] $DelegatedOrganization, + [string] $ConfigPath, + [switch] $NonInteractive, + [switch] $AutoInstallModules, + [switch] $NoLicenseAutoDetect, + [switch] $AdoptExisting, + [switch] $SkipPreflight, + [switch] $SkipMdoEop, + [switch] $SkipDefenderForBusiness, + [switch] $SkipMdeAdvanced, + [switch] $SkipDefenderForCloudApps, + [switch] $IncludeHighRisk, + [string] $CustomerApprovalId, + [string] $ApprovalArtifactPath, + [switch] $RollbackAcknowledged, + [switch] $EnableMailFlowImpactingChanges, + [switch] $EnableAutomatedRemediation, + [switch] $EnableAsrBlockMode, + [switch] $EnableMdcaEnforcement, + [switch] $CloudDiscoveryReviewed, + [string] $PilotGroupId +) + +$ErrorActionPreference = 'Stop' +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$moduleRoot = Join-Path $scriptRoot 'Modules' +$runId = [guid]::NewGuid() +$startTime = [datetime]::UtcNow +$jsonPath = $null +$htmlPath = $null + +. (Join-Path $moduleRoot 'DefenderRunLog.ps1') +. (Join-Path $moduleRoot 'Invoke-WithTransientRetry.ps1') +. (Join-Path $moduleRoot 'Write-DefenderHtmlReport.ps1') + +function Test-DefenderConfigContract { + [CmdletBinding()] + param([Parameter(Mandatory)] [hashtable] $Config) + + if (-not $Config.BestPracticeItems) { + throw 'Defender configuration must define at least one BestPracticeItems entry.' + } + $keys = @() + foreach ($item in @($Config.BestPracticeItems)) { + foreach ($property in @('Key', 'Name', 'Module', 'Risk')) { + if ([string]::IsNullOrWhiteSpace([string] $item[$property])) { + throw "BestPracticeItems entries must define '$property'." + } + } + if ([string]::IsNullOrWhiteSpace([string] $item.LicenseCapability) -or + -not $Config.LicenseCapabilities.ContainsKey([string] $item.LicenseCapability)) { + throw "BestPracticeItems entry '$($item.Key)' must reference a configured LicenseCapability." + } + if ($item.Key -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)+$') { + throw "BestPracticeItems key '$($item.Key)' must be lowercase kebab-case." + } + if ($keys -contains $item.Key) { + throw "Duplicate BestPracticeItems key '$($item.Key)'." + } + $keys += $item.Key + } +} + +try { + $certificateArguments = @($TenantId, $ClientId, $CertificateThumbprint) | + ForEach-Object { -not [string]::IsNullOrWhiteSpace($_) } + if (($certificateArguments | Where-Object { $_ }).Count -gt 0 -and + ($certificateArguments | Where-Object { -not $_ }).Count -gt 0) { + throw 'TenantId, ClientId, and CertificateThumbprint must be supplied together for certificate authentication.' + } + + if ($IncludeHighRisk -and [string]::IsNullOrWhiteSpace($CustomerApprovalId)) { + throw 'IncludeHighRisk requires CustomerApprovalId.' + } + if ($ApprovalArtifactPath) { + if (-not (Test-Path -LiteralPath $ApprovalArtifactPath -PathType Leaf)) { + throw "Approval artifact not found: $ApprovalArtifactPath" + } + if ((Get-Item -LiteralPath $ApprovalArtifactPath).Length -eq 0) { + throw "Approval artifact is empty: $ApprovalArtifactPath" + } + } + $categoryGates = @( + @{ Enabled = $EnableMailFlowImpactingChanges; Name = 'EnableMailFlowImpactingChanges' } + @{ Enabled = $EnableAutomatedRemediation; Name = 'EnableAutomatedRemediation' } + @{ Enabled = $EnableAsrBlockMode; Name = 'EnableAsrBlockMode' } + @{ Enabled = $EnableMdcaEnforcement; Name = 'EnableMdcaEnforcement' } + ) + foreach ($gate in $categoryGates) { + if ($gate.Enabled -and -not $IncludeHighRisk) { + throw "$($gate.Name) requires IncludeHighRisk." + } + } + if ($EnableMailFlowImpactingChanges -and -not $RollbackAcknowledged) { + throw 'EnableMailFlowImpactingChanges requires RollbackAcknowledged.' + } + + if (-not $ConfigPath) { + $ConfigPath = Join-Path $scriptRoot 'Config\DefenderConfig.psd1' + } + if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "Config file not found: $ConfigPath" + } + + $config = Import-PowerShellDataFile -Path $ConfigPath + Test-DefenderConfigContract -Config $config + $reportDirectory = Join-Path $scriptRoot $config.Report.OutputDirectory + $jsonPath = Join-Path $reportDirectory $config.Report.JsonLogFileName + $htmlPath = Join-Path $reportDirectory $config.Report.HtmlReportFileName + Initialize-DefenderRunLog -JsonPath $jsonPath -RunId $runId ` + -StartTime $startTime -ScriptVersion $config.ProductVersion ` + -TenantId $TenantId -TenantAdminUpn $TenantAdminUpn + + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'Run' -Status 'Started' -Detail 'Defender scaffold run started.' + if ($WhatIfPreference) { + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'WhatIf' -Status 'Info' ` + -Detail 'WhatIf preview enabled; child modules may not change tenant state.' + } + foreach ($item in @($config.BestPracticeItems)) { + Add-DefenderRunLogEntry -Module $item.Module -Action 'ConfiguredItem' ` + -BestPracticeKey $item.Key -Status 'Info' ` + -Detail "Configured recommendation: $($item.Name)." + } + + if ($IncludeHighRisk -and (-not $CustomerApprovalId -or -not $RollbackAcknowledged)) { + throw 'High-risk operations require CustomerApprovalId and RollbackAcknowledged.' + } + + $connectScript = Join-Path $moduleRoot 'Connect-DefenderServices.ps1' + $connectArgs = @{ + TenantAdminUpn = $TenantAdminUpn + TenantId = $TenantId + ClientId = $ClientId + CertificateThumbprint = $CertificateThumbprint + DelegatedOrganization = $DelegatedOrganization + AutoInstallModules = $AutoInstallModules + NonInteractive = $NonInteractive + ConnectGraph = $true + } + $connection = & $connectScript @connectArgs + + $commonContext = @{ + TenantAdminUpn = $TenantAdminUpn + TenantId = $TenantId + ClientId = $ClientId + CertificateThumbprint = $CertificateThumbprint + DelegatedOrganization = $DelegatedOrganization + CustomerApprovalId = $CustomerApprovalId + ApprovalArtifactPath = $ApprovalArtifactPath + IncludeHighRisk = [bool] $IncludeHighRisk + RollbackAcknowledged = [bool] $RollbackAcknowledged + WhatIf = [bool] $WhatIfPreference + NonInteractive = [bool] $NonInteractive + } + $tasks = @( + @{ Name = 'Setup-DefenderPreflight'; Script = 'Setup-DefenderPreflight.ps1'; Skip = $SkipPreflight; Args = @{ Config = $config; Context = $commonContext; TenantAdminUpn = $TenantAdminUpn; TenantId = $TenantId; NoLicenseAutoDetect = $NoLicenseAutoDetect } } + @{ Name = 'Setup-MdoEopBaseline'; Script = 'Setup-MdoEopBaseline.ps1'; Skip = $SkipMdoEop; Args = @{ Config = $config; Context = $commonContext; AdoptExisting = $AdoptExisting; IncludeHighRisk = $IncludeHighRisk; EnableMailFlowImpactingChanges = $EnableMailFlowImpactingChanges; RollbackAcknowledged = $RollbackAcknowledged } } + @{ Name = 'Setup-DefenderForBusiness'; Script = 'Setup-DefenderForBusiness.ps1'; Skip = $SkipDefenderForBusiness; Args = @{ Config = $config; Context = $commonContext; AdoptExisting = $AdoptExisting; PilotGroupId = $PilotGroupId; EnableAsrBlockMode = $EnableAsrBlockMode } } + @{ Name = 'Setup-MdeAdvanced'; Script = 'Setup-MdeAdvanced.ps1'; Skip = $SkipMdeAdvanced; Args = @{ Config = $config; Context = $commonContext; IncludeHighRisk = $IncludeHighRisk; EnableAutomatedRemediation = $EnableAutomatedRemediation } } + @{ Name = 'Setup-DefenderForCloudApps'; Script = 'Setup-DefenderForCloudApps.ps1'; Skip = $SkipDefenderForCloudApps; Args = @{ Config = $config; Context = $commonContext; IncludeHighRisk = $IncludeHighRisk; EnableMdcaEnforcement = $EnableMdcaEnforcement; CloudDiscoveryReviewed = $CloudDiscoveryReviewed } } + ) + + foreach ($task in $tasks) { + if ($task.Skip) { + Add-DefenderRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Skipped' -Detail 'Skip switch was supplied.' + continue + } + + $taskPath = Join-Path $moduleRoot $task.Script + if (-not (Test-Path -LiteralPath $taskPath -PathType Leaf)) { + Add-DefenderRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Failed' -Detail "Module script not found: $taskPath" + throw "Module script not found: $taskPath" + } + Add-DefenderRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Started' -Detail 'Module process started.' + $taskArgs = $task.Args + if ($WhatIfPreference) { + $taskArgs['WhatIf'] = $true + } + try { + & $taskPath @taskArgs + Add-DefenderRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Succeeded' -Detail 'Module process completed.' + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module $task.Name -Action 'Module' ` + -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw + } + } + + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'Run' -Status 'Succeeded' -Detail 'Defender scaffold run completed.' +} +catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + if ($global:DefenderRunLogPath) { + Add-DefenderRunLogEntry -Module 'Deploy-DefenderBestPractice' ` + -Action 'Run' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + } + throw +} +finally { + if ($global:DefenderRunLog) { + $endTime = [datetime]::UtcNow + Save-DefenderRunLogJson -EndTime $endTime + Write-DefenderHtmlReport -Path $htmlPath -Entries (Get-DefenderRunLog) ` + -RunId $runId -StartTime $startTime -EndTime $endTime ` + -TenantId $TenantId -TenantAdminUpn $TenantAdminUpn ` + -ScriptVersion $config.ProductVersion + Clear-DefenderRunLog + } +} diff --git a/Products/Defender/Modules/Connect-DefenderServices.ps1 b/Products/Defender/Modules/Connect-DefenderServices.ps1 new file mode 100644 index 0000000..96ec6f8 --- /dev/null +++ b/Products/Defender/Modules/Connect-DefenderServices.ps1 @@ -0,0 +1,148 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Establishes supported Defender service connections and verifies tenant scope. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [string] $TenantId, + [string] $ClientId, + [string] $CertificateThumbprint, + [string] $DelegatedOrganization, + [switch] $AutoInstallModules, + [switch] $NonInteractive, + [switch] $ConnectGraph +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +function Connect-DefenderGraph { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string[]] $Scopes, + [string] $TenantId, + [string] $ClientId, + [string] $CertificateThumbprint + ) + + try { + if ($ClientId -and $CertificateThumbprint -and $TenantId) { + # Certificate authentication requires a pre-installed certificate + # in the local certificate store; no secret is accepted here. + Connect-MgGraph -ClientId $ClientId -TenantId $TenantId ` + -CertificateThumbprint $CertificateThumbprint -NoWelcome ` + -ErrorAction Stop + } + else { + Connect-MgGraph -Scopes $Scopes -NoWelcome -ErrorAction Stop + } + + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'Connect-MgGraph' -Status 'Succeeded' ` + -Detail 'Microsoft Graph connection established.' + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'Connect-MgGraph' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw + } +} + +function Invoke-DefenderGraphRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('GET','POST','PATCH','PUT','DELETE')] + [string] $Method, + [Parameter(Mandatory)] [string] $Uri, + [hashtable] $Body + ) + + try { + $request = @{ + Method = $Method + Uri = $Uri + ErrorAction = 'Stop' + } + if ($Body) { + # Graph payloads are JSON objects whose property names and nesting + # must match the resource schema documented for the target API. + $request.Body = $Body | ConvertTo-Json -Depth 20 + $request.ContentType = 'application/json' + } + return Invoke-MgGraphRequest @request + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'Invoke-MgGraphRequest' -Status 'Failed' ` + -Target $Uri -HttpStatusCode $status -Detail $_.Exception.Message + throw + } +} + +function Get-DefenderTenantIdentity { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ExpectedDomain, + [Parameter(Mandatory)] [string] $TenantAdminUpn + ) + + try { + $response = Invoke-DefenderGraphRequest -Method 'GET' ` + -Uri 'https://graph.microsoft.com/v1.0/organization?$select=id,verifiedDomains' + $organization = @($response.value)[0] + $domains = @($organization.verifiedDomains | ForEach-Object { $_.name.ToLowerInvariant() }) + $expected = $ExpectedDomain.ToLowerInvariant() + if ($domains -notcontains $expected) { + throw "Connected tenant does not contain expected domain '$ExpectedDomain'." + } + + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'TenantIdentity' -Status 'Succeeded' ` + -Detail "Tenant identity verified for $ExpectedDomain." + return [pscustomobject]@{ + TenantId = $organization.id + ExpectedDomain = $ExpectedDomain + VerifiedDomains = $domains + TenantAdminUpn = $TenantAdminUpn + } + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Connect-DefenderServices' ` + -Action 'TenantIdentity' -Status 'Failed' ` + -HttpStatusCode $status -Detail $_.Exception.Message + throw + } +} + +if ($ConnectGraph) { + $scopes = @('Organization.Read.All') + Connect-DefenderGraph -Scopes $scopes -TenantId $TenantId ` + -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint + $expectedDomain = if ($DelegatedOrganization) { + $DelegatedOrganization + } + elseif ($TenantAdminUpn -match '@(?[^@]+)$') { + $Matches.domain + } + else { + throw 'Unable to derive an expected tenant domain from the supplied identity.' + } + $tenantIdentity = Get-DefenderTenantIdentity ` + -ExpectedDomain $expectedDomain -TenantAdminUpn $TenantAdminUpn +} + +[pscustomobject]@{ + TenantAdminUpn = $TenantAdminUpn + TenantId = $TenantId + DelegatedOrganization = $DelegatedOrganization + GraphConnected = [bool] $ConnectGraph + TenantIdentity = $tenantIdentity + GraphRequest = ${function:Invoke-DefenderGraphRequest} +} diff --git a/Products/Defender/Modules/DefenderRunLog.ps1 b/Products/Defender/Modules/DefenderRunLog.ps1 new file mode 100644 index 0000000..caa3b63 --- /dev/null +++ b/Products/Defender/Modules/DefenderRunLog.ps1 @@ -0,0 +1,167 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Structured console and local JSON logging for Defender deployments. + +.DESCRIPTION + Mirrors the Purview run-log contract while adding Defender-specific + best-practice keys and HTTP status evidence. Logging failures are surfaced + through verbose output but do not interrupt the deployment. +#> + +function Initialize-DefenderRunLog { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $JsonPath, + [guid] $RunId = [guid]::NewGuid(), + [datetime] $StartTime = [datetime]::UtcNow, + [string] $ScriptVersion = 'unknown', + [string] $TenantId, + [string] $TenantAdminUpn + ) + + $directory = Split-Path -Parent $JsonPath + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + $global:DefenderRunLog = [System.Collections.Generic.List[hashtable]]::new() + $global:DefenderRunLogPath = $JsonPath + $global:DefenderRunMetadata = @{ + RunId = $RunId + StartTime = $StartTime + ScriptVersion = $ScriptVersion + TenantId = $TenantId + TenantAdminUpn = Protect-DefenderIdentity -Value $TenantAdminUpn + } + Save-DefenderRunLogJson +} + +function Save-DefenderRunLogJson { + [CmdletBinding()] + param( + [string] $Path = $global:DefenderRunLogPath, + [datetime] $EndTime = [datetime]::UtcNow + ) + + try { + if (-not $Path) { return } + $entries = if ($global:DefenderRunLog) { @($global:DefenderRunLog) } else { @() } + $metadata = if ($global:DefenderRunMetadata) { $global:DefenderRunMetadata } else { @{} } + $payload = [ordered]@{ + runId = if ($metadata.RunId) { ([guid] $metadata.RunId).ToString() } else { $null } + scriptVersion = $metadata.ScriptVersion + startTime = if ($metadata.StartTime) { ([datetime] $metadata.StartTime).ToString('o') } else { $null } + endTime = $EndTime.ToString('o') + durationSec = if ($metadata.StartTime) { [int] (($EndTime - [datetime] $metadata.StartTime).TotalSeconds) } else { $null } + tenantId = $metadata.TenantId + tenantAdmin = $metadata.TenantAdminUpn + entryCount = $entries.Count + entries = @($entries | ForEach-Object { + [ordered]@{ + timestamp = $_.Timestamp + module = $_.Module + action = $_.Action + bestPracticeKey = $_.BestPracticeKey + disposition = $_.Disposition + target = $_.Target + status = $_.Status + detail = $_.Detail + httpStatusCode = $_.HttpStatusCode + attempt = $_.Attempt + elapsedMs = $_.ElapsedMs + } + }) + } + $payload | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Path -Encoding utf8 + } + catch { + Write-Verbose "Save-DefenderRunLogJson failed: $($_.Exception.Message)" + } +} + +function Add-DefenderRunLogEntry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Module, + [Parameter(Mandatory)] [string] $Action, + [ValidateSet('Started','Succeeded','Created','Updated','Adopted','Skipped','Failed','Retried','Info')] + [string] $Status = 'Info', + [string] $BestPracticeKey, + [ValidateSet('Applicable','AlreadyCompliant','WillChange','GuidedOnly','Skipped','Blocked')] + [string] $Disposition, + [string] $Target, + [string] $Detail, + [int] $HttpStatusCode, + [int] $Attempt, + [int] $ElapsedMs + ) + + try { + if (-not $global:DefenderRunLog) { + $global:DefenderRunLog = [System.Collections.Generic.List[hashtable]]::new() + } + + $entry = [ordered]@{ + Timestamp = [datetime]::UtcNow.ToString('o') + Module = $Module + Action = $Action + Status = $Status + BestPracticeKey = $BestPracticeKey + Disposition = $Disposition + Target = Protect-DefenderLogText -Value $Target + Detail = Protect-DefenderLogText -Value $Detail + HttpStatusCode = if ($PSBoundParameters.ContainsKey('HttpStatusCode')) { $HttpStatusCode } else { $null } + Attempt = if ($PSBoundParameters.ContainsKey('Attempt')) { $Attempt } else { $null } + ElapsedMs = if ($PSBoundParameters.ContainsKey('ElapsedMs')) { $ElapsedMs } else { $null } + } + + $global:DefenderRunLog.Add($entry) + Write-Verbose ("[{0}] {1}/{2}: {3}" -f $Status, $Module, $Action, $entry.Detail) + Write-Host ("[{0}] {1}: {2}" -f $Status, $Action, $entry.Detail) + Save-DefenderRunLogJson + } + catch { + Write-Verbose "Add-DefenderRunLogEntry failed: $($_.Exception.Message)" + } +} + +function Protect-DefenderLogText { + [CmdletBinding()] + param([AllowNull()] [object] $Value) + + if ($null -eq $Value) { return $null } + $text = [string] $Value + $text = $text -replace '(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+', '$1[REDACTED]' + $text = $text -replace '(?i)(access[_-]?token|refresh[_-]?token|client[_-]?secret|password)\s*[=:]\s*[^&\s,;]+', '$1=[REDACTED]' + return $text +} + +function Protect-DefenderIdentity { + [CmdletBinding()] + param([AllowNull()] [string] $Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + if ($Value -match '^([^@\s])[^@\s]*(@[^@\s]+)$') { + return "$($Matches[1])***$($Matches[2])" + } + return '[REDACTED]' +} + +function Get-DefenderRunLog { + [CmdletBinding()] + [OutputType([hashtable[]])] + param() + + if (-not $global:DefenderRunLog) { return @() } + return ,@($global:DefenderRunLog.ToArray()) +} + +function Clear-DefenderRunLog { + [CmdletBinding()] + param() + + Remove-Variable -Name 'DefenderRunLog' -Scope Global -ErrorAction SilentlyContinue + Remove-Variable -Name 'DefenderRunLogPath' -Scope Global -ErrorAction SilentlyContinue + Remove-Variable -Name 'DefenderRunMetadata' -Scope Global -ErrorAction SilentlyContinue +} diff --git a/Products/Defender/Modules/Invoke-WithTransientRetry.ps1 b/Products/Defender/Modules/Invoke-WithTransientRetry.ps1 new file mode 100644 index 0000000..3bc63af --- /dev/null +++ b/Products/Defender/Modules/Invoke-WithTransientRetry.ps1 @@ -0,0 +1,46 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Single retry boundary for transient Defender API and cmdlet operations. +#> + +function Invoke-WithTransientRetry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [scriptblock] $Action, + [Parameter(Mandatory)] [string] $Description, + [int] $MaxAttempts = 3 + ) + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + return & $Action + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + $transient = $status -in @(408, 429, 500, 502, 503, 504) + if (-not $transient -or $attempt -eq $MaxAttempts) { + throw + } + + Add-DefenderRunLogEntry -Module 'Invoke-WithTransientRetry' ` + -Action $Description -Status 'Retried' -Attempt $attempt ` + -HttpStatusCode $status -Detail $_.Exception.Message + Start-Sleep -Seconds ([math]::Min(30, [math]::Pow(2, $attempt))) + } + } +} + +function Get-DefenderHttpStatusCode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.ErrorRecord] $ErrorRecord + ) + + $response = $ErrorRecord.Exception.Response + if ($response -and $response.StatusCode) { + return [int] $response.StatusCode + } + return 0 +} diff --git a/Products/Defender/Modules/Setup-DefenderForBusiness.ps1 b/Products/Defender/Modules/Setup-DefenderForBusiness.ps1 new file mode 100644 index 0000000..9c6df0c --- /dev/null +++ b/Products/Defender/Modules/Setup-DefenderForBusiness.ps1 @@ -0,0 +1,16 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting, + [string] $PilotGroupId, + [switch] $EnableAsrBlockMode +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +Add-DefenderRunLogEntry -Module 'Setup-DefenderForBusiness' ` + -Action 'Module' -Status 'Info' ` + -Detail 'DfB and Intune policy operations are reserved for verified BP implementations.' diff --git a/Products/Defender/Modules/Setup-DefenderForCloudApps.ps1 b/Products/Defender/Modules/Setup-DefenderForCloudApps.ps1 new file mode 100644 index 0000000..f4cffaa --- /dev/null +++ b/Products/Defender/Modules/Setup-DefenderForCloudApps.ps1 @@ -0,0 +1,16 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $IncludeHighRisk, + [switch] $EnableMdcaEnforcement, + [switch] $CloudDiscoveryReviewed +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +Add-DefenderRunLogEntry -Module 'Setup-DefenderForCloudApps' ` + -Action 'Module' -Status 'Info' ` + -Detail 'MDCA operations are reserved for verified supported APIs.' diff --git a/Products/Defender/Modules/Setup-DefenderPreflight.ps1 b/Products/Defender/Modules/Setup-DefenderPreflight.ps1 new file mode 100644 index 0000000..f6bbd9c --- /dev/null +++ b/Products/Defender/Modules/Setup-DefenderPreflight.ps1 @@ -0,0 +1,183 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [Parameter(Mandatory)] [string] $TenantAdminUpn, + [string] $TenantId, + [switch] $NoLicenseAutoDetect +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +function Get-DefenderLicenseInventory { + [CmdletBinding()] + param() + + try { + $uri = 'https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuId,skuPartNumber,capabilityStatus,consumedUnits,prepaidUnits,servicePlans' + $response = Invoke-WithTransientRetry -Description 'Get subscribed SKUs' -Action { + Invoke-DefenderGraphRequest -Method 'GET' -Uri $uri + } + $records = @($response.value | ForEach-Object { + [pscustomobject]@{ + SkuId = $_.skuId + SkuPartNumber = [string] $_.skuPartNumber + CapabilityStatus = [string] $_.capabilityStatus + ConsumedUnits = [int] $_.consumedUnits + PrepaidUnitsEnabled = [int] $_.prepaidUnits.enabled + ServicePlanNames = @($_.servicePlans | ForEach-Object { [string] $_.servicePlanName }) + } + }) + if ($records.Count -eq 0) { + throw 'Microsoft Graph returned no subscribed SKU records; license readiness is unknown.' + } + return $records + } + catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'LicenseCheck' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw + } +} + +function Test-DefenderLicenseCapability { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $CapabilityDefinition, + [Parameter(Mandatory)] [object[]] $Inventory + ) + + $identified = @($Inventory | Where-Object { + ($_.SkuPartNumber -in @($CapabilityDefinition.SkuPartNumbers)) -or + (@($_.ServicePlanNames) | Where-Object { + $_ -in @($CapabilityDefinition.ServicePlanNames) + }) + }) + $skuMatches = @($identified | Where-Object { + $_.CapabilityStatus -eq 'Enabled' -and + ($_.ConsumedUnits -gt 0 -or $_.PrepaidUnitsEnabled -gt 0) + }) + return [pscustomobject]@{ + Available = $skuMatches.Count -gt 0 + Matches = $skuMatches + Identified = $identified + } +} + +function Test-DefenderApiCapability { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [hashtable] $ApiDefinition + ) + + $missing = @($ApiDefinition.RequiredCommands | Where-Object { + -not (Get-Command -Name $_ -ErrorAction SilentlyContinue) + }) + if ($missing.Count -gt 0) { + throw "Required Defender API commands are unavailable: $($missing -join ', ')" + } + + $graphContext = Get-MgContext + if (-not $graphContext) { + throw 'Microsoft Graph context is not available after connection.' + } + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'ApiCapability' -Status 'Succeeded' ` + -Detail "Graph context available. AuthenticationType=$($graphContext.AuthType); Account=$($graphContext.Account)." +} + +function Write-DefenderGuidedReadiness { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Action, + [Parameter(Mandatory)] [string] $Detail, + [ValidateSet('Applicable','AlreadyCompliant','WillChange','GuidedOnly','Skipped','Blocked')] + [string] $Disposition = 'GuidedOnly' + ) + + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action $Action -Status 'Info' -Disposition $Disposition ` + -Detail $Detail +} + +Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'Preflight' -Status 'Started' -Detail 'Read-only capability checks started.' + +try { + if (-not $Context) { + throw 'F6 requires orchestrator context for Graph authentication.' + } + $connectScript = Join-Path $PSScriptRoot 'Connect-DefenderServices.ps1' + if ($Context.NonInteractive -and + ([string]::IsNullOrWhiteSpace([string] $Context.ClientId) -or + [string]::IsNullOrWhiteSpace([string] $Context.CertificateThumbprint) -or + [string]::IsNullOrWhiteSpace([string] $Context.TenantId))) { + throw 'NonInteractive F6 requires certificate authentication parameters; delegated sign-in cannot prompt.' + } + $connectArgs = @{ + TenantAdminUpn = $TenantAdminUpn + TenantId = $Context.TenantId + ClientId = $Context.ClientId + CertificateThumbprint = $Context.CertificateThumbprint + DelegatedOrganization = $Context.DelegatedOrganization + NonInteractive = $true + ConnectGraph = $true + } + # Dot-sourcing retains the verified Graph request helper in this + # independently running module process. + . $connectScript @connectArgs | Out-Null + Test-DefenderApiCapability -ApiDefinition $Config.Api + + if (-not $NoLicenseAutoDetect) { + $inventory = Get-DefenderLicenseInventory + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'LicenseCheck' -Status 'Succeeded' ` + -Detail "Retrieved $(@($inventory).Count) subscribed SKU records." + + foreach ($item in @($Config.BestPracticeItems)) { + $capabilityKey = [string] $item.LicenseCapability + $definition = $Config.LicenseCapabilities[$capabilityKey] + if (-not $definition) { + Add-DefenderRunLogEntry -Module $item.Module ` + -Action 'Disposition' -BestPracticeKey $item.Key ` + -Status 'Skipped' -Disposition 'GuidedOnly' ` + -Detail 'No configured license capability mapping; treat as GuidedOnly.' + continue + } + $result = Test-DefenderLicenseCapability ` + -CapabilityDefinition $definition -Inventory $inventory + $disposition = if ($result.Available) { 'Applicable' } else { 'Skipped' } + Add-DefenderRunLogEntry -Module $item.Module ` + -Action 'Disposition' -BestPracticeKey $item.Key ` + -Status 'Info' -Disposition $disposition ` + -Detail "Disposition=$disposition; LicenseCapability=$capabilityKey; IdentifiedSubscriptions=$(@($result.Identified).Count); EnabledSubscriptions=$(@($result.Matches).Count)." + } + + } + else { + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'LicenseCheck' -Status 'Skipped' ` + -Detail 'Automatic license detection disabled by NoLicenseAutoDetect.' + } + Write-DefenderGuidedReadiness -Action 'AuditReadiness' ` + -Disposition $Config.Preflight.AuditReadinessDisposition ` + -Detail 'Audit readiness requires tenant-specific retention and audit policy review; no stable least-privilege unattended check is claimed.' + Write-DefenderGuidedReadiness -Action 'EmergencyAccessSafety' ` + -Disposition $Config.Preflight.EmergencyAccessDisposition ` + -Detail 'Emergency-access account coverage and break-glass exclusions require operator confirmation before enforcement.' + + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'Preflight' -Status 'Succeeded' ` + -Detail 'Preflight framework completed without state changes.' +} +catch { + $status = Get-DefenderHttpStatusCode -ErrorRecord $_ + Add-DefenderRunLogEntry -Module 'Setup-DefenderPreflight' ` + -Action 'Preflight' -Status 'Failed' -HttpStatusCode $status ` + -Detail $_.Exception.Message + throw +} diff --git a/Products/Defender/Modules/Setup-MdeAdvanced.ps1 b/Products/Defender/Modules/Setup-MdeAdvanced.ps1 new file mode 100644 index 0000000..e21d740 --- /dev/null +++ b/Products/Defender/Modules/Setup-MdeAdvanced.ps1 @@ -0,0 +1,15 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $IncludeHighRisk, + [switch] $EnableAutomatedRemediation +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +Add-DefenderRunLogEntry -Module 'Setup-MdeAdvanced' ` + -Action 'Module' -Status 'Info' ` + -Detail 'MDE advanced operations are reserved for verified supported APIs.' diff --git a/Products/Defender/Modules/Setup-MdoEopBaseline.ps1 b/Products/Defender/Modules/Setup-MdoEopBaseline.ps1 new file mode 100644 index 0000000..0782379 --- /dev/null +++ b/Products/Defender/Modules/Setup-MdoEopBaseline.ps1 @@ -0,0 +1,17 @@ +#requires -Version 7.0 +[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'None')] +param( + [Parameter(Mandatory)] [hashtable] $Config, + [hashtable] $Context, + [switch] $AdoptExisting, + [switch] $IncludeHighRisk, + [switch] $EnableMailFlowImpactingChanges, + [switch] $RollbackAcknowledged +) + +. (Join-Path $PSScriptRoot 'DefenderRunLog.ps1') +. (Join-Path $PSScriptRoot 'Invoke-WithTransientRetry.ps1') + +Add-DefenderRunLogEntry -Module 'Setup-MdoEopBaseline' ` + -Action 'Module' -Status 'Info' ` + -Detail 'MDO/EOP API operations are reserved for verified BP implementations.' diff --git a/Products/Defender/Modules/Write-DefenderHtmlReport.ps1 b/Products/Defender/Modules/Write-DefenderHtmlReport.ps1 new file mode 100644 index 0000000..342200d --- /dev/null +++ b/Products/Defender/Modules/Write-DefenderHtmlReport.ps1 @@ -0,0 +1,115 @@ +#requires -Version 7.0 +function Write-DefenderHtmlReport { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [hashtable[]] $Entries, + [guid] $RunId, + [datetime] $StartTime, + [datetime] $EndTime = [datetime]::UtcNow, + [string] $TenantId, + [string] $TenantAdminUpn, + [string] $ScriptVersion = 'unknown' + ) + + function Encode([object] $Value) { + if ($null -eq $Value) { return '' } + return [System.Net.WebUtility]::HtmlEncode([string] $Value) + } + + function Get-StatusClass([string] $Status) { + switch ($Status) { + 'Failed' { return 'failed' } + 'Skipped' { return 'skipped' } + 'Retried' { return 'retried' } + 'Succeeded' { return 'succeeded' } + default { return 'info' } + } + } + + function Protect-Identity([string] $Value) { + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + if ($Value -match '^([^@\s])[^@\s]*(@[^@\s]+)$') { + return "$($Matches[1])***$($Matches[2])" + } + return '[REDACTED]' + } + + try { + $failed = @($Entries | Where-Object Status -eq 'Failed').Count + $skipped = @($Entries | Where-Object Status -eq 'Skipped').Count + $succeeded = @($Entries | Where-Object Status -in @('Succeeded','Created','Updated','Adopted')).Count + $duration = if ($StartTime) { [int] (($EndTime - $StartTime).TotalSeconds) } else { $null } + $startText = if ($StartTime) { $StartTime.ToString('o') } else { $null } + + $rows = foreach ($entry in $Entries) { + $statusClass = Get-StatusClass $entry.Status + '{0}{1}{2}{3}{4}{5}{6}{7}{8}{9}' -f ` + (Encode $entry.Timestamp), (Encode $entry.Module), (Encode $entry.Action), + (Encode $entry.BestPracticeKey), (Encode $entry.Disposition), (Encode $entry.Target), + ('{1}' -f $statusClass, (Encode $entry.Status)), + (Encode $entry.Detail), (Encode $entry.HttpStatusCode), (Encode $entry.ElapsedMs) + } + + $html = @" + + + + +Defender deployment report + + +
+

Microsoft Defender Best Practice Deployment Report

Read-only and state-changing evidence for one deployment run.

+

Run metadata

+
Run ID
$(Encode $RunId)
+
Script version
$(Encode $ScriptVersion)
+
Tenant ID
$(Encode $TenantId)
+
Tenant administrator
$(Encode (Protect-Identity $TenantAdminUpn))
+
Started
$(Encode $startText)
+
Ended
$(Encode $EndTime.ToString('o'))
+
Duration (seconds)
$(Encode $duration)
+
+

Evidence summary

+
$($Entries.Count)Total entries
+
$succeededSuccessful/change entries
+
$skippedSkipped
+
$failedFailed
+
+

Decision-point evidence

+ +$($rows -join [Environment]::NewLine) +
TimestampModuleActionBP keyDispositionTargetStatusDetail / reasonHTTPElapsed ms
+
+"@ + Set-Content -LiteralPath $Path -Value $html -Encoding utf8 + } + catch { + throw "Unable to write Defender HTML report '$Path': $($_.Exception.Message)" + } +} diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 index f909783..f56f35b 100644 --- a/scripts/verify.ps1 +++ b/scripts/verify.ps1 @@ -27,6 +27,108 @@ foreach ($c in $psd1) { catch { Write-Host " INVALID: $($c.FullName) -> $($_.Exception.Message)"; $failures++ } } +Write-Host "== Defender scaffold contract ==" +$defenderRoot = Join-Path $prod 'Defender' +if (Test-Path -LiteralPath $defenderRoot) { + $requiredDefenderFiles = @( + 'Deploy-DefenderBestPractice.ps1', + 'Config\DefenderConfig.psd1', + 'Modules\Connect-DefenderServices.ps1', + 'Modules\DefenderRunLog.ps1', + 'Modules\Invoke-WithTransientRetry.ps1', + 'Modules\Write-DefenderHtmlReport.ps1', + 'Modules\Setup-DefenderPreflight.ps1', + 'Modules\Setup-MdoEopBaseline.ps1', + 'Modules\Setup-DefenderForBusiness.ps1', + 'Modules\Setup-MdeAdvanced.ps1', + 'Modules\Setup-DefenderForCloudApps.ps1' + ) + foreach ($relativePath in $requiredDefenderFiles) { + $requiredPath = Join-Path $defenderRoot $relativePath + if (Test-Path -LiteralPath $requiredPath) { + Write-Host " OK: Products/Defender/$relativePath" + } + else { + Write-Host " MISSING: Products/Defender/$relativePath" + $failures++ + } + } + try { + $defenderConfig = Import-PowerShellDataFile -LiteralPath (Join-Path $defenderRoot 'Config\DefenderConfig.psd1') + $keys = @($defenderConfig.BestPracticeItems | ForEach-Object { $_.Key }) + if ($keys.Count -eq 0 -or @($keys | Where-Object { $_ -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)+$' }).Count -gt 0) { + Write-Host " INVALID: Defender BestPracticeItems keys must use lowercase kebab-case." + $failures++ + } + elseif (@($keys | Sort-Object -Unique).Count -ne $keys.Count) { + Write-Host " INVALID: Defender BestPracticeItems keys must be unique." + $failures++ + } + else { + Write-Host " OK: Defender BestPracticeItems key contract" + } + $capabilityKeys = @($defenderConfig.LicenseCapabilities.Keys) + $missingMappings = @($defenderConfig.BestPracticeItems | Where-Object { + [string]::IsNullOrWhiteSpace([string] $_.LicenseCapability) -or + $_.LicenseCapability -notin $capabilityKeys + }) + if ($missingMappings.Count -gt 0) { + Write-Host " INVALID: Defender license capability mappings are incomplete." + $failures++ + } + else { + Write-Host " OK: Defender license capability mappings" + } + if (@($defenderConfig.Api.RequiredCommands).Count -eq 0) { + Write-Host " INVALID: Defender API command readiness contract is empty." + $failures++ + } + else { + Write-Host " OK: Defender API command readiness contract" + } + + $fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("defender-verify-" + [guid]::NewGuid().ToString('N')) + try { + New-Item -ItemType Directory -Path $fixtureRoot -Force | Out-Null + . (Join-Path $defenderRoot 'Modules\DefenderRunLog.ps1') + . (Join-Path $defenderRoot 'Modules\Write-DefenderHtmlReport.ps1') + $fixtureJson = Join-Path $fixtureRoot 'fixture.json' + $fixtureHtml = Join-Path $fixtureRoot 'fixture.html' + Initialize-DefenderRunLog -JsonPath $fixtureJson -ScriptVersion $defenderConfig.ProductVersion + Add-DefenderRunLogEntry -Module 'verify' -Action 'Fixture' -Status 'Info' ` + -BestPracticeKey 'mdo-safe-attachments' -Disposition 'Applicable' ` + -Detail 'access_token=fixture-secret' + Add-DefenderRunLogEntry -Module 'verify' -Action 'Fixture' -Status 'Skipped' ` + -Disposition 'GuidedOnly' -Detail 'guided-only fixture' + Save-DefenderRunLogJson + Write-DefenderHtmlReport -Path $fixtureHtml -Entries (Get-DefenderRunLog) + $fixturePayload = Get-Content -LiteralPath $fixtureJson -Raw | ConvertFrom-Json + $fixtureMarkup = Get-Content -LiteralPath $fixtureHtml -Raw + if ($fixturePayload.entryCount -ne 2 -or + $fixturePayload.entries[0].detail -match 'fixture-secret' -or + $fixtureMarkup -notmatch 'Disposition' -or + $fixtureMarkup -notmatch 'GuidedOnly') { + throw 'Defender logger/report fixture assertions failed.' + } + Write-Host " OK: Defender logger/report fixture" + } + catch { + Write-Host " INVALID: Defender logger/report fixture -> $($_.Exception.Message)" + $failures++ + } + finally { + Clear-DefenderRunLog + if (Test-Path -LiteralPath $fixtureRoot) { + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force + } + } + } + catch { + Write-Host " INVALID: Defender configuration contract -> $($_.Exception.Message)" + $failures++ + } +} + Pop-Location if ($failures -gt 0) { Write-Host "verify.ps1 FAILED with $failures error(s)." -ForegroundColor Red; exit 1 } Write-Host "verify.ps1 PASSED." -ForegroundColor Green