diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4563121..d86cddf 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,3 +18,18 @@ ### Testing and verification Describe how this change was tested. + +### Security and compliance + +- [ ] This change does not weaken security controls or introduce unsafe behaviors. +- [ ] If the change modifies state-changing behavior, `ShouldProcess` support is included where applicable. + +### CI checks (automatic) + +- [ ] PSScriptAnalyzer: no warnings/errors. +- [ ] Pester tests: all tests pass. +- [ ] Any new public functions have tests and documentation updated. + +### Reviewer notes + +- Provide any special verification steps, permissions needed, or rationale for approving. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b46ac0..b4cc206 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,35 +2,146 @@ name: CI on: push: - branches: [ main ] + branches: + - main + - ci/** + - feature/** + - bugfix/** pull_request: - branches: [ main ] + workflow_dispatch: + +permissions: + contents: read jobs: - build-and-test: + test-and-analyze: + name: PSSA + Pester + Coverage runs-on: windows-latest + defaults: + run: + shell: pwsh + steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PowerShell - uses: PowerShell/PowerShell@v3 - - - name: Install Pester v5 and PSScriptAnalyzer - shell: pwsh - run: | - Install-Module -Name Pester -Force -Scope CurrentUser -RequiredVersion 5.* -AllowClobber - Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -AllowClobber - - - name: Run PSScriptAnalyzer - shell: pwsh - run: | - Import-Module PSScriptAnalyzer - Invoke-ScriptAnalyzer -Path . -SettingsPath .\PSScriptAnalyzerSettings.psd1 -Recurse -Severity Warning,Error - - - name: Run Tests (Pester v5) - shell: pwsh - run: | - Import-Module Pester - Invoke-Pester -Script .\tests\Netclean.Tests.ps1 -PassThru + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Show PowerShell environment + run: | + $PSVersionTable + Get-Module Pester -ListAvailable | + Sort-Object Version -Descending | + Select-Object -First 5 Name, Version, Path + + - name: Install PowerShell test and analysis tools + run: | + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -Scope CurrentUser -RequiredVersion 6.0.1 -Force -SkipPublisherCheck -ErrorAction Stop + Install-Module PSScriptAnalyzer -Scope CurrentUser -RequiredVersion 1.25.0 -Force -ErrorAction Stop + Import-Module Pester -RequiredVersion 6.0.1 -Force -ErrorAction Stop + if ((Get-Module Pester).Version -ne [version]'6.0.1') { + throw 'CI requires Pester 6.0.1.' + } + Get-Module PSScriptAnalyzer -ListAvailable | + Sort-Object Version -Descending | + Select-Object -First 1 Name, Version, Path + + - name: Validate module manifest + run: | + Test-ModuleManifest .\NetClean.psd1 | Out-Null + Write-Host 'Module manifest validated successfully.' + + - name: Run PSScriptAnalyzer + run: | + .\tests\Invoke-NetCleanAnalyzer.ps1 | Format-List + + - name: Run Pester with coverage + run: | + Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force + .\tests\Run-NetClean-Coverage.ps1 -MinimumCoverage 94 + + - name: Upload test and coverage artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: netclean-test-results + path: | + tests/TestResults/** + if-no-files-found: warn + retention-days: 14 + + windows-powershell-compatibility: + name: Windows PowerShell 5.1 compatibility + runs-on: windows-latest + + defaults: + run: + shell: powershell + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Validate Windows PowerShell environment + run: | + $PSVersionTable + if ($PSVersionTable.PSVersion -lt [version]'5.1' -or $PSVersionTable.PSEdition -ne 'Desktop') { + throw 'This job requires Windows PowerShell 5.1.' + } + + - name: Install compatibility test tools + run: | + [Net.ServicePointManager]::SecurityProtocol = + [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -Scope CurrentUser -RequiredVersion 6.0.1 -Force -SkipPublisherCheck -ErrorAction Stop + Install-Module PSScriptAnalyzer -Scope CurrentUser -RequiredVersion 1.25.0 -Force -ErrorAction Stop + Import-Module Pester -RequiredVersion 6.0.1 -Force -ErrorAction Stop + Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Force -ErrorAction Stop + + - name: Validate module manifest + run: | + Test-ModuleManifest .\NetClean.psd1 | Out-Null + Write-Output 'Module manifest validated successfully under Windows PowerShell 5.1.' + + - name: Run complete Pester suite + run: | + $config = New-PesterConfiguration + $config.Run.Path = (Resolve-Path .\tests).Path + $config.Run.PassThru = $true + $config.Run.Throw = $true + $config.Output.Verbosity = 'Normal' + $result = Invoke-Pester -Configuration $config + if ($result.FailedCount -gt 0) { + throw "Pester reported $($result.FailedCount) failed test(s) under Windows PowerShell 5.1." + } + + comment-coverage: + name: Comment coverage on pull request + if: github.event_name == 'pull_request' + needs: test-and-analyze + runs-on: windows-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Download test and coverage artifacts + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: netclean-test-results + path: tests/TestResults + + - name: Comment PR with coverage summary + uses: madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920 # v1.8.0 + with: + paths: | + ${{ github.workspace }}/tests/TestResults/coverage.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 94 + min-coverage-changed-files: 95 + title: NetClean Coverage Report + update-comment: true diff --git a/.github/workflows/powershell-check.yml b/.github/workflows/powershell-check.yml deleted file mode 100644 index 3b86425..0000000 --- a/.github/workflows/powershell-check.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: PowerShell checks - -on: [push, pull_request] - -jobs: - ps-lint: - runs-on: windows-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - name: Install PSScriptAnalyzer and Pester (non-interactive) - shell: pwsh - run: | - try { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue } catch { } - Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -AllowClobber -ErrorAction Stop - Install-Module -Name Pester -Force -Scope CurrentUser -AllowClobber -ErrorAction Stop - - - name: Run PSScriptAnalyzer (fail on any findings) - shell: pwsh - run: | - $settings = Join-Path $PWD 'PSScriptAnalyzerSettings.psd1' - if (-not (Test-Path $settings)) { Write-Host 'No PSScriptAnalyzer settings found; using defaults.' } - # Exclude .ai folder and any large artifacts - $files = Get-ChildItem -Path . -Include *.ps1 -Recurse -File -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -notmatch '\.ai\\' } | - Select-Object -ExpandProperty FullName - if (-not $files) { Write-Host 'No PowerShell files found for analysis.'; exit 0 } - - if (Test-Path $settings) { $results = Invoke-ScriptAnalyzer -Path $files -SettingsPath $settings -Recurse -ErrorAction SilentlyContinue } - else { $results = Invoke-ScriptAnalyzer -Path $files -Recurse -ErrorAction SilentlyContinue } - - if ($results) { - $results | ForEach-Object { Write-Host "[$($_.Severity)] $($_.ScriptName):$($_.Line) $($_.RuleName) - $($_.Message)" } - Write-Error "PSScriptAnalyzer found $($results.Count) issue(s). Failing CI." - exit 1 - } else { Write-Host 'No findings from PSScriptAnalyzer.' } - - - name: Run Pester tests - shell: pwsh - run: | - $testFolder = Join-Path $PWD 'tests' - if (-not (Test-Path $testFolder)) { Write-Host 'No tests directory found.'; exit 0 } - $res = Invoke-Pester -Script $testFolder -PassThru - if ($res.FailedCount -gt 0) { Write-Error "Pester tests failed: $($res.FailedCount) failing"; exit 1 } else { Write-Host 'Pester tests passed.' } - - - name: Report summary - shell: pwsh - run: | - Write-Host 'Analyzer + tests completed. Any findings or failing tests fail CI.' diff --git a/.gitignore b/.gitignore index 8447ebf..13d887f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ # Ignore common AI/chat artifacts and local assistant data # Adjust these patterns to match your editor/assistant storage locations +# Ignore common AI/chat artifacts and local assistant data +# Adjust these patterns to match your editor/assistant storage locations +.ai/ +**/.ai/ + # Copilot / assistant caches .copilot/ .copilot/* @@ -22,3 +27,7 @@ ai_outputs/ # Node/npm lockfiles or others you might not want to commit (optional) # package-lock.json + +# Generated test and coverage output +tests/TestResults/ +coverage*.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a09055..cdbf010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,45 @@ All notable changes to this project should be documented in this file. ## Unreleased -- Add detailed `README.md` with usage, backups, safety notes, and examples. -- Add example wrapper scripts in `examples/` (`run-netclean.ps1`, `restore-wifi-profiles.ps1`, `register-scheduledtask.ps1`). -- Add GitHub Actions workflow for linting and safe `-DryRun` execution. -- Add `.gitignore` to exclude AI/chat artifacts. -- Add `CONTRIBUTING.md` and PR template. +- Add conference-preparation adapter reset and verification: IPv4 DHCP, Quad9 IPv4/IPv6 DNS, DNS over HTTPS without plaintext fallback where supported, IPv6 retained with IPv4 preferred, protected/managed-adapter boundaries, private adapter backups, and a machine-readable verification ledger. +- Add conditional post-cleanup evidence for Wi-Fi disconnection and DNS/ARP caches; cache state is evaluated only when no physical wired LAN is connected. +- Upgrade the test toolchain to Pester 6.0.1, make test files discovery-isolated, and fail coverage runs on discovery or container errors. +- Add isolated registry System tests that exercise real Registry-provider detection, native backup, protected-boundary cleanup, and independent verification against synthetic Pester `TestRegistry:` data. +- Fix native registry export argument quoting for keys and output paths containing spaces. +- Raise the overall command-coverage ratchet from 68% to 69% after the isolated System tests increased Pester 6's measured production coverage from 68.43% to 69.42%; retain the 95% changed-file and long-term overall targets. +- Expand fail-closed cleanup verification coverage for registry and user artifacts, event logs, DNS and ARP caches, and physical-adapter discovery; preserve fail-soft registry discovery while allowing verification callers to surface query failures. +- Treat physical adapters without an interface index as having no queryable ARP entries instead of failing under strict mode. +- Raise the overall command-coverage ratchet from 69% to 71% after the verification failure matrix increased measured production coverage from 69.42% to 71.29%, and align Pester's displayed target with the enforced runner and pull-request gates. +- Add source-matrix and correlation tests for Security Center products, services, drivers, uninstall records, adapters, PnP devices, service registry data, file metadata/signatures, and protected service/adapter registry boundaries. +- Allow empty metadata paths to reach the existing fail-soft guard instead of failing during parameter binding. +- Raise the overall command-coverage ratchet from 71% to 80% after protection-discovery tests increased measured production coverage from 71.29% to 80.17%. +- Add functional contracts for menus, confirmation, summaries, previews, safe Preview routing, live backup-directory creation, and mocked dry/live restart or shutdown actions. +- Make launcher-owned settings explicitly script-scoped, allow empty lists to reach their existing summary output, and correct invalid-input feedback that passed an unsupported parameter to `Write-Verbose`. +- Raise the overall command-coverage ratchet from 80% to 85% after launcher and user-facing tests increased measured production coverage from 80.17% to 85.56%. +- Raise the overall command-coverage ratchet from 85% to 90% after failure-path, strict-mode, and active-helper tests increased measured production coverage to 92.05%. +- Expand active-path protection discovery, workflow, registry export, cleanup, and verification tests to 95.09% measured production coverage, and raise the overall command-coverage ratchet from 90% to 94% while retaining the 95% changed-file gate. +- Reject test-directory files as coverage sources so test code cannot inflate reported production coverage. +- Preserve textual vendor inference when executable metadata is present but does not identify a vendor. +- Normalize exported `Wi-Fi-.xml` filenames before using them as expected profile names during workflow verification. +- Retry a PSScriptAnalyzer target once only when version 1.25.0 raises its intermittent `NullReferenceException`; all repeated null references, other exceptions, and analyzer findings remain fatal. +- Temporarily omit performance tuning from the interactive menu while retaining explicit `-Mode PerformanceTune` compatibility. +- Add a parallel Windows PowerShell 5.1 compatibility job and update workflow actions to immutable Node.js 24 release commits. +- Treat Workplace registration as preserved user-level SSO rather than organization management unless independent domain, Entra join, enterprise join, or MDM evidence exists. +- Preserve identity, account, token, credential, and BrokerPlugin stores through explicit narrow cleanup allowlists and regression tests. +- Capture `netsh` Wi-Fi profile output as UTF-8 while restoring the caller's console encoding so non-ASCII SSID names render correctly. + +- Refactor: Make repository PSScriptAnalyzer-clean across all scripts and module functions. + - Replace `Write-Host` with structured logging and proper streams. + - Add `CmdletBinding(SupportsShouldProcess=$true)` to state-changing functions. + - Rename functions to use approved verbs and singular nouns; add backward-compatible aliases. + - Harden `catch` blocks to surface errors (no empty catches). + - Remove unsafe `Invoke-Expression` usage and use safer execution patterns. + - Fix scoping (avoid `global:`), remove unused variables, and avoid assigning to automatic variables in examples/tests. + - Fix file encoding/BOM for Unicode files and remove non-ASCII artifacts that triggered analyzer warnings. + - Update examples and tests to be analyzer- and CI-friendly. + - Add unit tests and ensure Pester tests pass locally. + +These changes were made to improve safety, testability, and to ensure CI fails fast on analyzer or test regressions. ## 0.1.0 - 2026-03-07 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7494681..16b32e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,35 +1,38 @@ # Contributing -Thanks for considering contributing to `netclean` — your help improves the tool for everyone. +Contributions should be small, focused, and based on the latest `main` branch unless a maintainer specifies another base. -Please follow these guidelines for a smooth collaboration: +## Engineering expectations -- Fork the repository and open a feature branch from `main`. -- Keep commits small and focused; use clear commit messages. -- Run `PSScriptAnalyzer` locally and fix warnings where practical. +- Use red-green-refactor: add or correct a focused Pester 6 test, observe the intended failure, apply the smallest production change, then refactor with the suite green. +- Preserve dry-run, `ShouldProcess`, protected-artifact, and backup behavior for state-changing operations. +- Keep functions compact and single-purpose; isolate native commands and filesystem/registry access behind testable helpers. +- Do not weaken security checks, analyzer rules, test assertions, the 94% overall coverage ratchet, or the 95% changed-file target to make CI pass. Current total coverage exceeds 95%; preserve that result and raise the enforced ratchet only when durable margin permits. +- Treat network performance tuning as tabled. Do not expose it in the interactive menu or expand that code without explicit maintainer direction. +- Update public help, README, and CHANGELOG entries when behavior or interfaces change. -Suggested checks before opening a pull request: +## Local validation ```powershell -Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force -Invoke-ScriptAnalyzer -Path . -Recurse -``` - -Pull request checklist +Install-Module Pester -Scope CurrentUser -RequiredVersion 6.0.1 -Force -SkipPublisherCheck +Install-Module PSScriptAnalyzer -Scope CurrentUser -RequiredVersion 1.25.0 -Force -- [ ] Code changes include tests or verification steps (if applicable). -- [ ] README and CHANGELOG updated if behavior or interface changed. -- [ ] CI passes (GitHub Actions will lint and run safe dry-runs). +.\tests\Invoke-NetCleanAnalyzer.ps1 -Code style and tests +Invoke-Pester -Path .\tests +.\tests\Run-NetClean-Coverage.ps1 +``` -- Use clear, descriptive names for functions and parameters. -- Keep scripts idempotent where possible and add checks for required privileges. +Run the complete Pester suite under both PowerShell 7.4 or later and Windows PowerShell 5.1 before submitting changes. The authoritative coverage run remains on PowerShell 7 and is intentionally sequential. -Reporting issues +The repository's registry System tests are safe for normal local and CI runs: Pester creates a random, container-scoped `TestRegistry:` key under HKCU, and the suite populates it only with synthetic data. Run tests that change real adapters, Wi-Fi state, caches, event logs, or restart behavior only on a disposable Windows system you control. Never commit generated test results, coverage reports, logs, exported registry data, Wi-Fi profiles, credentials, or other machine inventory. -- Use the repository's Issues to report bugs or request features. Provide reproduction steps and environment details. +## Pull request checklist -License +- [ ] A focused test demonstrated the defect or missing behavior before the implementation change. +- [ ] Pester 6.0.1 tests pass under PowerShell 7.4 or later and Windows PowerShell 5.1. +- [ ] PSScriptAnalyzer reports no warnings or errors. +- [ ] Documentation and change notes match the implementation. +- [ ] No generated output, secrets, credentials, or host-specific inventory is included. -By contributing you agree that your contributions will be licensed under the project's license. +Contributions are licensed under the repository's GPLv3 license. diff --git a/Modules/NetClean.psm1 b/Modules/NetClean.psm1 new file mode 100644 index 0000000..b5029d8 --- /dev/null +++ b/Modules/NetClean.psm1 @@ -0,0 +1,2114 @@ +<# +.SYNOPSIS + NetClean PowerShell module +.DESCRIPTION + Phase-oriented engine for: + - Phase 1: Detect + - Phase 2: Protect + - Phase 3: Clean + - Phase 4: Verify +.FUNCTIONALITY + System, Security, Diagnostics + Goal: + Remove user/environment-identifying network history and metadata while + preserving required security, virtualization, firewall, VPN, and network + infrastructure software. +#> +Set-StrictMode -Version Latest + +$script:ModuleRoot = Split-Path -Parent $PSCommandPath + +. (Join-Path $script:ModuleRoot 'NetCleanPhase1.ps1') +. (Join-Path $script:ModuleRoot 'NetCleanPhase2.ps1') +. (Join-Path $script:ModuleRoot 'NetCleanPhase3.ps1') +. (Join-Path $script:ModuleRoot 'NetCleanPhase4.ps1') + +# Invoke independent work in a bounded runspace pool. Runspaces provide +# in-process multithreading on both Windows PowerShell 5.1 and PowerShell 7. +function Invoke-InParallel { + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory = $true)] + [scriptblock]$ScriptBlock, + + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$InputObjects, + + [ValidateRange(1, 256)] + [int]$ThrottleLimit = ([System.Environment]::ProcessorCount) + ) + + if ($InputObjects.Count -eq 0) { + return @() + } + + if ($InputObjects.Count -eq 1) { + try { + return @(& $ScriptBlock $InputObjects[0]) + } + catch { + Write-Verbose "Invoke-InParallel worker failed: $($_.Exception.Message)" + return @() + } + } + + $workerCount = [System.Math]::Min($ThrottleLimit, $InputObjects.Count) + $runspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $workerCount) + $workers = [System.Collections.Generic.List[object]]::new() + $results = [System.Collections.Generic.List[object]]::new() + + try { + $runspacePool.Open() + + foreach ($item in $InputObjects) { + $powerShell = [System.Management.Automation.PowerShell]::Create() + $powerShell.RunspacePool = $runspacePool + [void]$powerShell.AddScript($ScriptBlock.ToString()).AddArgument($item) + + $workers.Add([pscustomobject]@{ + PowerShell = $powerShell + AsyncResult = $powerShell.BeginInvoke() + }) + } + + foreach ($worker in $workers) { + try { + foreach ($outputItem in @($worker.PowerShell.EndInvoke($worker.AsyncResult))) { + if ($null -ne $outputItem) { + $results.Add($outputItem) + } + } + } + catch { + Write-Verbose "Invoke-InParallel worker failed: $($_.Exception.Message)" + } + } + } + finally { + foreach ($worker in $workers) { + $worker.PowerShell.Dispose() + } + + $runspacePool.Close() + $runspacePool.Dispose() + } + + return $results.ToArray() +} + + +<# +.SYNOPSIS + NetClean PowerShell module +.DESCRIPTION + Phase-oriented engine for: + - Phase 1: Detect + - Phase 2: Protect + - Phase 3: Clean + - Phase 4: Verify +.FUNCTIONALITY + System, Security, Diagnostics + Goal: + Remove user/environment-identifying network history and metadata while + preserving required security, virtualization, firewall, VPN, and network + infrastructure software. + +.NOTES + - Best fidelity requires administrative privileges + - The default workflow is intentionally conservative + - Advanced repair and performance tuning are opt-in + - This module favors explainability, backup, and verification +#> + +Set-StrictMode -Version Latest +$script:NetCleanModuleVersion = '1.0.0' +$script:RegistryHiveMap = [ordered]@{ + HKLM = 'HKEY_LOCAL_MACHINE' + HKCU = 'HKEY_CURRENT_USER' + HKCR = 'HKEY_CLASSES_ROOT' + HKU = 'HKEY_USERS' + HKCC = 'HKEY_CURRENT_CONFIG' +} +$script:RegistryRootMap = $null + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + +$script:LogFile = $null +$script:NewLine = [Environment]::NewLine +$script:Utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +<# +.SYNOPSIS +Detects whether Windows is connected to organization management. +.DESCRIPTION +Uses dsregcmd /status for Active Directory, Microsoft Entra, enterprise, and +workplace registration state. Workplace registration alone is not treated as +device management. EnterpriseMgmt scheduled-task evidence is treated as MDM +enrollment evidence. If dsregcmd is unavailable, domain join state falls back +to Win32_ComputerSystem. No tenant or domain identifier is returned or logged. +.OUTPUTS +A PSCustomObject describing the management state and detection warnings. +#> +function Get-NetCleanDeviceManagementState { + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + $warnings = [System.Collections.Generic.List[string]]::new() + $state = [ordered]@{ + EntraJoined = $false + EnterpriseJoined = $false + DomainJoined = $false + WorkplaceJoined = $false + } + + $dsregSucceeded = $false + try { + $dsreg = Invoke-NetCleanNativeCapture -Name 'Detect Windows organization join state' -FilePath 'dsregcmd.exe' -ArgumentList @('/status') + + if ($dsreg.Succeeded) { + foreach ($line in @($dsreg.Output)) { + if ($line -match '^\s*(AzureAdJoined|EnterpriseJoined|DomainJoined|WorkplaceJoined)\s*:\s*(YES|NO)\s*$') { + $propertyName = if ($Matches[1] -eq 'AzureAdJoined') { 'EntraJoined' } else { $Matches[1] } + $state[$propertyName] = $Matches[2] -eq 'YES' + $dsregSucceeded = $true + } + } + } + + if (-not $dsregSucceeded) { + [void]$warnings.Add('Microsoft Entra join-state detection was unavailable.') + } + } + catch { + [void]$warnings.Add('Microsoft Entra join-state detection was unavailable.') + } + + if (-not $dsregSucceeded) { + try { + $computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop + $state.DomainJoined = [bool]$computerSystem.PartOfDomain + } + catch { + [void]$warnings.Add('Active Directory domain-state fallback was unavailable.') + } + } + + $mdmEnrolled = $false + try { + $mdmTasks = @(Get-ScheduledTask -TaskPath '\Microsoft\Windows\EnterpriseMgmt\*' -ErrorAction Stop) + $mdmEnrolled = $mdmTasks.Count -gt 0 + } + catch { + [void]$warnings.Add('MDM enrollment-task detection was unavailable.') + } + + $isManaged = ( + $state.EntraJoined -or + $state.EnterpriseJoined -or + $state.DomainJoined -or + $mdmEnrolled + ) + + $joinType = if ($state.EntraJoined -and $state.DomainJoined) { + 'MicrosoftEntraHybridJoined' + } + elseif ($state.EntraJoined) { + 'MicrosoftEntraJoined' + } + elseif ($state.DomainJoined) { + 'DomainJoined' + } + elseif ($state.EnterpriseJoined) { + 'EnterpriseJoined' + } + elseif ($state.WorkplaceJoined) { + 'WorkplaceRegistered' + } + elseif ($mdmEnrolled) { + 'MdmEnrolled' + } + else { + 'Workgroup' + } + + return [pscustomobject]@{ + IsManaged = [bool]$isManaged + JoinType = $joinType + DomainJoined = [bool]$state.DomainJoined + EntraJoined = [bool]$state.EntraJoined + EnterpriseJoined = [bool]$state.EnterpriseJoined + WorkplaceJoined = [bool]$state.WorkplaceJoined + MdmEnrolled = [bool]$mdmEnrolled + Warnings = $warnings.ToArray() + } +} + +<# +.SYNOPSIS +Returns the active NetClean log-file path. +.DESCRIPTION +Returns the path initialized by Start-NetCleanLog, or null when logging has +not been initialized. +.OUTPUTS +System.String +#> +function Get-NetCleanLogFile { + [CmdletBinding()] + param() + + return $script:LogFile +} + +<# +.SYNOPSIS +Restricts a NetClean data directory to the current user, Administrators, and SYSTEM. +.DESCRIPTION +Replaces inherited access rules so sensitive backups and logs are not readable +through broad parent-directory permissions. +.PARAMETER Path +Existing directory whose access control list will be replaced. +#> +function Set-NetCleanPrivateDirectoryAcl { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + throw "Private data directory does not exist: $Path" + } + + $acl = [System.Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + + $identities = @( + [System.Security.Principal.WindowsIdentity]::GetCurrent().User, + [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'), + [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18') + ) | Select-Object -Unique + + $inheritance = ( + [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [System.Security.AccessControl.InheritanceFlags]::ObjectInherit + ) + + foreach ($identity in $identities) { + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $identity, + [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($rule) + } + + if ($PSCmdlet.ShouldProcess($Path, 'Restrict directory access')) { + Set-Acl -LiteralPath $Path -AclObject $acl -ErrorAction Stop + } +} + +<# +.SYNOPSIS + Starts the NetClean logging. +.DESCRIPTION + This function initializes the logging for the NetClean process. +.PARAMETER Directory + The directory where log files will be stored. +.EXAMPLE + Start-NetCleanLog -Directory "C:\Logs" +.NOTES + The function creates the log directory if it does not exist. +#> +function Start-NetCleanLog { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $true)] + [string]$Directory + ) + + if (-not (Test-Path -LiteralPath $Directory)) { + if ($PSCmdlet.ShouldProcess($Directory, "Create directory")) { + New-Item -Path $Directory -ItemType Directory -Force | Out-Null + } + } + + if (Test-Path -LiteralPath $Directory -PathType Container) { + Set-NetCleanPrivateDirectoryAcl -Path $Directory + } + elseif (-not $WhatIfPreference) { + throw "Unable to create private log directory: $Directory" + } + + $candidateLogFile = Join-Path $Directory ("NetClean_{0}.log" -f (Get-Date -Format 'yyyy-MM-dd_HH-mm-ss')) + + if ($PSCmdlet.ShouldProcess($candidateLogFile, "Create log file")) { + try { + [System.IO.File]::WriteAllText( + $candidateLogFile, + "[$(Get-Date -Format s)] [INFO] Log started" + $script:NewLine, + $script:Utf8NoBom + ) + + $script:LogFile = $candidateLogFile + } + catch { + $script:LogFile = $null + Write-Verbose "Failed to create log file '$candidateLogFile': $_" + } + } +} + +<# +.SYNOPSIS + Writes a message to the NetClean log. +.DESCRIPTION + This function writes a message to the NetClean log with the specified level. +.PARAMETER Level + The level of the log message. +.PARAMETER Message + The message to write to the log. +.EXAMPLE + Write-NetCleanLog -Level 'INFO' -Message 'Starting NetClean process' +.NOTES + The function uses the Convert-RegToProviderPath function to normalize the input path. +#> +function Write-NetCleanLog { + [CmdletBinding()] + param( + [ValidateSet('INFO', 'WARN', 'ERROR', 'DEBUG', 'TRACE')] + [string]$Level = 'INFO', + + [Parameter(Mandatory = $true)] + [string]$Message + ) + + $line = "[$(Get-Date -Format s)] [$Level] $Message" + + $logFile = Get-NetCleanLogFile + if ($logFile) { + try { + [System.IO.File]::AppendAllText($logFile, $line + $script:NewLine, $script:Utf8NoBom) + } + catch { + Write-Verbose "Failed to append to log file '$logFile': $_" + } + } + + switch ($Level) { + 'ERROR' { Write-Error $Message -ErrorAction Continue } + 'WARN' { Write-Warning $Message } + 'INFO' { Write-Information $Message -InformationAction Continue } + 'DEBUG' { Write-Verbose $Message } + 'TRACE' { Write-Debug $Message } + } +} + +# --------------------------------------------------------------------------- +# Utility helpers +# --------------------------------------------------------------------------- + +<# +.SYNOPSIS +Normalize a registry key path to a canonical form. +.DESCRIPTION +Removes provider prefixes and normalizes separators for a registry path. Validates common registry hives. +.PARAMETER Path +The registry path to normalize. +.EXAMPLE +Convert-RegKeyPath -Path 'HKLM:\SOFTWARE\\MyKey' +#> +function Convert-RegKeyPath { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string]$Path + ) + + if ($null -eq $Path) { return $null } + if ($Path -eq '') { return '' } + $p = $Path.Trim() + $p = $p -replace '^Microsoft\.PowerShell\.Core\\Registry::', '' + $p = $p -replace '^Registry::', '' + $p = $p -replace '/', '\' + + try { + $parts = [regex]::Split($p, '[\\/]+') | Where-Object { $_ -and $_.Trim() -ne '' } + $p = ($parts -join '\') + } + catch { + while ($p -match '\\\\') { + $p = $p -replace '\\\\', '\' + } + $p = $p -replace '^\\+|\\+$', '' + } + + $p = $p -replace '^(HKLM|HKCU|HKCR|HKU|HKCC):', '$1' + + if ($p -notmatch '^(HKLM|HKEY_LOCAL_MACHINE|HKCU|HKEY_CURRENT_USER|HKCR|HKEY_CLASSES_ROOT|HKU|HKEY_USERS|HKCC|HKEY_CURRENT_CONFIG)(\\.*)?$') { + throw "Invalid registry path: '$Path'" + } + + return $p.Trim() +} + +<# +.SYNOPSIS +Parses a normalized registry path into its root and suffix. +.DESCRIPTION +Returns the short logical root, native root name, suffix, and normalized input +for a path accepted by Convert-RegKeyPath. +#> +function Get-NetCleanRegistryPathInfo { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string]$RegistryPath + ) + + $normalized = Convert-RegKeyPath -Path $RegistryPath + foreach ($entry in $script:RegistryHiveMap.GetEnumerator()) { + $pattern = '^(?:{0}|{1})(?:\\(?.*))?$' -f + [regex]::Escape($entry.Key), + [regex]::Escape($entry.Value) + $match = [regex]::Match($normalized, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($match.Success) { + return [pscustomobject]@{ + Root = $entry.Key + NativeRoot = $entry.Value + Suffix = $match.Groups['Suffix'].Value + Normalized = $normalized + } + } + } + + throw "Unsupported registry root in path '$RegistryPath'" +} + +function Get-NetCleanRegistryProviderPath { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$RegistryPath + ) + + $info = Get-NetCleanRegistryPathInfo -RegistryPath $RegistryPath + if ([string]::IsNullOrWhiteSpace($info.Suffix)) { + return "Registry::$($info.NativeRoot)" + } + + return "Registry::$($info.NativeRoot)\$($info.Suffix)" +} + +<# +.SYNOPSIS +Sets private logical registry-root mappings for an isolated registry tree. +.DESCRIPTION +Validates all target roots as existing Registry-provider keys, then atomically +replaces the private map. This helper is intentionally not exported. +#> +function Set-NetCleanRegistryRootMap { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [hashtable]$RootMap + ) + + if ($RootMap.Count -eq 0) { + throw 'Registry root map must contain at least one mapping.' + } + + $validated = @{} + foreach ($entry in $RootMap.GetEnumerator()) { + $logical = Get-NetCleanRegistryPathInfo -RegistryPath ([string]$entry.Key) + if (-not [string]::IsNullOrWhiteSpace($logical.Suffix)) { + throw "Registry root map key must be a hive root: '$($entry.Key)'" + } + if ($validated.ContainsKey($logical.Root)) { + throw "Registry root map contains a duplicate logical root: '$($logical.Root)'" + } + + $target = Convert-RegKeyPath -Path ([string]$entry.Value) + $targetProviderPath = Get-NetCleanRegistryProviderPath -RegistryPath $target + $targetItem = Get-Item -LiteralPath $targetProviderPath -ErrorAction Stop + if ($targetItem.PSProvider.Name -ne 'Registry') { + throw "Registry root map target is not a Registry-provider key: '$($entry.Value)'" + } + + $validated[$logical.Root] = $target + } + + if ($PSCmdlet.ShouldProcess('private registry-root map', 'Replace registry-root mappings')) { + $script:RegistryRootMap = $validated + } +} + +function Clear-NetCleanRegistryRootMap { + [CmdletBinding()] + param() + + $script:RegistryRootMap = $null +} + +function Resolve-NetCleanRegistryPath { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$RegistryPath + ) + + $info = Get-NetCleanRegistryPathInfo -RegistryPath $RegistryPath + if ($null -eq $script:RegistryRootMap -or -not $script:RegistryRootMap.ContainsKey($info.Root)) { + return $info.Normalized + } + + $mappedRoot = $script:RegistryRootMap[$info.Root] + if ([string]::IsNullOrWhiteSpace($info.Suffix)) { + return $mappedRoot + } + + return "$mappedRoot\$($info.Suffix)" +} + +<# +.SYNOPSIS +Validates and normalizes a GUID string. +.DESCRIPTION +Parses a GUID string and returns the canonical lowercase GUID form. Returns `$null` for empty input. +.PARAMETER Guid +The GUID string to validate and convert. +.EXAMPLE +Convert-Guid -Guid 'A0E6C2D0-...' +#> +function Convert-Guid { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string]$Guid + ) + + if ([string]::IsNullOrWhiteSpace($Guid)) { + return $null + } + + $parsed = [guid]::Empty + if (-not [guid]::TryParse($Guid, [ref]$parsed)) { + throw "Invalid GUID: '$Guid'" + } + + return $parsed.ToString().ToLowerInvariant() +} + +<# +.SYNOPSIS +Convert a registry key path to the provider-qualified path. +.DESCRIPTION +Transforms a normalized registry path into a Provider:: style path usable by provider cmdlets (e.g., Registry::HKEY_LOCAL_MACHINE\...). +.PARAMETER RegistryPath +The registry path to convert. +.EXAMPLE +Convert-RegToProviderPath -RegistryPath 'HKLM:\SOFTWARE\\MyKey' +#> +function Convert-RegToProviderPath { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [Alias('Path')] + [string]$RegistryPath + ) + + if ([string]::IsNullOrWhiteSpace($RegistryPath)) { return $null } + + $resolvedPath = Resolve-NetCleanRegistryPath -RegistryPath $RegistryPath + return Get-NetCleanRegistryProviderPath -RegistryPath $resolvedPath +} + +<# +.SYNOPSIS + Tests if a registry path exists. +.DESCRIPTION + This function checks if a specified registry path exists. +.PARAMETER RegistryPath + The registry path to test. +.PARAMETER ThrowOnError + Rethrows provider errors so verification callers can fail closed. Discovery callers remain fail-soft by default. +.EXAMPLE + Test-RegistryPathExist -RegistryPath "HKLM:\SOFTWARE\MyKey" +.OUTPUTS + System.Boolean - True if the path exists, false otherwise. +.NOTES + The function uses the Convert-RegToProviderPath function to normalize the input path. +#> +function Test-RegistryPathExist { + [CmdletBinding()] + [OutputType([System.Boolean])] + param( + [Parameter(Mandatory = $true)] + [Alias('Path')] + [string]$RegistryPath, + + [switch]$ThrowOnError + ) + + try { + $providerPath = Convert-RegToProviderPath -RegistryPath $RegistryPath + return (Test-Path -LiteralPath $providerPath) + } + catch { + if ($ThrowOnError) { + throw + } + return $false + } +} + +<# +.SYNOPSIS + Gets the values of a registry key. +.DESCRIPTION + This function retrieves the values of a specified registry key. +.PARAMETER RegistryPath + The registry path to query. +.EXAMPLE + Get-RegistryValuesSafe -RegistryPath "HKLM:\SOFTWARE\MyKey" +.OUTPUTS + System.Object - The registry values. +.NOTES + The function uses the Convert-RegToProviderPath function to normalize the input path. +#> +function Get-RegistryValuesSafe { + [CmdletBinding()] + [OutputType([System.Object])] + param( + [Parameter(Mandatory = $true)] + [Alias('Path')] + [string]$RegistryPath + ) + + try { + $providerPath = Convert-RegToProviderPath -RegistryPath $RegistryPath + return Get-ItemProperty -LiteralPath $providerPath -ErrorAction Stop + } + catch { + return $null + } +} + +<# +.SYNOPSIS + Gets the names of child keys in a registry path. +.DESCRIPTION + This function retrieves the names of child keys in a specified registry path. +.PARAMETER RegistryPath + The registry path to query. +.EXAMPLE + Get-RegistryChildKeyNamesSafe -RegistryPath "HKLM:\SOFTWARE\MyKey" +.OUTPUTS + System.String[] - A list of child key names. +.NOTES + The function uses the Convert-RegToProviderPath function to normalize the input path. +#> +function Get-RegistryChildKeyNamesSafe { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $true)] + [Alias('Path')] + [string]$RegistryPath + ) + + try { + $providerPath = Convert-RegToProviderPath -RegistryPath $RegistryPath + return @(Get-ChildItem -LiteralPath $providerPath -ErrorAction Stop | Select-Object -ExpandProperty PSChildName) + } + catch { + return @() + } +} + +<# +.SYNOPSIS + Gets unique, non-empty strings from an array. +.DESCRIPTION + This function takes an array of objects and returns a list of unique, non-empty strings. +.PARAMETER InputObject + The array of objects to process. +.EXAMPLE + Get-UniqueNonEmptyString -InputObject @("apple", $null, "banana", "apple") +.OUTPUTS + System.String[] - A list of unique, non-empty strings. +.NOTES + The function trims whitespace from each string before checking if it's empty. +#> +function Get-UniqueNonEmptyString { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter()] + [AllowNull()] + [object[]]$InputObject + ) + + $list = New-Object System.Collections.Generic.List[string] + + foreach ($item in @($InputObject)) { + if ($null -eq $item) { continue } + + if ($item -is [System.Array]) { + foreach ($inner in $item) { + if ($null -eq $inner) { continue } + $s2 = $inner.ToString().Trim() + if ([string]::IsNullOrWhiteSpace($s2)) { continue } + [void]$list.Add($s2) + } + continue + } + + $s = $item.ToString().Trim() + if ([string]::IsNullOrWhiteSpace($s)) { continue } + + [void]$list.Add($s) + } + + return $list.ToArray() | Sort-Object -Unique +} + +<# +.SYNOPSIS + Adds a value to a HashSet. +.DESCRIPTION + This function adds a string value to a HashSet if it is not null or whitespace. +.PARAMETER Set + The HashSet to which the value will be added. +.PARAMETER Values + The value(s) to add to the HashSet. +.EXAMPLE + Add-HashSetValue -Set $mySet -Values "apple" +.OUTPUTS + System.Void +.NOTES + The function uses the Get-UniqueNonEmptyString function to normalize the input arrays. +#> +function Add-HashSetValue { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[string]]$Set, + + [Parameter()] + [AllowNull()] + [object[]]$Values + ) + + foreach ($value in @($Values)) { + if ($null -eq $value) { continue } + + if ($value -is [System.Array]) { + foreach ($inner in $value) { + if ($null -eq $inner) { continue } + $s2 = $inner.ToString().Trim() + if ([string]::IsNullOrWhiteSpace($s2)) { continue } + [void]$Set.Add($s2) + } + continue + } + + $s = $value.ToString().Trim() + if ([string]::IsNullOrWhiteSpace($s)) { continue } + [void]$Set.Add($s) + } +} + +<# +.SYNOPSIS + Compares two sets of strings and returns the differences. +.DESCRIPTION + This function compares two arrays of strings and returns a custom object containing the unique strings from each array. +.PARAMETER Before + The first array of strings to compare. +.PARAMETER After + The second array of strings to compare. +.EXAMPLE + Compare-StringSet -Before @("apple", "banana") -After @("banana", "cherry") +.OUTPUTS + System.Management.Automation.PSCustomObject - A custom object containing the comparison results. +.NOTES + The function uses the Get-UniqueNonEmptyString function to normalize the input arrays. +#> +function Compare-StringSet { + [CmdletBinding()] + [OutputType([System.Management.Automation.PSCustomObject])] + param( + [Parameter()] + [AllowNull()] + [string[]]$Before, + + [Parameter()] + [AllowNull()] + [string[]]$After + ) + + $beforeSet = @(Get-UniqueNonEmptyString -InputObject $Before) + $afterSet = @(Get-UniqueNonEmptyString -InputObject $After) + + return [pscustomobject]@{ + Before = $beforeSet + After = $afterSet + Missing = @($beforeSet | Where-Object { $_ -notin $afterSet }) + Added = @($afterSet | Where-Object { $_ -notin $beforeSet }) + } +} + +<# +.SYNOPSIS + Creates a directory if it does not exist. +.DESCRIPTION + This function checks if a directory exists and creates it if it does not. +.PARAMETER Path + The path of the directory to create. +.EXAMPLE + New-DirectoryIfNotExist -Path "C:\MyDirectory" +.OUTPUTS + System.Void +.NOTES + The function uses the -Force parameter to create the directory if it does not exist. +#> +function New-DirectoryIfNotExist { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.Void])] + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + if ($PSCmdlet.ShouldProcess($Path, 'Create directory')) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } + } +} + +# Thin wrappers for file IO so tests can Mock these easily +function WriteAllLines { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)][object[]]$Contents, + $Encoding + ) + + if ($null -eq $Encoding) { + [System.IO.File]::WriteAllLines($Path, $Contents) + } + else { + [System.IO.File]::WriteAllLines($Path, $Contents, $Encoding) + } +} + +function WriteAllText { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$Path, + [Parameter(Mandatory=$true)][string]$Contents, + $Encoding + ) + + if ($null -eq $Encoding) { + [System.IO.File]::WriteAllText($Path, $Contents) + } + else { + [System.IO.File]::WriteAllText($Path, $Contents, $Encoding) + } +} + +<# +.SYNOPSIS + Normalizes a file path from a command line argument. +.DESCRIPTION + This function attempts to extract and normalize a file path from a command line string. +.PARAMETER CommandLine + The command line string containing the file path. +.EXAMPLE + Get-NormalizedFilePathFromCommandLine -CommandLine '"C:\Program Files\Example\example.exe"' +.OUTPUTS + System.String - The normalized file path or $null if not found. +.NOTES + The function trims whitespace and removes surrounding quotes from the command line argument. +#> +function Get-NormalizedFilePathFromCommandLine { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string]$CommandLine + ) + + if ([string]::IsNullOrWhiteSpace($CommandLine)) { + return $null + } + + $text = $CommandLine.Trim() + + $text = [Environment]::ExpandEnvironmentVariables($text) + + if ($text.StartsWith('\SystemRoot\', [System.StringComparison]::OrdinalIgnoreCase)) { + $text = Join-Path $env:windir $text.Substring(12) + } + + if ($text.StartsWith('"')) { + $m = [regex]::Match($text, '^"([^"]+\.(exe|dll|sys|com|cpl|ocx))"', 'IgnoreCase') + if ($m.Success) { + return $m.Groups[1].Value + } + } + + $m = [regex]::Match($text, '^[^\s"]+\.(exe|dll|sys|com|cpl|ocx)', 'IgnoreCase') + if ($m.Success) { + return $m.Value + } + + $m = [regex]::Match($text, '^[A-Za-z]:\\.*?\.(exe|dll|sys|com|cpl|ocx)', 'IgnoreCase') + if ($m.Success) { + return $m.Value.Trim('"') + } + + return $text.Trim('"') +} + +<# +.SYNOPSIS + Resolves the vendor name from a given text. +.DESCRIPTION + This function attempts to identify the vendor associated with a given text by checking for known vendor hints. +.PARAMETER Text + The text to analyze for vendor information. +.EXAMPLE + Resolve-VendorFromText -Text "Microsoft Windows Defender" +.OUTPUTS + System.String - The resolved vendor name or $null if not found. +.NOTES + The function uses a predefined set of vendor hints to match against the input text. It returns the first matching vendor name based on the hints provided. +#> +function Resolve-VendorFromText { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowNull()] + [string[]]$Text + ) + + $joined = (@($Text) | Where-Object { $_ } | ForEach-Object { $_.ToString() }) -join ' ' + if ([string]::IsNullOrWhiteSpace($joined)) { + return $null + } + + $joined = $joined.ToLowerInvariant() + + $vendorHints = @{ + 'Microsoft' = @('microsoft', 'windows defender', 'microsoft corporation', 'hyper-v') + 'VMware' = @('vmware', 'vmware, inc') + 'VirtualBox' = @('virtualbox', 'oracle virtualbox') + 'Parallels' = @('parallels') + 'CrowdStrike' = @('crowdstrike', 'falcon') + 'SentinelOne' = @('sentinelone', 'sentinel') + 'Sophos' = @('sophos') + 'Bitdefender' = @('bitdefender') + 'Malwarebytes' = @('malwarebytes', 'mbam') + 'Symantec' = @('symantec', 'broadcom endpoint', 'sep') + 'Trellix/McAfee' = @('trellix', 'mcafee', 'mfe') + 'Palo Alto Networks' = @('palo alto', 'cortex', 'globalprotect', 'traps') + 'Cisco' = @('cisco', 'anyconnect', 'secure client', 'umbrella', 'amp') + 'Zscaler' = @('zscaler') + 'ESET' = @('eset') + 'Trend Micro' = @('trend micro', 'apex one') + 'Check Point' = @('check point', 'capsule', 'snx') + 'Fortinet' = @('fortinet', 'forticlient', 'fortiedr') + } + + foreach ($vendor in $vendorHints.Keys) { + foreach ($hint in $vendorHints[$vendor]) { + if ($joined -like "*$hint*") { + return $vendor + } + } + } + + return $null +} + +<# +.SYNOPSIS + Retrieves metadata for a given file. +.DESCRIPTION + This function returns detailed metadata for a specified file, including version information and digital signature details. +.PARAMETER Path + The path to the file for which to retrieve metadata. +.EXAMPLE + Get-FileMetadatum -Path "C:\Windows\System32\ntdll.dll" +.OUTPUTS + System.Management.Automation.PSCustomObject - A custom object containing the file's metadata. +.NOTES + The function will display verbose information about the file being processed and any errors encountered. +#> +<# Duplicate helper removed; use Get-FileMetadata instead. #> + +<# +.SYNOPSIS + Retrieves the registry roots for a given vendor. +.DESCRIPTION + This function returns the registry paths commonly associated with a specified vendor's software. +.PARAMETER Vendor + The name of the vendor for which to retrieve registry roots. +.EXAMPLE + Get-VendorRoots -Vendor "Microsoft" +.OUTPUTS + System.String[] - An array of registry paths associated with the vendor. +.NOTES + The function will display verbose information about the registry paths being retrieved. +#> +function Get-VendorRootsFromInstallPath { + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter()] + [AllowNull()] + [string]$InstallPath + ) + + if ([string]::IsNullOrWhiteSpace($InstallPath)) { + return [string[]] @() + } + + $roots = New-Object System.Collections.Generic.List[string] + + try { + $resolved = $InstallPath.Trim().Trim('"') + if (-not [string]::IsNullOrWhiteSpace($resolved)) { + $leaf = Split-Path -Path $resolved -Leaf + $parent = Split-Path -Path $resolved -Parent + + if (-not [string]::IsNullOrWhiteSpace($leaf)) { + [void]$roots.Add("HKLM\SOFTWARE\$leaf") + [void]$roots.Add("HKLM\SOFTWARE\WOW6432Node\$leaf") + } + + if (-not [string]::IsNullOrWhiteSpace($parent)) { + $parentLeaf = Split-Path -Path $parent -Leaf + if (-not [string]::IsNullOrWhiteSpace($parentLeaf)) { + [void]$roots.Add("HKLM\SOFTWARE\$parentLeaf") + [void]$roots.Add("HKLM\SOFTWARE\WOW6432Node\$parentLeaf") + } + } + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + [string[]]$result = @(Get-UniqueNonEmptyString -InputObject $roots) + return [string[]] $result +} + +<# +.SYNOPSIS + Invokes an external command safely. +.DESCRIPTION + This function executes an external command and handles potential errors gracefully. +.PARAMETER Name + The name of the command to invoke. +.PARAMETER FilePath + The path to the executable file. +.PARAMETER ArgumentList + The list of arguments for the command. +.PARAMETER DryRun + Indicates whether to perform a dry run without actually executing the command. +.PARAMETER IgnoreExitCode + Indicates whether to ignore the exit code of the command. +.EXAMPLE + Invoke-ExternalCommandSafe -Name "Example" -FilePath "C:\Example.exe" -ArgumentList @("-arg1", "-arg2") +.OUTPUTS + System.Object - A custom object representing the result of the command execution. +.NOTES + The function will display verbose information about the command being executed and any errors encountered. +#> +function Invoke-ExternalCommandSafe { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList, + + [switch]$DryRun, + [switch]$IgnoreExitCode + ) + + $display = "$FilePath $($ArgumentList -join ' ')" + + if ($DryRun) { + return [pscustomobject]@{ + Name = $Name + Command = $display + ExitCode = 0 + Succeeded = $true + DryRun = $true + Error = $null + } + } + + try { + $proc = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -NoNewWindow -Wait -PassThru -ErrorAction Stop + + if (-not $IgnoreExitCode -and $proc.ExitCode -ne 0) { + return [pscustomobject]@{ + Name = $Name + Command = $display + ExitCode = $proc.ExitCode + Succeeded = $false + DryRun = $false + Error = "$Name failed with exit code $($proc.ExitCode)" + } + } + + return [pscustomobject]@{ + Name = $Name + Command = $display + ExitCode = $proc.ExitCode + Succeeded = $true + DryRun = $false + Error = $null + } + } + catch { + return [pscustomobject]@{ + Name = $Name + Command = $display + ExitCode = -1 + Succeeded = $false + DryRun = $false + Error = $_.Exception.Message + } + } +} + +<# +.SYNOPSIS + Tests if a registry path is protected. +.DESCRIPTION + This function checks if a given registry path is protected based on the provided context. +.PARAMETER Path + The registry path to test. +.PARAMETER Context + The context containing protected registry paths. +.EXAMPLE + Test-RegistryPathProtected -Path "HKLM\SOFTWARE\Microsoft" -Context $context +.OUTPUTS + System.Boolean - True if the path is protected, false otherwise. +.NOTES + The context should have a property named 'ProtectedRegistryPaths' which is a collection of registry paths that are considered protected. The function checks if the input path matches or is a subpath of any of the protected paths. +#> +function Test-RegistryPathProtected { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter()] + [AllowNull()] + [pscustomobject]$Context + ) + + if ($null -eq $Context) { return $false } + + if (-not $Context.PSObject.Properties.Name.Contains('ProtectedRegistryPaths')) { + return $false + } + + try { + $normalizedPath = Convert-RegKeyPath -Path $Path + } + catch { + return $false + } + + foreach ($protected in @($Context.ProtectedRegistryPaths)) { + if ([string]::IsNullOrWhiteSpace($protected)) { continue } + + try { + $normalizedProtected = Convert-RegKeyPath -Path $protected + } + catch { + continue + } + + $pathIsProtected = $normalizedPath.Equals( + $normalizedProtected, + [System.StringComparison]::OrdinalIgnoreCase + ) -or $normalizedPath.StartsWith( + "$normalizedProtected\", + [System.StringComparison]::OrdinalIgnoreCase + ) -or $normalizedProtected.StartsWith( + "$normalizedPath\", + [System.StringComparison]::OrdinalIgnoreCase + ) + + if ($pathIsProtected) { + return $true + } + } + + return $false +} + +# --------------------------------------------------------------------------- +# Signature model +# --------------------------------------------------------------------------- + +<# +.SYNOPSIS + Retrieves the signature for a given vendor. +.DESCRIPTION + This function returns the signature information for a specified vendor, including categories, patterns, and registry roots. +.PARAMETER Vendor + The name of the vendor for which to retrieve signature information. +.EXAMPLE + Get-VendorSignature -Vendor "Microsoft" +.OUTPUTS + System.Collections.Hashtable - A hashtable containing the vendor's signature information. +.NOTES + The returned hashtable includes the following keys: + - Categories: An array of categories associated with the vendor (e.g., AV, Firewall). + - Patterns: An array of strings used to identify the vendor in various contexts. + - RegistryRoots: An array of registry paths commonly associated with the vendor's software. +#> +function Get-VendorSignature { + [CmdletBinding()] + [OutputType([System.Collections.Hashtable])] + param() + + @{ + 'Microsoft' = @{ + Categories = @('AV', 'Firewall', 'Hypervisor', 'VirtualAdapter', 'NetworkFilter', 'EndpointAgent') + Patterns = @( + 'microsoft defender', 'windows defender', 'msmpsvc', 'windefend', 'sense', + 'wdfilter', 'vmcompute', 'vmms', 'vmswitch', 'vmsmp', 'hns', 'hyper-v', + 'vethernet', 'microsoft' + ) + RegistryRoots = @( + 'HKLM\SOFTWARE\Microsoft\Windows Defender', + 'HKLM\SOFTWARE\Microsoft\Windows Advanced Threat Protection', + 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Virtualization' + ) + } + 'Bitdefender' = @{ + Categories = @('AV', 'Firewall', 'EndpointAgent', 'NetworkFilter') + Patterns = @('bitdefender', 'vsserv', 'bdservice', 'bdredline', 'bdc', 'epsecurityservice') + RegistryRoots = @('HKLM\SOFTWARE\Bitdefender') + } + 'Malwarebytes' = @{ + Categories = @('AV', 'EndpointAgent') + Patterns = @('malwarebytes', 'mbamservice', 'mbamprotector', 'mbam') + RegistryRoots = @('HKLM\SOFTWARE\Malwarebytes') + } + 'CrowdStrike' = @{ + Categories = @('EDR', 'XDR', 'EndpointAgent', 'NetworkFilter') + Patterns = @('crowdstrike', 'falcon', 'csfalconservice', 'csagent', 'crowdstrike falcon') + RegistryRoots = @('HKLM\SOFTWARE\CrowdStrike') + } + 'SentinelOne' = @{ + Categories = @('EDR', 'XDR', 'EndpointAgent', 'NetworkFilter') + Patterns = @('sentinelone', 'sentinelagent', 'sentinelctl', 'sentinel') + RegistryRoots = @('HKLM\SOFTWARE\SentinelOne') + } + 'Sophos' = @{ + Categories = @('AV', 'Firewall', 'EndpointAgent', 'NetworkFilter') + Patterns = @('sophos', 'savservice', 'sophos endpoint', 'hitmanpro', 'sntp') + RegistryRoots = @('HKLM\SOFTWARE\Sophos') + } + 'Symantec' = @{ + Categories = @('AV', 'EndpointAgent', 'Firewall') + Patterns = @('symantec', 'broadcom endpoint', 'sep', 'smc', 'symcorpui') + RegistryRoots = @('HKLM\SOFTWARE\Symantec') + } + 'Trellix/McAfee' = @{ + Categories = @('AV', 'EDR', 'Firewall', 'EndpointAgent', 'NetworkFilter') + Patterns = @('mcafee', 'trellix', 'mfe', 'ens', 'mfefire', 'mfewfpk') + RegistryRoots = @('HKLM\SOFTWARE\McAfee', 'HKLM\SOFTWARE\Trellix') + } + 'Palo Alto Networks' = @{ + Categories = @('EDR', 'XDR', 'Firewall', 'VPN', 'EndpointAgent', 'NetworkFilter') + Patterns = @('palo alto', 'cortex', 'globalprotect', 'traps', 'pangps', 'pangpd') + RegistryRoots = @('HKLM\SOFTWARE\Palo Alto Networks') + } + 'Cisco' = @{ + Categories = @('Firewall', 'VPN', 'EndpointAgent', 'NetworkFilter') + Patterns = @('cisco', 'anyconnect', 'secure client', 'amp', 'umbrella', 'ciscosecureclient') + RegistryRoots = @('HKLM\SOFTWARE\Cisco') + } + 'Zscaler' = @{ + Categories = @('Firewall', 'VPN', 'EndpointAgent', 'NetworkFilter') + Patterns = @('zscaler', 'zsa', 'zsatray', 'zscaler tunnel', 'zcc') + RegistryRoots = @('HKLM\SOFTWARE\Zscaler') + } + 'VMware' = @{ + Categories = @('Hypervisor', 'VirtualAdapter') + Patterns = @('vmware', 'vmnet', 'vmnat', 'vmwarehostd', 'vmx86', 'vmci', 'vmusb', 'vmware network adapter') + RegistryRoots = @('HKLM\SOFTWARE\VMware, Inc.') + } + 'VirtualBox' = @{ + Categories = @('Hypervisor', 'VirtualAdapter') + Patterns = @('virtualbox', 'oracle virtualbox', 'vbox', 'vboxnet', 'vboxdrv') + RegistryRoots = @('HKLM\SOFTWARE\Oracle\VirtualBox') + } + 'Parallels' = @{ + Categories = @('Hypervisor', 'VirtualAdapter') + Patterns = @('parallels', 'prl_', 'prl net', 'prl networking') + RegistryRoots = @('HKLM\SOFTWARE\Parallels') + } + 'ESET' = @{ + Categories = @('AV', 'EndpointAgent', 'Firewall', 'NetworkFilter') + Patterns = @('eset', 'ekrn', 'epfw', 'epfwlwf') + RegistryRoots = @('HKLM\SOFTWARE\ESET') + } + 'Trend Micro' = @{ + Categories = @('AV', 'EDR', 'EndpointAgent', 'NetworkFilter') + Patterns = @('trend micro', 'tmlisten', 'ntrtscan', 'ds_agent', 'apex one') + RegistryRoots = @('HKLM\SOFTWARE\TrendMicro') + } + 'Check Point' = @{ + Categories = @('Firewall', 'VPN', 'EndpointAgent', 'NetworkFilter') + Patterns = @('check point', 'endpoint security', 'tracsrvwrapper', 'snx', 'capsule') + RegistryRoots = @('HKLM\SOFTWARE\CheckPoint') + } + 'Fortinet' = @{ + Categories = @('Firewall', 'VPN', 'EndpointAgent', 'NetworkFilter') + Patterns = @('fortinet', 'forticlient', 'fortiedr', 'fortishield') + RegistryRoots = @('HKLM\SOFTWARE\Fortinet') + } + } +} + + +function Test-VendorPatternMatch { + [CmdletBinding()] + [OutputType([System.Boolean])] + param( + [Parameter(Mandatory = $true)] + [string]$Vendor, + + [Parameter(Mandatory = $true)] + [hashtable]$Signature, + + [Parameter(Mandatory = $true)] + [pscustomobject]$Evidence + ) + + if ($Evidence.PSObject.Properties.Name -contains 'InferredVendor') { + if ($Evidence.InferredVendor -and $Evidence.InferredVendor -eq $Vendor) { + return $true + } + } + + $haystackParts = foreach ($propertyName in @( + 'Name', + 'DisplayName', + 'Path', + 'Publisher', + 'InstallPath', + 'InterfaceDescription', + 'Manufacturer', + 'CompanyName', + 'FileDescription', + 'ProductName', + 'SignerSubject' + )) { + $property = $Evidence.PSObject.Properties[$propertyName] + if ($null -ne $property) { + $property.Value + } + } + + $haystack = ($haystackParts | Where-Object { $_ }) -join ' ' + $haystack = $haystack.ToLowerInvariant() + + foreach ($pattern in $Signature.Patterns) { + if ($haystack -like "*$pattern*") { + return $true + } + } + + return $false +} + +<# +.SYNOPSIS +Retrieves metadata for a specified file. +.DESCRIPTION +Gets detailed information about a file, including its version and company details. +.EXAMPLE +Get-FileMetadatum -Path "C:\Windows\System32\notepad.exe" +.OUTPUTS +PSCustomObject - A custom object containing the file's metadata. +#> +function Get-FileMetadatum { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $null + } + + $resolvedPath = Get-NormalizedFilePathFromCommandLine -CommandLine $Path + + if ([string]::IsNullOrWhiteSpace($resolvedPath)) { + return $null + } + + try { + try { + if (-not (Test-Path -LiteralPath $resolvedPath -ErrorAction Stop)) { + return [pscustomobject]@{ + Path = $resolvedPath + Exists = $false + CompanyName = $null + FileDescription = $null + ProductName = $null + OriginalName = $null + FileVersion = $null + SignerSubject = $null + SignerIssuer = $null + SignerThumbprint = $null + SignatureStatus = $null + InferredVendor = $null + } + } + } + catch { + Write-Verbose "Invalid path for metadata lookup: $resolvedPath" + return $null + } + + $item = Get-Item -LiteralPath $resolvedPath -ErrorAction Stop + $versionInfo = $item.VersionInfo + + $sig = $null + try { + $sig = Get-AuthenticodeSignature -FilePath $item.FullName -ErrorAction Stop + } + catch { + Write-Verbose "Failed to get Authenticode signature for '$($item.FullName)': $_" + } + + return [pscustomobject]@{ + Path = $item.FullName + Exists = $true + CompanyName = if ($versionInfo) { $versionInfo.CompanyName } else { $null } + FileDescription = if ($versionInfo) { $versionInfo.FileDescription } else { $null } + ProductName = if ($versionInfo) { $versionInfo.ProductName } else { $null } + OriginalName = if ($versionInfo) { $versionInfo.OriginalFilename } else { $null } + FileVersion = if ($versionInfo) { $versionInfo.FileVersion } else { $null } + SignerSubject = if ($sig -and $sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { $null } + SignerIssuer = if ($sig -and $sig.SignerCertificate) { $sig.SignerCertificate.Issuer } else { $null } + SignerThumbprint = if ($sig -and $sig.SignerCertificate) { $sig.SignerCertificate.Thumbprint } else { $null } + SignatureStatus = if ($sig) { [string]$sig.Status } else { $null } + InferredVendor = Resolve-VendorFromText -Text @( + if ($versionInfo) { $versionInfo.CompanyName } + if ($versionInfo) { $versionInfo.FileDescription } + if ($versionInfo) { $versionInfo.ProductName } + $item.Name + ) + } + } + catch { + Write-Verbose "Get-FileMetadatum ignored error for path '$Path': $_" + return $null + } +} + +function Get-ServiceRegistryMap { + [CmdletBinding()] + [OutputType([System.Collections.Hashtable])] + param() + + $map = @{} + $servicesRoot = 'HKLM\SYSTEM\CurrentControlSet\Services' + + foreach ($svcName in @(Get-RegistryChildKeyNamesSafe -RegistryPath $servicesRoot)) { + $svcPath = "$servicesRoot\$svcName" + $props = Get-RegistryValuesSafe -RegistryPath $svcPath + + $imagePath = $null + $displayName = $null + $type = $null + $start = $null + $group = $null + + if ($null -ne $props) { + if ($props.PSObject.Properties.Name -contains 'ImagePath') { + $imagePath = $props.ImagePath + } + if ($props.PSObject.Properties.Name -contains 'DisplayName') { + $displayName = $props.DisplayName + } + if ($props.PSObject.Properties.Name -contains 'Type') { + $type = $props.Type + } + if ($props.PSObject.Properties.Name -contains 'Start') { + $start = $props.Start + } + if ($props.PSObject.Properties.Name -contains 'Group') { + $group = $props.Group + } + } + + $entry = [ordered]@{ + Name = $svcName + RegistryPath = $svcPath + ImagePath = $imagePath + DisplayName = $displayName + Type = $type + Start = $start + Group = $group + EnumPath = if (Test-RegistryPathExist -RegistryPath "$svcPath\Enum") { "$svcPath\Enum" } else { $null } + LinkagePath = if (Test-RegistryPathExist -RegistryPath "$svcPath\Linkage") { "$svcPath\Linkage" } else { $null } + ParamsPath = if (Test-RegistryPathExist -RegistryPath "$svcPath\Parameters") { "$svcPath\Parameters" } else { $null } + InstancesPath = if (Test-RegistryPathExist -RegistryPath "$svcPath\Instances") { "$svcPath\Instances" } else { $null } + } + + $map[$svcName.ToLowerInvariant()] = [pscustomobject]$entry + } + + return $map +} + +function Get-AdapterRegistryCorrelation { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $results = New-Object System.Collections.Generic.List[object] + $classRoot = 'HKLM\SYSTEM\CurrentControlSet\Control\Class\{4d36e972-e325-11ce-bfc1-08002be10318}' + $networkRoot = 'HKLM\SYSTEM\CurrentControlSet\Control\Network\{4d36e972-e325-11ce-bfc1-08002be10318}' + $tcpipInterfacesRoot = 'HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces' + + foreach ($classSub in @(Get-RegistryChildKeyNamesSafe -RegistryPath $classRoot)) { + if ($classSub -notmatch '^\d{4}$') { continue } + + $classPath = "$classRoot\$classSub" + $props = Get-RegistryValuesSafe -RegistryPath $classPath + if ($null -eq $props) { continue } + + $componentId = $props.ComponentId + $driverDesc = $props.DriverDesc + $providerName = $props.ProviderName + $netCfgInstanceId = $props.NetCfgInstanceId + + $networkPath = $null + $connectionPath = $null + $interfacePath = $null + $guid = $null + + if ($netCfgInstanceId) { + try { + $guid = ([guid]$netCfgInstanceId).Guid.ToLowerInvariant() + } + catch { + $guid = $netCfgInstanceId.Trim('{}').ToLowerInvariant() + } + + $candidateNetwork = "$networkRoot\{$guid}" + $candidateConnection = "$candidateNetwork\Connection" + $candidateInterface = "$tcpipInterfacesRoot\{$guid}" + + if (Test-RegistryPathExist -RegistryPath $candidateNetwork) { $networkPath = $candidateNetwork } + if (Test-RegistryPathExist -RegistryPath $candidateConnection) { $connectionPath = $candidateConnection } + if (Test-RegistryPathExist -RegistryPath $candidateInterface) { $interfacePath = $candidateInterface } + + $results.Add([pscustomobject]@{ + InterfaceGuid = $guid + ClassPath = $classPath + NetworkPath = $networkPath + ConnectionPath = $connectionPath + TcpipPath = $interfacePath + ComponentId = $componentId + DriverDesc = $driverDesc + ProviderName = $providerName + }) + } + } + + return $results.ToArray() +} + + +function Invoke-RegExport { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter(Mandatory = $true)] + [string]$Key, + + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [switch]$DryRun + ) + + if ($DryRun) { + return $FilePath + } + + $resolvedKey = Resolve-NetCleanRegistryPath -RegistryPath $Key + if ($resolvedKey.Contains('"') -or $FilePath.Contains('"')) { + throw 'Registry export key and output path must not contain double-quote characters.' + } + $quote = [char]34 + $regArgs = @('export', "$quote$resolvedKey$quote", "$quote$FilePath$quote", '/y') + + $proc = Start-Process -FilePath 'reg.exe' -ArgumentList $regArgs -NoNewWindow -Wait -PassThru + if ($null -eq $proc) { + throw "Failed to start reg.exe export for '$Key'" + } + + if ($proc.ExitCode -ne 0) { + throw "reg.exe export failed for '$Key' with exit code $($proc.ExitCode)" + } + + if (-not (Test-Path -LiteralPath $FilePath)) { + throw "reg.exe reported success but output file was not created: '$FilePath'" + } + + return $FilePath +} + +<# +.SYNOPSIS +Invoke a native executable and capture its output. +.DESCRIPTION +Executes a native executable with the specified arguments and captures its output, including any errors. +.PARAMETER FilePath +The path to the native executable. +.PARAMETER ArgumentList +The list of arguments to pass to the executable. +.PARAMETER Name +The name to use for the captured output. +.PARAMETER IgnoreExitCode +If specified, ignores the exit code of the executable. +.OUTPUTS +A custom object containing the execution results. +#> +function Invoke-NetCleanNativeCapture { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [string]$FilePath, + + [Parameter()] + [string[]]$ArgumentList = @(), + + [string]$Name = $FilePath, + + [switch]$IgnoreExitCode + ) + + try { + $output = & $FilePath @ArgumentList 2>&1 + $exitCode = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 } + + if (-not $IgnoreExitCode -and $exitCode -ne 0) { + return [pscustomobject]@{ + Name = $Name + ExitCode = $exitCode + Succeeded = $false + Output = @($output) + Error = (@($output) | Out-String).Trim() + } + } + + return [pscustomobject]@{ + Name = $Name + ExitCode = $exitCode + Succeeded = $true + Output = @($output) + Error = $null + } + } + catch { + return [pscustomobject]@{ + Name = $Name + ExitCode = -1 + Succeeded = $false + Output = @() + Error = $_.Exception.Message + } + } +} + + +# --------------------------------------------------------------------------- +# Workflow orchestration +# --------------------------------------------------------------------------- + +<# +.SYNOPSIS +Orchestrates the NetClean workflow across detect, protect, clean, and verify phases. +.DESCRIPTION +Coordinates the execution of the NetClean workflow by invoking each phase in sequence. Accepts parameters to control the mode of operation, backup paths, and which cleaning actions to perform or skip. Returns a context object containing detailed information about each phase's operations and results. +.PARAMETER Mode +Defines the cleaning mode to execute. Supported values are: +- 'Preview': Executes detect and protect phases, then returns context without making changes. +.PARAMETER BackupPath +Specifies the directory path where backups will be stored during the protect phase. +.PARAMETER DryRun +If set, simulates the workflow without performing any destructive actions, allowing for review of intended operations. +.PARAMETER SkipWifi +If set, skips the removal of Wi-Fi profiles during the clean phase. +.PARAMETER SkipDnsFlush +If set, skips flushing the DNS resolver cache during the clean phase. +.PARAMETER SkipEventLogs +If set, skips clearing network-related event logs during the clean phase. +.PARAMETER SkipUserArtifacts +If set, skips removing user artifacts during the clean phase. +.PARAMETER SkipFirewallBackup +If set, skips backing up firewall policies during the protect phase. +.PARAMETER PerformanceProfile +Specifies the validated performance profile used only with PerformanceTune mode. +.EXAMPLE +Invoke-NetCleanWorkflow -Mode 'SafeConferencePrep' -BackupPath 'C:\NetCleanBackups' -DryRun +.OUTPUTS +A context object containing detailed information about the operations performed in each phase of the NetClean workflow, including inventories, backups, cleaning actions, and verification results. +.NOTES +- Ensure that you have appropriate permissions to perform the operations in this workflow. +#> +function Invoke-NetCleanWorkflow { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Preview', 'SafeConferencePrep', 'AdvancedRepair', 'PerformanceTune')] + [string]$Mode, + + [Parameter(Mandatory = $true)] + [string]$BackupPath, + + [switch]$DryRun, + [switch]$SkipWifi, + [switch]$SkipDnsFlush, + [switch]$SkipEventLogs, + [switch]$SkipUserArtifacts, + [switch]$SkipFirewallBackup, + + [ValidateSet('Conservative', 'Optimal', 'Gaming', 'Default')] + [string]$PerformanceProfile + ) + + if ($Mode -eq 'PerformanceTune' -and [string]::IsNullOrWhiteSpace($PerformanceProfile)) { + throw "PerformanceProfile is required when Mode is 'PerformanceTune'." + } + + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + if ($Mode -eq 'PerformanceTune') { + Write-NetCleanLog -Level INFO -Message ('Workflow starting. Mode={0} BackupPath={1} DryRun={2} PerformanceProfile={3}' -f $Mode, $BackupPath, [bool]$DryRun, $PerformanceProfile) + } + else { + Write-NetCleanLog -Level INFO -Message ('Workflow starting. Mode={0} BackupPath={1} DryRun={2}' -f $Mode, $BackupPath, [bool]$DryRun) + } + } + + $timings = @{} + + # Phase 1 - Detect + $t0 = Get-Date + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + Write-NetCleanLog -Level INFO -Message ("Phase Detect start: {0}" -f $t0.ToString('s')) + } + + $ctx = Invoke-NetCleanPhase1Detect + + $t1 = Get-Date + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + Write-NetCleanLog -Level INFO -Message ("Phase Detect end: {0} (duration: {1})" -f $t1.ToString('s'), ($t1 - $t0).ToString()) + } + + $timings.Detect = [pscustomobject]@{ + Start = $t0 + End = $t1 + Duration = ($t1 - $t0) + } + + # Phase 2 - Protect + $t0 = Get-Date + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + Write-NetCleanLog -Level INFO -Message ("Phase Protect start: {0}" -f $t0.ToString('s')) + } + + $ctx = Invoke-NetCleanPhase2Protect ` + -Context $ctx ` + -BackupPath $BackupPath ` + -DryRun:$DryRun ` + -SkipFirewallBackup:$SkipFirewallBackup + + $backupPathFromProtect = $null + if ($ctx -and $ctx.PSObject.Properties.Name -contains 'BackupPath') { + $backupPathFromProtect = $ctx.BackupPath + } + + $t1 = Get-Date + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + Write-NetCleanLog -Level INFO -Message ("Phase Protect end: {0} (duration: {1})" -f $t1.ToString('s'), ($t1 - $t0).ToString()) + } + + $timings.Protect = [pscustomobject]@{ + Start = $t0 + End = $t1 + Duration = ($t1 - $t0) + } + + # Populate cached Wi-Fi and network profile lists + try { + if ($ctx.PSObject.Properties.Name -contains 'Protect' -and $ctx.Protect.PSObject.Properties.Name -contains 'Manifest') { + $manifest = $ctx.Protect.Manifest + $wifiFound = @() + + if ($manifest -and $manifest.WiFiExports -and $manifest.WiFiExports.Count -gt 0) { + foreach ($e in $manifest.WiFiExports) { + if ($e -is [string] -and $e -like 'PROFILE:*') { + $wifiFound += ($e -replace '^PROFILE:', '') + } + elseif ($e -is [string] -and $e -like '*.xml') { + $profileName = [System.IO.Path]::GetFileNameWithoutExtension($e) + $wifiFound += ($profileName -replace '^Wi-Fi-', '') + } + } + } + + if ($wifiFound.Count -eq 0) { + $wifiFound = @(Get-WiFiProfileName) + } + + Add-Member -InputObject $ctx.Protect.Summary -NotePropertyName WiFiProfilesFound -NotePropertyValue @($wifiFound) -Force + Add-Member -InputObject $ctx.Protect.Summary -NotePropertyName WiFiProfilesFoundCount -NotePropertyValue $wifiFound.Count -Force + + $netProfiles = @(Get-NetworkListProfileName) + Add-Member -InputObject $ctx.Protect.Summary -NotePropertyName NetworkProfilesFound -NotePropertyValue @($netProfiles) -Force + Add-Member -InputObject $ctx.Protect.Summary -NotePropertyName NetworkProfilesFoundCount -NotePropertyValue $netProfiles.Count -Force + } + } + catch { + Write-Verbose "Invoke-NetCleanWorkflow cache population: $($_.Exception.Message)" + } + + if ($Mode -eq 'Preview') { + Add-Member -InputObject $ctx -NotePropertyName Timings -NotePropertyValue $timings -Force + return $ctx + } + + # Phase 3 - Clean + $t0 = Get-Date + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + Write-NetCleanLog -Level INFO -Message ("Phase Clean start: {0}" -f $t0.ToString('s')) + } + + $cleanParameters = @{ + Context = $ctx + Mode = $Mode + DryRun = [bool]$DryRun + SkipWifi = [bool]$SkipWifi + SkipDnsFlush = [bool]$SkipDnsFlush + SkipEventLogs = [bool]$SkipEventLogs + SkipUserArtifacts = [bool]$SkipUserArtifacts + } + + if ($Mode -eq 'PerformanceTune') { + $cleanParameters.PerformanceProfile = $PerformanceProfile + } + + $ctx = Invoke-NetCleanPhase3Clean @cleanParameters + + if ($backupPathFromProtect -and -not ($ctx.PSObject.Properties.Name -contains 'BackupPath')) { + Add-Member -InputObject $ctx -NotePropertyName BackupPath -NotePropertyValue $backupPathFromProtect -Force + } + + if ($Mode -eq 'PerformanceTune' -and $PerformanceProfile) { + Add-Member -InputObject $ctx -NotePropertyName PerformanceProfile -NotePropertyValue $PerformanceProfile -Force + } + + $t1 = Get-Date + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + Write-NetCleanLog -Level INFO -Message ("Phase Clean end: {0} (duration: {1})" -f $t1.ToString('s'), ($t1 - $t0).ToString()) + } + + $timings.Clean = [pscustomobject]@{ + Start = $t0 + End = $t1 + Duration = ($t1 - $t0) + } + + # Phase 4 - Verify + $t0 = Get-Date + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + Write-NetCleanLog -Level INFO -Message ("Phase Verify start: {0}" -f $t0.ToString('s')) + } + + $ctx = Invoke-NetCleanPhase4Verify -Context $ctx + + if ($backupPathFromProtect -and -not ($ctx.PSObject.Properties.Name -contains 'BackupPath')) { + Add-Member -InputObject $ctx -NotePropertyName BackupPath -NotePropertyValue $backupPathFromProtect -Force + } + + if ($Mode -eq 'PerformanceTune') { + Add-Member -InputObject $ctx -NotePropertyName PerformanceProfile -NotePropertyValue $PerformanceProfile -Force + } + + $t1 = Get-Date + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + Write-NetCleanLog -Level INFO -Message ("Phase Verify end: {0} (duration: {1})" -f $t1.ToString('s'), ($t1 - $t0).ToString()) + } + + $timings.Verify = [pscustomobject]@{ + Start = $t0 + End = $t1 + Duration = ($t1 - $t0) + } + + Add-Member -InputObject $ctx -NotePropertyName Timings -NotePropertyValue $timings -Force + + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + Write-NetCleanLog -Level INFO -Message ('Workflow complete. Mode={0} DryRun={1}' -f $Mode, [bool]$DryRun) + } + + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + $manifestFile = $null + if ($ctx.PSObject.Properties.Name -contains 'Protect' -and $ctx.Protect.PSObject.Properties.Name -contains 'ManifestFile') { + $manifestFile = $ctx.Protect.ManifestFile + } + + $backupPathVal = $null + if ($ctx -and $ctx.PSObject.Properties.Name -contains 'BackupPath') { + $backupPathVal = $ctx.BackupPath + } + + $backupPathDisplay = if ([string]::IsNullOrWhiteSpace($backupPathVal)) { '(none)' } else { $backupPathVal } + + Write-NetCleanLog -Level INFO -Message ('Final summary: Mode={0} DryRun={1} BackupPath={2} ManifestFile={3}' -f $Mode, [bool]$DryRun, $backupPathDisplay, $manifestFile) + + $wifiProfiles = @() + if ($ctx.PSObject.Properties.Name -contains 'Clean' -and $ctx.Clean.PSObject.Properties.Name -contains 'WiFi') { + $wifiProfiles = @($ctx.Clean.WiFi.Profiles) + } + Write-NetCleanLog -Level INFO -Message ("Wi-Fi profiles removed/wouldRemove: {0}" -f ($wifiProfiles -join ', ')) + + $removedRegs = [System.Collections.Generic.List[string]]::new() + if ($ctx.PSObject.Properties.Name -contains 'Clean' -and $ctx.Clean.PSObject.Properties.Name -contains 'RegistryArtifacts') { + $results = @($ctx.Clean.RegistryArtifacts.Results) + foreach ($r in $results) { + if ($r.Removed) { + [void]$removedRegs.Add($r.RegistryPath) + } + } + } + + Write-NetCleanLog -Level INFO -Message ("Registry artifacts removed count: {0}" -f $removedRegs.Count) + foreach ($rp in $removedRegs) { + Write-NetCleanLog -Level INFO -Message ("Registry removed: {0}" -f $rp) + } + + $eventCount = 0 + if ($ctx.PSObject.Properties.Name -contains 'Clean' -and $ctx.Clean.PSObject.Properties.Name -contains 'EventLogs') { + $eventCount = @($ctx.Clean.EventLogs).Count + } + Write-NetCleanLog -Level INFO -Message ("Event logs touched: {0}" -f $eventCount) + + $userTouched = [System.Collections.Generic.List[string]]::new() + if ($ctx.PSObject.Properties.Name -contains 'Clean' -and $ctx.Clean.PSObject.Properties.Name -contains 'UserArtifacts') { + foreach ($u in @($ctx.Clean.UserArtifacts)) { + if ($u.Removed) { + [void]$userTouched.Add($u.Path) + } + } + } + Write-NetCleanLog -Level INFO -Message ("User artifacts touched count: {0}" -f $userTouched.Count) + + if ($ctx.PSObject.Properties.Name -contains 'Verify') { + Write-NetCleanLog -Level INFO -Message ("Verification passed: {0}" -f $ctx.Verify.Summary.Passed) + Write-NetCleanLog -Level INFO -Message ("Missing vendors: {0}" -f (@($ctx.Verify.VendorComparison.Missing) -join ', ')) + Write-NetCleanLog -Level INFO -Message ("Missing GUIDs: {0}" -f (@($ctx.Verify.GuidComparison.Missing) -join ', ')) + Write-NetCleanLog -Level INFO -Message ("Missing services: {0}" -f (@($ctx.Verify.ServiceComparison.Missing) -join ', ')) + } + + $verifyPassed = $false + if ($ctx.PSObject.Properties.Name -contains 'Verify') { + $verifyPassed = [bool]$ctx.Verify.Summary.Passed + } + + Write-Information ('NetClean final summary: Mode={0} DryRun={1} WiFiRemoved={2} RegistryRemoved={3} VerifyPassed={4}' -f $Mode, [bool]$DryRun, $wifiProfiles.Count, $removedRegs.Count, $verifyPassed) -InformationAction Continue + } + + return $ctx +} + +# --------------------------------------------------------------------------- +# Aliases +# --------------------------------------------------------------------------- + +Set-Alias -Name Convert-NormalizeGuid -Value Convert-Guid -Force +Set-Alias -Name Normalize-Guid -Value Convert-Guid -Force +Set-Alias -Name Backup-ProtectedRegistryKeys -Value Export-ProtectedRegistryKey -Force +Set-Alias -Name Backup-NetworkList -Value Export-NetworkList -Force +Set-Alias -Name Backup-WiFiProfiles -Value Export-WiFiProfile -Force +Set-Alias -Name Export-ProtectedRegistryKeys -Value Export-ProtectedRegistryKey -Force + +# --------------------------------------------------------------------------- +# Module exports +# --------------------------------------------------------------------------- + +Export-ModuleMember -Function @( + 'Invoke-NetCleanPhase1Detect', + 'Invoke-NetCleanPhase2Protect', + 'Invoke-NetCleanPhase3Clean', + 'Invoke-NetCleanPhase4Verify', + 'Invoke-NetCleanWorkflow', + 'Get-NetCleanLogFile', + 'Start-NetCleanLog', + 'Write-NetCleanLog' +) -Alias @( + 'Backup-NetworkList', + 'Backup-ProtectedRegistryKeys', + 'Backup-WiFiProfiles' +) diff --git a/Modules/NetCleanPhase1.ps1 b/Modules/NetCleanPhase1.ps1 new file mode 100644 index 0000000..44bed04 --- /dev/null +++ b/Modules/NetCleanPhase1.ps1 @@ -0,0 +1,1530 @@ +# --------------------------------------------------------------------------- +# Phase 1 - Detect helpers +# --------------------------------------------------------------------------- + +<# +.SYNOPSIS +Retrieves evidence of WFP (Windows Filtering Platform) state information. +.DESCRIPTION +Scans the WFP state to identify installed filter objects and extracts relevant properties. Attempts to infer the vendor based on known patterns. +.EXAMPLE +Get-WfpStateEvidence +.OUTPUTS +System.Object[] - A collection of custom objects representing WFP filter objects, including properties such as Name, DisplayName, Path, Publisher, and InferredVendor. +.NOTES +- Requires administrative privileges to access WFP state information. +#> +function Get-WfpStateEvidence { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $results = New-Object System.Collections.Generic.List[object] + $tempFile = Join-Path $env:TEMP ("netclean_wfp_{0}.xml" -f ([guid]::NewGuid().Guid)) + + try { + & netsh wfp show state file="$tempFile" 2>$null | Out-Null + + if (-not (Test-Path -LiteralPath $tempFile)) { + return @() + } + + [xml]$xml = Get-Content -LiteralPath $tempFile -Raw -ErrorAction Stop + $xmlNodes = @() + + if ($xml -and $xml.DocumentElement) { + $xmlNodes = $xml.SelectNodes('//*') + } + + foreach ($node in @($xmlNodes)) { + $textParts = @() + + foreach ($prop in @('displayData', 'name', 'description', 'serviceName', 'providerKey', 'calloutKey', 'layerKey')) { + try { + $value = $node.$prop + if ($value) { + $textParts += ($value | Out-String).Trim() + } + } + catch { + Write-Verbose "Failed to extract property '$prop' from WFP XML node: $_" + } + } + + $joined = ($textParts | Where-Object { $_ }) -join ' ' + if ([string]::IsNullOrWhiteSpace($joined)) { + continue + } + + $vendor = Resolve-VendorFromText -Text @($joined) + + $results.Add([pscustomobject]@{ + Source = 'WFP' + ProductClass = 'WfpObject' + Name = $joined + DisplayName = $joined + Path = $null + Publisher = $null + InstallPath = $null + InterfaceDescription = $null + Manufacturer = $null + CompanyName = $null + FileDescription = $null + ProductName = $null + SignerSubject = $null + InferredVendor = $vendor + XmlNodeName = $node.Name + Instance = $node.OuterXml + }) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + finally { + if (Test-Path -LiteralPath $tempFile) { + Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue + } + } + + return $results.ToArray() | Sort-Object Name -Unique +} + +<# +.SYNOPSIS +Retrieves evidence of NDIS filter classes from the registry. +.DESCRIPTION +Scans the registry under the NDIS class keys to identify installed network filter classes. Extracts relevant properties and attempts to infer the vendor based on known patterns. +.EXAMPLE +Get-NdisFilterClassEvidence +.OUTPUTS +A collection of custom objects representing NDIS filter class evidence, including properties such as Name, DisplayName, Publisher, and InferredVendor. +.NOTES +- Requires administrative privileges for full access to registry keys. +#> +function Get-NdisFilterClassEvidence { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $results = New-Object 'System.Collections.Generic.List[object]' + $classRoot = 'HKLM\SYSTEM\CurrentControlSet\Control\Class\{4d36e974-e325-11ce-bfc1-08002be10318}' + + foreach ($child in @(Get-RegistryChildKeyNamesSafe -RegistryPath $classRoot)) { + if ($child -notmatch '^\d{4}$') { continue } + + $path = "$classRoot\$child" + $props = Get-RegistryValuesSafe -RegistryPath $path + if ($null -eq $props) { continue } + + $text = New-Object 'System.Collections.Generic.List[string]' + + foreach ($propertyName in @( + 'ComponentId', + 'DriverDesc', + 'ProviderName', + 'MatchingDeviceId', + 'FilterClass', + 'Characteristic' + )) { + if ($props.PSObject.Properties.Name -contains $propertyName) { + $value = $props.$propertyName + if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) { + $text.Add([string]$value) + } + } + } + + if ($text.Count -eq 0) { continue } + + $driverDesc = if ($props.PSObject.Properties.Name -contains 'DriverDesc') { $props.DriverDesc } else { $null } + $providerName = if ($props.PSObject.Properties.Name -contains 'ProviderName') { $props.ProviderName } else { $null } + $componentId = if ($props.PSObject.Properties.Name -contains 'ComponentId') { $props.ComponentId } else { $null } + + $vendor = Resolve-VendorFromText -Text $text.ToArray() + + $results.Add([pscustomobject]@{ + Source = 'NDIS' + ProductClass = 'NdisFilterClass' + Name = ($text.ToArray() -join ' | ') + DisplayName = $driverDesc + Path = $null + Publisher = $providerName + InstallPath = $null + InterfaceDescription = $driverDesc + Manufacturer = $providerName + CompanyName = $providerName + FileDescription = $driverDesc + ProductName = $componentId + SignerSubject = $null + InferredVendor = $vendor + RegistryPath = $path + ComponentId = $componentId + Instance = $props + }) + } + + return $results.ToArray() +} + +<# +.SYNOPSIS +Retrieves evidence of NDIS service bindings from the registry. +.DESCRIPTION +Scans the registry under the Services key to identify services that may be related to NDIS bindings. Extracts relevant properties and attempts to infer the vendor based on known patterns. +.EXAMPLE +Get-NdisServiceBindingEvidence +.OUTPUTS +System.Object[] - A collection of custom objects representing NDIS service bindings, including properties such as Name, DisplayName, Path, Publisher, and InferredVendor. +.NOTES +- Requires administrative privileges for full access to registry keys. +#> +function Get-NdisServiceBindingEvidence { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $results = New-Object System.Collections.Generic.List[object] + $servicesRoot = 'HKLM\SYSTEM\CurrentControlSet\Services' + + foreach ($svcName in @(Get-RegistryChildKeyNamesSafe -RegistryPath $servicesRoot)) { + $svcPath = "$servicesRoot\$svcName" + $linkage = Get-RegistryValuesSafe -RegistryPath "$svcPath\Linkage" + $props = Get-RegistryValuesSafe -RegistryPath $svcPath + + $tokens = New-Object System.Collections.Generic.List[string] + $tokens.Add([string]$svcName) + + $displayName = $null + $group = $null + $imagePath = $null + $bindValues = @() + $exportValues = @() + $routeValues = @() + + if ($null -ne $props) { + if ($props.PSObject.Properties.Name -contains 'DisplayName') { + $displayName = $props.DisplayName + if ($null -ne $displayName -and "$displayName".Trim() -ne '') { + $tokens.Add([string]$displayName) + } + } + + if ($props.PSObject.Properties.Name -contains 'Group') { + $group = $props.Group + if ($null -ne $group -and "$group".Trim() -ne '') { + $tokens.Add([string]$group) + } + } + + if ($props.PSObject.Properties.Name -contains 'ImagePath') { + $imagePath = $props.ImagePath + } + } + + if ($null -ne $linkage) { + if ($linkage.PSObject.Properties.Name -contains 'Bind') { + $bindValues = @($linkage.Bind) + foreach ($value in $bindValues) { + if ($null -ne $value -and "$value".Trim() -ne '') { + $tokens.Add([string]$value) + } + } + } + + if ($linkage.PSObject.Properties.Name -contains 'Export') { + $exportValues = @($linkage.Export) + foreach ($value in $exportValues) { + if ($null -ne $value -and "$value".Trim() -ne '') { + $tokens.Add([string]$value) + } + } + } + + if ($linkage.PSObject.Properties.Name -contains 'Route') { + $routeValues = @($linkage.Route) + foreach ($value in $routeValues) { + if ($null -ne $value -and "$value".Trim() -ne '') { + $tokens.Add([string]$value) + } + } + } + } + + $tokenArray = @($tokens | Where-Object { $null -ne $_ -and "$_".Trim() -ne '' }) + if ($tokenArray.Count -eq 0) { continue } + + $joined = ($tokenArray | ForEach-Object { $_.ToString() }) -join ' ' + $vendor = Resolve-VendorFromText -Text @($joined) + + if ($joined.ToLowerInvariant() -match 'ndis|filter|lwf|wfp|vpn|fw|firewall|net|vmswitch|vmnet|vbox|vethernet|packet|inspect|falcon|sentinel|zscaler|globalprotect|forti|anyconnect') { + $results.Add([pscustomobject]@{ + Source = 'NDIS' + ProductClass = 'NdisServiceBinding' + Name = $svcName + DisplayName = if ($null -ne $displayName -and "$displayName".Trim() -ne '') { $displayName } else { $svcName } + Path = $imagePath + Publisher = $null + InstallPath = $null + InterfaceDescription = $null + Manufacturer = $null + CompanyName = $null + FileDescription = $null + ProductName = $null + SignerSubject = $null + InferredVendor = $vendor + RegistryPath = $svcPath + Instance = [pscustomobject]@{ + Service = $props + Linkage = $linkage + } + }) + } + } + + return $results.ToArray() +} + +<# +.SYNOPSIS +Retrieves evidence of installed MSI products from the registry. +.DESCRIPTION +Scans the registry under the MSI product keys to identify installed products. Extracts relevant properties such as DisplayName, Publisher, and InstallLocation. Attempts to infer the vendor based on these properties and known patterns. +.EXAMPLE +Get-MsiRegistryEvidence +.OUTPUTS +System.Object[] - A collection of custom objects representing installed MSI products, including properties such as Name +.NOTES +Requires appropriate permissions to access the registry keys for installed MSI products. +#> +function Get-MsiRegistryEvidence { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $results = New-Object 'System.Collections.Generic.List[object]' + + foreach ($root in @( + 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products', + 'HKLM\SOFTWARE\Classes\Installer\Products' + )) { + foreach ($child in @(Get-RegistryChildKeyNamesSafe -RegistryPath $root)) { + $productPath = "$root\$child\InstallProperties" + $props = Get-RegistryValuesSafe -RegistryPath $productPath + if ($null -eq $props) { continue } + + $displayName = if ($props.PSObject.Properties.Name -contains 'DisplayName') { + $props.DisplayName + } + else { + $null + } + + if ([string]::IsNullOrWhiteSpace([string]$displayName)) { continue } + + $publisher = if ($props.PSObject.Properties.Name -contains 'Publisher') { + $props.Publisher + } + else { + $null + } + + $installLocation = if ($props.PSObject.Properties.Name -contains 'InstallLocation') { + $props.InstallLocation + } + else { + $null + } + + $uninstallString = if ($props.PSObject.Properties.Name -contains 'UninstallString') { + $props.UninstallString + } + else { + $null + } + + $vendorText = New-Object 'System.Collections.Generic.List[string]' + foreach ($value in @($displayName, $publisher, $installLocation, $uninstallString)) { + if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) { + $vendorText.Add([string]$value) + } + } + + $vendor = Resolve-VendorFromText -Text $vendorText.ToArray() + + $results.Add([pscustomobject]@{ + Source = 'MSI' + ProductClass = 'MsiProduct' + Name = $displayName + DisplayName = $displayName + Path = $null + Publisher = $publisher + InstallPath = $installLocation + InterfaceDescription = $null + Manufacturer = $publisher + CompanyName = $publisher + FileDescription = $null + ProductName = $displayName + SignerSubject = $null + InferredVendor = $vendor + RegistryPath = $productPath + Instance = $props + }) + } + } + + return $results.ToArray() | Sort-Object Name -Unique +} + +<# +.SYNOPSIS +Retrieves evidence of network-related INF files from the system. +.DESCRIPTION +Scans the Windows INF directory for files matching the pattern 'oem*.inf'. Extracts relevant properties such as Provider, Manufacturer, Class, and ClassGuid. Attempts to infer the vendor based on these properties and known patterns. +.EXAMPLE +Get-InfFileEvidence +.OUTPUTS +System.Object[] - A collection of custom objects representing network-related INF files, including properties such as Name, DisplayName, Path, Publisher, and InferredVendor. +.NOTES +Requires appropriate permissions to access the INF directory and read INF files. +#> +function Get-InfFileEvidence { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $results = New-Object System.Collections.Generic.List[object] + $infDirs = @("$env:windir\INF") + + foreach ($dir in $infDirs) { + if (-not (Test-Path -LiteralPath $dir)) { continue } + + foreach ($file in @(Get-ChildItem -LiteralPath $dir -Filter 'oem*.inf' -File -ErrorAction SilentlyContinue)) { + try { + $content = Get-Content -LiteralPath $file.FullName -ErrorAction Stop + + $provider = $null + $manufacturer = $null + $class = $null + $classGuid = $null + + foreach ($line in $content) { + $m = [regex]::Match($line, '^\s*Provider\s*=\s*(.+)$') + if (-not $provider -and $m.Success) { + $provider = $m.Groups[1].Value.Trim().Trim('"').Trim('%') + } + + $m = [regex]::Match($line, '^\s*Manufacturer\s*=\s*(.+)$') + if (-not $manufacturer -and $m.Success) { + $manufacturer = $m.Groups[1].Value.Trim().Trim('"').Trim('%') + } + + $m = [regex]::Match($line, '^\s*Class\s*=\s*(.+)$') + if (-not $class -and $m.Success) { + $class = $m.Groups[1].Value.Trim().Trim('"') + } + + $m = [regex]::Match($line, '^\s*ClassGuid\s*=\s*(.+)$') + if (-not $classGuid -and $m.Success) { + $classGuid = $m.Groups[1].Value.Trim().Trim('"') + } + + if ($provider -and $manufacturer -and $class -and $classGuid) { + break + } + } + + $vendor = Resolve-VendorFromText -Text @($provider, $manufacturer, $class, $file.Name) + + if ($vendor -or ($class -and $class -match 'Net|NetService')) { + $results.Add([pscustomobject]@{ + Source = 'INF' + ProductClass = 'SetupApiInf' + Name = $file.Name + DisplayName = $file.Name + Path = $file.FullName + Publisher = $provider + InstallPath = Split-Path -Path $file.FullName -Parent + InterfaceDescription = $null + Manufacturer = $manufacturer + CompanyName = $provider + FileDescription = $class + ProductName = $file.Name + SignerSubject = $null + InferredVendor = $vendor + Class = $class + ClassGuid = $classGuid + Instance = $null + }) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + } + } + + return $results.ToArray() +} + +<# +.SYNOPSIS +Retrieves evidence of scheduled tasks from the system. +.DESCRIPTION +Queries the system for scheduled tasks and extracts relevant properties. Attempts to infer the vendor based on known patterns in task names, paths, and actions. +.EXAMPLE +Get-ScheduledTaskEvidence +.OUTPUTS +System.Object[] - A collection of custom objects representing scheduled tasks, including properties such as Name, DisplayName, Path, Publisher, and InferredVendor. +.NOTES +- Requires appropriate permissions to access scheduled task information. +#> +function Get-ScheduledTaskEvidence { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $results = New-Object System.Collections.Generic.List[object] + + try { + $tasks = Get-ScheduledTask -ErrorAction Stop + foreach ($task in $tasks) { + $actions = @($task.Actions) + $actionText = @() + + foreach ($action in $actions) { + $actionText += $action.Execute + $actionText += $action.Arguments + $actionText += $action.WorkingDirectory + } + + $vendor = Resolve-VendorFromText -Text @( + $task.TaskName, + $task.TaskPath, + $actionText + ) + + if ($vendor) { + $results.Add([pscustomobject]@{ + Source = 'ScheduledTask' + ProductClass = 'ScheduledTask' + Name = $task.TaskName + DisplayName = $task.TaskName + Path = ($actionText -join ' ') + Publisher = $null + InstallPath = $null + InterfaceDescription = $null + Manufacturer = $null + CompanyName = $null + FileDescription = $null + ProductName = $task.TaskName + SignerSubject = $null + InferredVendor = $vendor + TaskPath = $task.TaskPath + Instance = $task + }) + } + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + return $results.ToArray() +} + +<# +.SYNOPSIS +Retrieves evidence of AppX packages from the system. +.DESCRIPTION +Queries the system for installed AppX packages and extracts relevant properties. Attempts to infer the vendor based on known patterns. +.EXAMPLE +Get-AppxPackageEvidence +.OUTPUTS +System.Object[] - A collection of custom objects representing AppX packages, including properties such as Name, DisplayName, Path, Publisher, and InferredVendor. +.NOTES +- Requires appropriate permissions to access AppX package information for all users. +#> +function Get-AppxPackageEvidence { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $results = New-Object System.Collections.Generic.List[object] + + try { + $packages = Get-AppxPackage -AllUsers -ErrorAction Stop + foreach ($pkg in $packages) { + $vendor = Resolve-VendorFromText -Text @( + $pkg.Name, + $pkg.PackageFamilyName, + $pkg.PublisherDisplayName, + $pkg.InstallLocation + ) + + if ($vendor) { + $results.Add([pscustomobject]@{ + Source = 'AppX' + ProductClass = 'AppxPackage' + Name = $pkg.Name + DisplayName = $pkg.Name + Path = $pkg.InstallLocation + Publisher = $pkg.PublisherDisplayName + InstallPath = $pkg.InstallLocation + InterfaceDescription = $null + Manufacturer = $pkg.PublisherDisplayName + CompanyName = $pkg.PublisherDisplayName + FileDescription = $null + ProductName = $pkg.Name + SignerSubject = $pkg.Publisher + InferredVendor = $vendor + PackageFamilyName = $pkg.PackageFamilyName + Instance = $pkg + }) + } + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + return $results.ToArray() +} + +<# +.SYNOPSIS +Retrieves evidence of antivirus and firewall products from the Security Center WMI namespace. +.DESCRIPTION +Queries the 'root/SecurityCenter2' WMI namespace for instances of 'AntivirusProduct' and 'FirewallProduct'. For each product found, extracts relevant properties and attempts to infer the vendor based on known patterns. Also retrieves file metadata for the product executable to enrich the evidence. +.EXAMPLE +Get-ProtectionEvidence +.OUTPUTS +System.Object[] - A collection of custom objects representing antivirus and firewall products, including properties such as Name, DisplayName, Path, Publisher, and InferredVendor. +.NOTES +- Requires administrative privileges to access the 'root/SecurityCenter2' WMI namespace. +#> +function Get-ProtectionEvidence { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $evidence = New-Object System.Collections.Generic.List[object] + + try { + $avProducts = Get-CimInstance -Namespace 'root/SecurityCenter2' -ClassName 'AntivirusProduct' -ErrorAction Stop + foreach ($item in $avProducts) { + $meta = Get-FileMetadatum -Path $item.pathToSignedProductExe + $evidence.Add([pscustomobject]@{ + Source = 'SecurityCenter2' + ProductClass = 'AntivirusProduct' + Name = $item.displayName + DisplayName = $item.displayName + Path = $item.pathToSignedProductExe + Publisher = if ($meta) { $meta.CompanyName } else { $null } + InstallPath = $null + InterfaceDescription = $null + Manufacturer = $null + CompanyName = if ($meta) { $meta.CompanyName } else { $null } + FileDescription = if ($meta) { $meta.FileDescription } else { $null } + ProductName = if ($meta) { $meta.ProductName } else { $null } + SignerSubject = if ($meta) { $meta.SignerSubject } else { $null } + InferredVendor = if ($meta -and $meta.InferredVendor) { $meta.InferredVendor } else { (Resolve-VendorFromText -Text @($item.displayName)) } + Instance = $item + }) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + try { + $fwProducts = Get-CimInstance -Namespace 'root/SecurityCenter2' -ClassName 'FirewallProduct' -ErrorAction Stop + foreach ($item in $fwProducts) { + $meta = Get-FileMetadatum -Path $item.pathToSignedProductExe + $evidence.Add([pscustomobject]@{ + Source = 'SecurityCenter2' + ProductClass = 'FirewallProduct' + Name = $item.displayName + DisplayName = $item.displayName + Path = $item.pathToSignedProductExe + Publisher = if ($meta) { $meta.CompanyName } else { $null } + InstallPath = $null + InterfaceDescription = $null + Manufacturer = $null + CompanyName = if ($meta) { $meta.CompanyName } else { $null } + FileDescription = if ($meta) { $meta.FileDescription } else { $null } + ProductName = if ($meta) { $meta.ProductName } else { $null } + SignerSubject = if ($meta) { $meta.SignerSubject } else { $null } + InferredVendor = if ($meta -and $meta.InferredVendor) { $meta.InferredVendor } else { (Resolve-VendorFromText -Text @($item.displayName)) } + Instance = $item + }) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + try { + $services = Get-CimInstance Win32_Service -ErrorAction Stop + foreach ($svc in $services) { + $meta = Get-FileMetadatum -Path $svc.PathName + $evidence.Add([pscustomobject]@{ + Source = 'Service' + ProductClass = 'Service' + Name = $svc.Name + DisplayName = $svc.DisplayName + Path = $svc.PathName + Publisher = if ($meta) { $meta.CompanyName } else { $null } + InstallPath = $null + InterfaceDescription = $null + Manufacturer = $null + CompanyName = if ($meta) { $meta.CompanyName } else { $null } + FileDescription = if ($meta) { $meta.FileDescription } else { $null } + ProductName = if ($meta) { $meta.ProductName } else { $null } + SignerSubject = if ($meta) { $meta.SignerSubject } else { $null } + InferredVendor = if ($meta -and $meta.InferredVendor) { $meta.InferredVendor } else { (Resolve-VendorFromText -Text @($svc.Name, $svc.DisplayName, $svc.PathName)) } + State = $svc.State + StartMode = $svc.StartMode + ServiceType = $svc.ServiceType + Instance = $svc + }) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + try { + $drivers = Get-CimInstance Win32_SystemDriver -ErrorAction Stop + foreach ($drv in $drivers) { + $meta = Get-FileMetadatum -Path $drv.PathName + $evidence.Add([pscustomobject]@{ + Source = 'Driver' + ProductClass = 'Driver' + Name = $drv.Name + DisplayName = $drv.DisplayName + Path = $drv.PathName + Publisher = if ($meta) { $meta.CompanyName } else { $null } + InstallPath = $null + InterfaceDescription = $null + Manufacturer = $null + CompanyName = if ($meta) { $meta.CompanyName } else { $null } + FileDescription = if ($meta) { $meta.FileDescription } else { $null } + ProductName = if ($meta) { $meta.ProductName } else { $null } + SignerSubject = if ($meta) { $meta.SignerSubject } else { $null } + InferredVendor = if ($meta -and $meta.InferredVendor) { $meta.InferredVendor } else { (Resolve-VendorFromText -Text @($drv.Name, $drv.DisplayName, $drv.PathName)) } + State = $drv.State + StartMode = $drv.StartMode + ServiceType = $drv.ServiceType + Instance = $drv + }) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + foreach ($root in @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + )) { + try { + Get-ItemProperty -Path $root -ErrorAction SilentlyContinue | ForEach-Object { + if ($_.DisplayName) { + $meta = $null + if ($_.DisplayIcon) { + $meta = Get-FileMetadatum -Path $_.DisplayIcon + } + + $evidence.Add([pscustomobject]@{ + Source = 'Uninstall' + ProductClass = 'InstalledProduct' + Name = $_.DisplayName + DisplayName = $_.DisplayName + Path = $_.DisplayIcon + Publisher = $_.Publisher + InstallPath = $_.InstallLocation + InterfaceDescription = $null + Manufacturer = $_.Publisher + CompanyName = if ($meta) { $meta.CompanyName } else { $_.Publisher } + FileDescription = if ($meta) { $meta.FileDescription } else { $null } + ProductName = if ($meta) { $meta.ProductName } else { $_.DisplayName } + SignerSubject = if ($meta) { $meta.SignerSubject } else { $null } + InferredVendor = if ($meta -and $meta.InferredVendor) { $meta.InferredVendor } else { (Resolve-VendorFromText -Text @($_.DisplayName, $_.Publisher, $_.InstallLocation, $_.UninstallString)) } + UninstallString = $_.UninstallString + Instance = $_ + }) + } + } + } + catch { + Write-Verbose "Ignored error: $_" + } + } + + try { + $adapters = Get-NetAdapter -IncludeHidden -ErrorAction Stop + foreach ($adapter in $adapters) { + $vendor = Resolve-VendorFromText -Text @( + $adapter.Name, + $adapter.InterfaceDescription, + $adapter.DriverDescription, + $adapter.DriverFileName + ) + + $guidValue = $null + try { + $guidValue = $adapter.InterfaceGuid.Guid.ToString().ToLowerInvariant() + } + catch { + try { + $guidValue = $adapter.InterfaceGuid.ToString().Trim('{}').ToLowerInvariant() + } + catch { + $guidValue = $null + } + } + + $evidence.Add([pscustomobject]@{ + Source = 'NetAdapter' + ProductClass = 'Adapter' + Name = $adapter.Name + DisplayName = $adapter.Name + Path = $null + Publisher = $null + InstallPath = $null + InterfaceDescription = $adapter.InterfaceDescription + Manufacturer = $null + CompanyName = $null + FileDescription = $null + ProductName = $adapter.InterfaceDescription + SignerSubject = $null + InferredVendor = $vendor + InterfaceGuid = $guidValue + MacAddress = $adapter.MacAddress + Status = $adapter.Status + Instance = $adapter + }) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + try { + $pnpNet = Get-PnpDevice -Class Net -ErrorAction Stop + foreach ($dev in $pnpNet) { + $vendor = Resolve-VendorFromText -Text @( + $dev.FriendlyName, + $dev.Manufacturer, + $dev.InstanceId + ) + + $evidence.Add([pscustomobject]@{ + Source = 'PnpDevice' + ProductClass = 'NetDevice' + Name = $dev.FriendlyName + DisplayName = $dev.FriendlyName + Path = $null + Publisher = $null + InstallPath = $null + InterfaceDescription = $dev.FriendlyName + Manufacturer = $dev.Manufacturer + CompanyName = $dev.Manufacturer + FileDescription = $null + ProductName = $dev.FriendlyName + SignerSubject = $null + InferredVendor = $vendor + InstanceId = $dev.InstanceId + Status = $dev.Status + Class = $dev.Class + Instance = $dev + }) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + $servicesRoot = 'HKLM\SYSTEM\CurrentControlSet\Services' + try { + foreach ($svcName in @(Get-RegistryChildKeyNamesSafe -RegistryPath $servicesRoot)) { + $svcRegPath = "$servicesRoot\$svcName" + $svcProps = Get-RegistryValuesSafe -RegistryPath $svcRegPath + if ($null -eq $svcProps) { continue } + + $imagePath = $svcProps.ImagePath + $displayName = $svcProps.DisplayName + $meta = Get-FileMetadatum -Path $imagePath + + $evidence.Add([pscustomobject]@{ + Source = 'ServiceRegistry' + ProductClass = 'ServiceRegistry' + Name = $svcName + DisplayName = $displayName + Path = $imagePath + Publisher = if ($meta) { $meta.CompanyName } else { $null } + InstallPath = $null + InterfaceDescription = $null + Manufacturer = $null + CompanyName = if ($meta) { $meta.CompanyName } else { $null } + FileDescription = if ($meta) { $meta.FileDescription } else { $null } + ProductName = if ($meta) { $meta.ProductName } else { $null } + SignerSubject = if ($meta) { $meta.SignerSubject } else { $null } + InferredVendor = if ($meta -and $meta.InferredVendor) { $meta.InferredVendor } else { (Resolve-VendorFromText -Text @($svcName, $displayName, $imagePath)) } + ServiceRegistryPath = $svcRegPath + Start = $svcProps.Start + Type = $svcProps.Type + Group = $svcProps.Group + Instance = $svcProps + }) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + foreach ($item in @(Get-WfpStateEvidence)) { $evidence.Add($item) } + foreach ($item in @(Get-NdisFilterClassEvidence)) { $evidence.Add($item) } + foreach ($item in @(Get-NdisServiceBindingEvidence)) { $evidence.Add($item) } + foreach ($item in @(Get-MsiRegistryEvidence)) { $evidence.Add($item) } + foreach ($item in @(Get-InfFileEvidence)) { $evidence.Add($item) } + foreach ($item in @(Get-ScheduledTaskEvidence)) { $evidence.Add($item) } + foreach ($item in @(Get-AppxPackageEvidence)) { $evidence.Add($item) } + + return $evidence.ToArray() +} + +<# +.SYNOPSIS +Builds the protection inventory used by later workflow phases. +.DESCRIPTION +Correlates collected system evidence with known protection vendors and returns +the services, drivers, adapters, interface GUIDs, registry keys, and evidence +that later phases must preserve. +.EXAMPLE +Get-ProtectionInventory +.OUTPUTS +System.Object[] +#> +function Get-ProtectionInventory { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $evidence = @(Get-ProtectionEvidence) + $signatures = Get-VendorSignature + $serviceMap = Get-ServiceRegistryMap + $adapterCorrelation = @(Get-AdapterRegistryCorrelation) + $inventory = New-Object System.Collections.Generic.List[object] + + foreach ($vendor in $signatures.Keys) { + $signature = $signatures[$vendor] + $matched = New-Object System.Collections.Generic.List[object] + + foreach ($item in $evidence) { + if (Test-VendorPatternMatch -Vendor $vendor -Signature $signature -Evidence $item) { + $matched.Add($item) + } + } + + if ($matched.Count -eq 0) { + continue + } + + $services = New-Object System.Collections.Generic.HashSet[string] + $drivers = New-Object System.Collections.Generic.HashSet[string] + $adapters = New-Object System.Collections.Generic.HashSet[string] + $adapterGuids = New-Object System.Collections.Generic.HashSet[string] + $registryKeys = New-Object System.Collections.Generic.HashSet[string] + $evidenceStrings = New-Object System.Collections.Generic.HashSet[string] + $categories = New-Object System.Collections.Generic.HashSet[string] + + Add-HashSetValue -Set $categories -Values $signature.Categories + Add-HashSetValue -Set $registryKeys -Values $signature.RegistryRoots + + foreach ($item in $matched) { + if ($item.Source -in @('Service', 'ServiceRegistry', 'NDIS')) { + if ($item.Name) { + [void]$services.Add($item.Name) + [void]$registryKeys.Add("HKLM\SYSTEM\CurrentControlSet\Services\$($item.Name)") + } + if ($item.PSObject.Properties.Name -contains 'RegistryPath' -and $item.RegistryPath) { + [void]$registryKeys.Add($item.RegistryPath) + } + } + + if ($item.Source -eq 'Driver') { + if ($item.Name) { + [void]$drivers.Add($item.Name) + [void]$registryKeys.Add("HKLM\SYSTEM\CurrentControlSet\Services\$($item.Name)") + } + } + + if ($item.Source -in @('NetAdapter', 'PnpDevice')) { + if ($item.DisplayName) { [void]$adapters.Add($item.DisplayName) } + if ($item.InterfaceDescription) { [void]$adapters.Add($item.InterfaceDescription) } + if ($item.PSObject.Properties.Name -contains 'InterfaceGuid' -and $item.InterfaceGuid) { + [void]$adapterGuids.Add($item.InterfaceGuid) + } + } + + if ($item.PSObject.Properties.Name -contains 'InstallPath') { + Add-HashSetValue -Set $registryKeys -Values (Get-VendorRootsFromInstallPath -InstallPath $item.InstallPath) + } + + $label = @( + $item.Source, + $item.Name, + $item.DisplayName, + $item.Path, + $item.InterfaceDescription, + $item.CompanyName, + $item.InferredVendor + ) | Where-Object { $_ } | Select-Object -First 5 + + if ($label.Count -gt 0) { + [void]$evidenceStrings.Add(($label -join ' | ')) + } + } + + foreach ($svc in @($services)) { + $key = $svc.ToLowerInvariant() + if ($serviceMap.ContainsKey($key)) { + $svcInfo = $serviceMap[$key] + + foreach ($candidate in @( + $svcInfo.RegistryPath, + $svcInfo.EnumPath, + $svcInfo.LinkagePath, + $svcInfo.ParamsPath, + $svcInfo.InstancesPath + )) { + if (-not [string]::IsNullOrWhiteSpace($candidate)) { + [void]$registryKeys.Add($candidate) + } + } + + $imgMeta = Get-FileMetadatum -Path $svcInfo.ImagePath + if ($imgMeta -and $imgMeta.Path) { + [void]$evidenceStrings.Add("ServiceBinary: $($imgMeta.Path)") + } + } + } + + foreach ($drv in @($drivers)) { + $key = $drv.ToLowerInvariant() + if ($serviceMap.ContainsKey($key)) { + $drvInfo = $serviceMap[$key] + foreach ($candidate in @( + $drvInfo.RegistryPath, + $drvInfo.EnumPath, + $drvInfo.LinkagePath, + $drvInfo.ParamsPath, + $drvInfo.InstancesPath + )) { + if (-not [string]::IsNullOrWhiteSpace($candidate)) { + [void]$registryKeys.Add($candidate) + } + } + } + } + + foreach ($corr in $adapterCorrelation) { + $adapterText = @( + $corr.ComponentId, + $corr.DriverDesc, + $corr.ProviderName + ) -join ' ' + + $adapterText = $adapterText.ToLowerInvariant() + $matchedByAdapter = $false + + foreach ($pattern in $signature.Patterns) { + if ($adapterText -like "*$pattern*") { + $matchedByAdapter = $true + break + } + } + + if ($matchedByAdapter) { + if ($corr.InterfaceGuid) { [void]$adapterGuids.Add($corr.InterfaceGuid) } + + foreach ($candidate in @($corr.ClassPath, $corr.NetworkPath, $corr.ConnectionPath, $corr.TcpipPath)) { + if (-not [string]::IsNullOrWhiteSpace($candidate)) { + [void]$registryKeys.Add($candidate) + } + } + + if ($corr.DriverDesc) { [void]$adapters.Add($corr.DriverDesc) } + if ($corr.ProviderName) { [void]$evidenceStrings.Add("AdapterProvider: $($corr.ProviderName)") } + } + } + + foreach ($svc in @($services)) { + $svcl = $svc.ToLowerInvariant() + switch -Wildcard ($svcl) { + 'vm*' { [void]$categories.Add('VirtualAdapter'); [void]$categories.Add('Hypervisor') } + '*vbox*' { [void]$categories.Add('VirtualAdapter'); [void]$categories.Add('Hypervisor') } + '*falcon*' { [void]$categories.Add('EDR'); [void]$categories.Add('XDR') } + '*sentinel*' { [void]$categories.Add('EDR'); [void]$categories.Add('XDR') } + '*defend*' { [void]$categories.Add('AV') } + '*fire*' { [void]$categories.Add('Firewall') } + '*vpn*' { [void]$categories.Add('VPN') } + } + } + + foreach ($adapter in @($adapters)) { + $al = $adapter.ToLowerInvariant() + switch -Wildcard ($al) { + '*vmware*' { [void]$categories.Add('Hypervisor'); [void]$categories.Add('VirtualAdapter') } + '*virtualbox*' { [void]$categories.Add('Hypervisor'); [void]$categories.Add('VirtualAdapter') } + '*vbox*' { [void]$categories.Add('Hypervisor'); [void]$categories.Add('VirtualAdapter') } + '*hyper-v*' { [void]$categories.Add('Hypervisor'); [void]$categories.Add('VirtualAdapter') } + '*vethernet*' { [void]$categories.Add('VirtualAdapter') } + '*vpn*' { [void]$categories.Add('VPN') } + } + } + + $sourceWeights = @{ + 'SecurityCenter2' = 12 + 'Service' = 8 + 'Driver' = 10 + 'ServiceRegistry' = 8 + 'NetAdapter' = 6 + 'PnpDevice' = 6 + 'WFP' = 10 + 'NDIS' = 9 + 'MSI' = 5 + 'INF' = 5 + 'ScheduledTask' = 4 + 'AppX' = 3 + 'Uninstall' = 4 + } + + $score = 10 + + foreach ($m in $matched) { + if ($sourceWeights.ContainsKey($m.Source)) { + $score += $sourceWeights[$m.Source] + } + else { + $score += 2 + } + } + + $score += (@($services).Count * 4) + $score += (@($drivers).Count * 5) + $score += (@($adapters).Count * 2) + $score += (@($adapterGuids).Count * 2) + + if ($matched | Where-Object { $_.Source -eq 'WFP' }) { + $score += 8 + [void]$categories.Add('NetworkFilter') + } + + if ($matched | Where-Object { $_.Source -eq 'NDIS' }) { + $score += 8 + [void]$categories.Add('NetworkFilter') + } + + $confidence = [Math]::Min(100, $score) + + $inventory.Add([pscustomobject]@{ + Vendor = $vendor + Categories = @($categories | Sort-Object -Unique) + Confidence = $confidence + Services = @($services | Sort-Object -Unique) + Drivers = @($drivers | Sort-Object -Unique) + Adapters = @($adapters | Sort-Object -Unique) + ProtectedInterfaceGuids = @($adapterGuids | Sort-Object -Unique) + RegistryKeys = @($registryKeys | Where-Object { $_ } | Sort-Object -Unique) + Evidence = @($evidenceStrings | Sort-Object -Unique) + RawEvidenceCount = $matched.Count + }) + } + + return $inventory.ToArray() | Sort-Object Vendor +} + +<# +.SYNOPSIS +Retrieves metadata for a specified file. +.DESCRIPTION +Gets detailed information about a file, including its version and company details. +.OUTPUTS +A PSCustomObject containing the file's metadata. +#> +<# Duplicate helper removed; canonical function is Get-FileMetadata. #> + +<# +.SYNOPSIS +Builds an inventory of protection software from collected evidence. +.DESCRIPTION +Collects vendor signatures and evidence sources to produce a prioritized inventory of detected protection products and related registry keys. +.OUTPUTS +A collection of PSCustomObject inventory entries. +#> +function Get-ProtectionRegistryMap { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter()] + [AllowNull()] + [object[]]$Inventory + ) + + if (-not $PSBoundParameters.ContainsKey('Inventory') -or $null -eq $Inventory) { + $Inventory = @(Get-ProtectionInventory) + } + + $result = New-Object System.Collections.Generic.List[object] + + foreach ($item in $Inventory) { + $keys = New-Object System.Collections.Generic.HashSet[string] + Add-HashSetValue -Set $keys -Values $item.RegistryKeys + + foreach ($svc in @($item.Services)) { + [void]$keys.Add("HKLM\SYSTEM\CurrentControlSet\Services\$svc") + foreach ($suffix in @('Parameters', 'Linkage', 'Enum', 'Instances')) { + $candidate = "HKLM\SYSTEM\CurrentControlSet\Services\$svc\$suffix" + if (Test-RegistryPathExist -RegistryPath $candidate) { + [void]$keys.Add($candidate) + } + } + } + + foreach ($drv in @($item.Drivers)) { + [void]$keys.Add("HKLM\SYSTEM\CurrentControlSet\Services\$drv") + foreach ($suffix in @('Parameters', 'Linkage', 'Enum', 'Instances')) { + $candidate = "HKLM\SYSTEM\CurrentControlSet\Services\$drv\$suffix" + if (Test-RegistryPathExist -RegistryPath $candidate) { + [void]$keys.Add($candidate) + } + } + } + + foreach ($guid in @($item.ProtectedInterfaceGuids)) { + if ([string]::IsNullOrWhiteSpace($guid)) { continue } + $g = $guid.Trim('{}').ToLowerInvariant() + + foreach ($candidate in @( + "HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{$g}", + "HKLM\SYSTEM\CurrentControlSet\Control\Network\{4d36e972-e325-11ce-bfc1-08002be10318}\{$g}", + "HKLM\SYSTEM\CurrentControlSet\Control\Network\{4d36e972-e325-11ce-bfc1-08002be10318}\{$g}\Connection" + )) { + if (Test-RegistryPathExist -RegistryPath $candidate) { + [void]$keys.Add($candidate) + } + } + } + + $result.Add([pscustomobject]@{ + Vendor = $item.Vendor + Categories = $item.Categories + Confidence = $item.Confidence + Services = $item.Services + Drivers = $item.Drivers + Adapters = $item.Adapters + ProtectedInterfaceGuids = $item.ProtectedInterfaceGuids + RegistryKeys = @($keys | Sort-Object -Unique) + Evidence = $item.Evidence + }) + } + + return $result.ToArray() | Sort-Object Vendor +} + +<# +.SYNOPSIS +Returns the set of protected interface GUIDs from inventory. +.DESCRIPTION +Creates a unique set of interface GUIDs marked as protected in an inventory. +.OUTPUTS +A list of GUID strings. +#> +function Get-ProtectedInterfaceGuidSet { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter()] + [AllowNull()] + [object[]]$Inventory + ) + + if (-not $PSBoundParameters.ContainsKey('Inventory') -or $null -eq $Inventory) { + $Inventory = @(Get-ProtectionInventory) + } + + $set = New-Object System.Collections.Generic.HashSet[string] + + foreach ($item in $Inventory) { + foreach ($guid in @($item.ProtectedInterfaceGuids)) { + if ([string]::IsNullOrWhiteSpace($guid)) { continue } + [void]$set.Add($guid.Trim('{}').ToLowerInvariant()) + } + } + + return @($set) | Sort-Object +} + +<# +.SYNOPSIS +Enumerates candidate registry artifacts relevant to network history. +.DESCRIPTION +Finds registry locations and interface-specific entries that may contain network history or metadata; marks whether each is protected by inventory. +.OUTPUTS +A collection of artifact candidate PSCustomObjects. +#> +function Get-NetworkPrivacyArtifactCandidate { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter()] + [AllowNull()] + [object[]]$Inventory + ) + + if (-not $PSBoundParameters.ContainsKey('Inventory') -or $null -eq $Inventory) { + $Inventory = @(Get-ProtectionInventory) + } + + $protectedGuids = @(Get-ProtectedInterfaceGuidSet -Inventory $Inventory) + $protectedGuidSet = New-Object System.Collections.Generic.HashSet[string] + Add-HashSetValue -Set $protectedGuidSet -Values $protectedGuids + + $candidates = New-Object System.Collections.Generic.List[object] + + foreach ($path in @( + 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles', + 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Signatures', + 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Signatures\Managed', + 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Signatures\Unmanaged' + )) { + if (Test-RegistryPathExist -RegistryPath $path) { + $candidates.Add([pscustomobject]@{ + ArtifactType = 'NetworkList' + RegistryPath = $path + InterfaceGuid = $null + IsProtected = $false + CanSanitize = $true + Reason = 'Network profile/signature history' + }) + } + } + + $tcpipRoot = 'HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces' + foreach ($child in @(Get-RegistryChildKeyNamesSafe -RegistryPath $tcpipRoot)) { + $raw = $child.Trim('{}') + $guid = $raw.ToLowerInvariant() + + $isGuid = $false + try { + [void][guid]$guid + $isGuid = $true + } + catch { + $isGuid = $false + } + + if (-not $isGuid) { continue } + + $path = "$tcpipRoot\{$guid}" + $isProtected = $protectedGuidSet.Contains($guid) + + $candidates.Add([pscustomobject]@{ + ArtifactType = 'TcpipInterface' + RegistryPath = $path + InterfaceGuid = $guid + IsProtected = $isProtected + CanSanitize = $false + Reason = if ($isProtected) { 'Protected by inventory correlation' } else { 'Preserved adapter configuration; not safe for recursive deletion' } + }) + } + + $networkRoot = 'HKLM\SYSTEM\CurrentControlSet\Control\Network\{4d36e972-e325-11ce-bfc1-08002be10318}' + foreach ($child in @(Get-RegistryChildKeyNamesSafe -RegistryPath $networkRoot)) { + $raw = $child.Trim('{}') + $guid = $raw.ToLowerInvariant() + + $isGuid = $false + try { + [void][guid]$guid + $isGuid = $true + } + catch { + $isGuid = $false + } + + if (-not $isGuid) { continue } + + foreach ($path in @( + "$networkRoot\{$guid}", + "$networkRoot\{$guid}\Connection" + )) { + if (Test-RegistryPathExist -RegistryPath $path) { + $isProtected = $protectedGuidSet.Contains($guid) + $candidates.Add([pscustomobject]@{ + ArtifactType = 'NetworkControl' + RegistryPath = $path + InterfaceGuid = $guid + IsProtected = $isProtected + CanSanitize = $false + Reason = if ($isProtected) { 'Protected by inventory correlation' } else { 'Preserved adapter configuration; not safe for recursive deletion' } + }) + } + } + } + + return $candidates.ToArray() +} + +<# +.SYNOPSIS +Filters artifact candidates to those safe to sanitize. +.DESCRIPTION +Returns artifacts from `Get-NetworkPrivacyArtifactCandidate` that are not marked protected by inventory. +.OUTPUTS +A collection of sanitizable artifact PSCustomObjects. +#> +function Get-SanitizableNetworkArtifact { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter()] + [AllowNull()] + [object[]]$Inventory + ) + + if (-not $PSBoundParameters.ContainsKey('Inventory') -or $null -eq $Inventory) { + $Inventory = @(Get-ProtectionInventory) + } + + return @( + Get-NetworkPrivacyArtifactCandidate -Inventory $Inventory | + Where-Object { + -not $_.IsProtected -and + ($_.PSObject.Properties.Name -notcontains 'CanSanitize' -or $_.CanSanitize) + } + ) +} + +<# +.SYNOPSIS +Run phase 1 detection to build context for subsequent phases. +.DESCRIPTION +Runs detection routines to assemble Inventory, ProtectionRegistryMap, candidate and sanitizable artifacts and returns a context object used by later phases. +.OUTPUTS +A PSCustomObject containing detection context and summary. +#> +function Invoke-NetCleanPhase1Detect { + [CmdletBinding()] + [OutputType([System.Object])] + param() + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'Phase 1 detection started.' + } + + $managementState = Get-NetCleanDeviceManagementState + $inventory = @(Get-ProtectionInventory) + $protectionMap = @(Get-ProtectionRegistryMap -Inventory $inventory) + $protectedGuids = @(Get-ProtectedInterfaceGuidSet -Inventory $inventory) + $candidateArtifacts = @(Get-NetworkPrivacyArtifactCandidate -Inventory $inventory) + $sanitizableArtifacts = @( + $candidateArtifacts | + Where-Object { + -not $_.IsProtected -and + ($_.PSObject.Properties.Name -notcontains 'CanSanitize' -or $_.CanSanitize) + } + ) + + $protectedRegistryPaths = @( + $protectionMap | + ForEach-Object { $_.RegistryKeys } | + Where-Object { $_ } | + Sort-Object -Unique + ) + + $result = [pscustomobject]@{ + ModuleVersion = $script:NetCleanModuleVersion + Phase = 'Detect' + DetectedAt = Get-Date + ManagementState = $managementState + Inventory = $inventory + ProtectionRegistryMap = $protectionMap + ProtectedInterfaceGuids = $protectedGuids + CandidateArtifacts = $candidateArtifacts + SanitizableArtifacts = $sanitizableArtifacts + ProtectedRegistryPaths = $protectedRegistryPaths + Summary = [pscustomobject]@{ + ProtectedVendorsCount = @($inventory).Count + ProtectedInterfaceGuidCount = @($protectedGuids).Count + CandidateArtifactCount = @($candidateArtifacts).Count + SanitizableArtifactCount = @($sanitizableArtifacts).Count + ManagedDevice = [bool]$managementState.IsManaged + } + } + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Device management state: {0}" -f $managementState.JoinType) + Write-NetCleanLog -Level INFO -Message ("Protected vendors detected: {0}" -f $result.Summary.ProtectedVendorsCount) + Write-NetCleanLog -Level INFO -Message ("Protected interface GUIDs detected: {0}" -f $result.Summary.ProtectedInterfaceGuidCount) + Write-NetCleanLog -Level INFO -Message ("Candidate artifacts detected: {0}" -f $result.Summary.CandidateArtifactCount) + Write-NetCleanLog -Level INFO -Message ("Sanitizable artifacts identified: {0}" -f $result.Summary.SanitizableArtifactCount) + + foreach ($artifact in @($sanitizableArtifacts)) { + $parts = New-Object System.Collections.Generic.List[string] + + if ($artifact.PSObject.Properties.Name -contains 'ArtifactType' -and $artifact.ArtifactType) { + [void]$parts.Add("Type=$($artifact.ArtifactType)") + Write-NetCleanLog -Level DEBUG -Message ("Evaluating artifact of type: {0}" -f $artifact.ArtifactType) + } + if ($artifact.PSObject.Properties.Name -contains 'Name' -and $artifact.Name) { + [void]$parts.Add("Name=$($artifact.Name)") + Write-NetCleanLog -Level DEBUG -Message ("Evaluating artifact named: {0}" -f $artifact.Name) + } + if ($artifact.PSObject.Properties.Name -contains 'RegistryPath' -and $artifact.RegistryPath) { + [void]$parts.Add("RegistryPath=$($artifact.RegistryPath)") + Write-NetCleanLog -Level DEBUG -Message ("Evaluating artifact with registry path: {0}" -f $artifact.RegistryPath) + } + if ($artifact.PSObject.Properties.Name -contains 'Path' -and $artifact.Path) { + [void]$parts.Add("Path=$($artifact.Path)") + Write-NetCleanLog -Level DEBUG -Message ("Evaluating artifact with path: {0}" -f $artifact.Path) + } + + if ($parts.Count -gt 0) { + Write-NetCleanLog -Level INFO -Message ("Preview candidate: {0}" -f ($parts.ToArray() -join ' ')) + } + } + + # Additional detection summary for auditing + Write-NetCleanLog -Level INFO -Message ("Detected inventory entries: {0}" -f @($inventory).Count) + + $vendors = @($inventory | ForEach-Object { $_.Vendor } | Where-Object { $_ } | Sort-Object -Unique) + if ($vendors.Count -gt 0) { + Write-NetCleanLog -Level INFO -Message ("Detected vendors: {0}" -f ($vendors -join ', ')) + } + + if ($protectedRegistryPaths -and $protectedRegistryPaths.Count -gt 0) { + Write-NetCleanLog -Level INFO -Message ("Protected registry paths count: {0}" -f $protectedRegistryPaths.Count) + foreach ($p in $protectedRegistryPaths) { + Write-NetCleanLog -Level INFO -Message ("Protected registry path: {0}" -f $p) + } + } + + Write-NetCleanLog -Level INFO -Message ("Candidate artifacts: {0}, Sanitizable artifacts: {1}" -f $candidateArtifacts.Count, $sanitizableArtifacts.Count) + + # Brief console summary + Write-Information ("Phase 1 detection: Vendors={0} ProtectedPaths={1} SanitizableCandidates={2}" -f (@($vendors).Count), @($protectedRegistryPaths).Count, @($sanitizableArtifacts).Count) -InformationAction Continue + + Write-NetCleanLog -Level INFO -Message 'Phase 1 detection complete.' + } + + return $result +} + +Export-ModuleMember -Function @( + 'Invoke-NetCleanPhase1Detect' +) diff --git a/Modules/NetCleanPhase2.ps1 b/Modules/NetCleanPhase2.ps1 new file mode 100644 index 0000000..a1c7d9b --- /dev/null +++ b/Modules/NetCleanPhase2.ps1 @@ -0,0 +1,954 @@ +# --------------------------------------------------------------------------- +# Phase 2 - Protect helpers +# --------------------------------------------------------------------------- + +<# +.SYNOPSIS +Export specified registry keys to .reg files in a provider-safe manner. +.DESCRIPTION +For each registry path provided, performs an export using `reg.exe` to ensure provider safety. Exports are saved to the specified destination directory with timestamped filenames. If `-DryRun` is specified, simulates the export process and returns the intended file paths without performing any exports. +.PARAMETER Paths +Array of registry key paths to export. +.PARAMETER Dest +Destination directory for exported files. +.PARAMETER DryRun +If specified, simulates the export process and returns the intended file paths without performing any exports. +.EXAMPLE +Export-ProtectedRegistryKey -Paths @('HKLM\SYSTEM\CurrentControlSet\Services\MyService', 'HKLM\SYSTEM\CurrentControlSet\Services\AnotherService') -Dest "C:\Backups\Registry" +This command exports the specified registry keys to .reg files in the given destination directory. +.OUTPUTS +Array of file paths for the exported .reg files. In dry-run mode, returns the intended file paths without creating any files. +.NOTES +- Ensure that the destination directory exists or can be created. +- The function relies on `reg.exe` for exporting registry keys, which may require appropriate permissions to execute successfully. +#> +function Export-ProtectedRegistryKey { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]]$Paths, + + [Parameter(Mandatory = $true)] + [string]$Dest, + + [switch]$DryRun + ) + + $exported = New-Object System.Collections.Generic.List[string] + if (-not $DryRun) { + New-DirectoryIfNotExist -Path $Dest + } + + foreach ($pathItem in @($Paths)) { + if ([string]::IsNullOrWhiteSpace($pathItem)) { continue } + + $candidate = $pathItem.Trim() + if ($candidate -notmatch '^(HKLM|HKEY_LOCAL_MACHINE|HKCU|HKEY_CURRENT_USER|HKCR|HKEY_CLASSES_ROOT|HKU|HKEY_USERS|HKCC|HKEY_CURRENT_CONFIG)(\\|:)?') { + $candidate = "HKLM\" + $candidate + } + + try { + $key = Convert-RegKeyPath -Path $candidate + } + catch { + Write-NetCleanLog -Level WARN -Message ("Skipping invalid registry path for export: {0}" -f $pathItem) + continue + } + + $safe = ($key -replace '[^a-zA-Z0-9_.-]', '_') + $file = Join-Path $Dest ("reg_backup_{0}_{1}.reg" -f $safe, (Get-Date -Format 'yyyyMMdd_HHmmss')) + + $result = Invoke-RegExport -Key $key -FilePath $file -DryRun:$DryRun + [void]$exported.Add($result) + } + + return $exported.ToArray() +} + +## Read human-friendly network profile names directly from the registry. +function Get-NetworkListProfileName { + [CmdletBinding()] + param() + + $root = Convert-RegToProviderPath -RegistryPath 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + $names = New-Object System.Collections.Generic.List[string] + try { + if (Test-Path -LiteralPath $root) { + $children = Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue + foreach ($c in $children) { + try { + $pn = Get-ItemProperty -LiteralPath $c.PSPath -Name 'ProfileName' -ErrorAction SilentlyContinue + if ($pn -and $pn.ProfileName) { [void]$names.Add($pn.ProfileName) } + } + catch { Write-Verbose "Get-NetworkListProfileName child: $($_.Exception.Message)" } + } + } + } + catch { Write-Verbose "Get-NetworkListProfileName: $($_.Exception.Message)" } + + return $names.ToArray() | Sort-Object -Unique +} + +<# +.SYNOPSIS +Export a set of registry keys to .reg files. +.DESCRIPTION +For each provided registry path, performs a provider-safe export to the destination directory. Honors `-DryRun` to simulate exports. +.PARAMETER Paths +Array of registry key paths to export. +.PARAMETER Dest +Destination directory for exported files. +.PARAMETER DryRun +If specified, no external export is performed and simulated results are returned. +.OUTPUTS +Array of exported file paths. +#> +function Export-NetworkList { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $true)] + [string]$Dest, + + [switch]$DryRun + ) + + if (-not $DryRun) { + New-DirectoryIfNotExist -Path $Dest + } + $key = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList' + $file = Join-Path $Dest ("NetworkList_{0}.reg" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + + return Invoke-RegExport -Key $key -FilePath $file -DryRun:$DryRun +} + +<# +.SYNOPSIS +Return Wi-Fi profile names present on the system. +.DESCRIPTION +Parses `netsh wlan show profiles` output to extract profile names; returns an empty list if none found. +.OUTPUTS +Array of Wi-Fi profile name strings. +#> +function Get-WiFiProfileName { + [CmdletBinding()] + [OutputType([string[]])] + param() + + $originalOutputEncoding = [Console]::OutputEncoding + try { + [Console]::OutputEncoding = $script:Utf8NoBom + $result = Invoke-NetCleanNativeCapture ` + -FilePath 'netsh.exe' ` + -ArgumentList @('wlan', 'show', 'profiles') ` + -Name 'List Wi-Fi profiles' ` + -IgnoreExitCode + } + finally { + [Console]::OutputEncoding = $originalOutputEncoding + } + + if (-not $result.Succeeded -or -not $result.Output -or $result.Output.Count -eq 0) { + Write-NetCleanLog -Level DEBUG -Message 'No Wi-Fi profiles returned by netsh.' + return [string[]]@() + } + + $profiles = [System.Collections.Generic.List[string]]::new() + + foreach ($line in $result.Output) { + if ($null -eq $line) { + continue + } + + $text = [string]$line + + if ($text -match '^\s*All User Profile\s*:\s*(.+?)\s*$') { + $name = $matches[1].Trim() + if (-not [string]::IsNullOrWhiteSpace($name)) { + [void]$profiles.Add($name) + } + continue + } + + if ($text -match '^\s*[^:]+:\s*(.+?)\s*$') { + $label = ($text -replace ':\s*.+$', '').Trim() + $name = $matches[1].Trim() + + if ($label -match 'Profile' -and -not [string]::IsNullOrWhiteSpace($name)) { + [void]$profiles.Add($name) + } + } + } + + [string[]]$finalProfiles = @($profiles | Sort-Object -Unique) + + Write-NetCleanLog -Level DEBUG -Message ("Detected Wi-Fi profiles: {0}" -f ($finalProfiles -join ', ')) + + return [string[]]$finalProfiles +} + +<# +.SYNOPSIS +Export Wi-Fi profiles to XML files and write a list of exported items. +.DESCRIPTION +For each Wi-Fi profile, exports to an XML file using `netsh wlan export profile`. A list file is also created containing the exported file paths. Honors `-DryRun` to simulate exports and return intended file paths without performing actual exports. +.PARAMETER Dest +Destination directory for exported Wi-Fi profile XML files and list file. +.PARAMETER DryRun +If specified, simulates the export process and returns the list of file paths that would have been created without performing any exports. +.OUTPUTS +Array of file paths for the exported Wi-Fi profile XML files and the list file. In dry-run mode, returns the intended file paths without creating any files. +.EXAMPLE +Export-WiFiProfile -Dest "C:\Backups\WiFiProfiles" +This command exports all Wi-Fi profiles to XML files in the specified directory and creates a list file with the exported profile names. +.EXAMPLE +Export-WiFiProfile -Dest "C:\Backups\WiFiProfiles" -DryRun +This command simulates the export process and returns the list of file paths that would have been created without performing any exports. +.NOTES +- Ensure that the destination directory exists or can be created. +- The function relies on `netsh` for exporting Wi-Fi profiles, which may require appropriate permissions to execute successfully. +#> +function Export-WiFiProfile { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $true)] + [string]$Dest, + + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + $exported = [System.Collections.Generic.List[string]]::new() + + $listFile = Join-Path $Dest ("WiFiProfiles_{0}.txt" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + $profiles = @(Get-WiFiProfileName) + + if ($profiles.Count -eq 0) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'No Wi-Fi profiles detected for backup.' + } + return @() + } + + if ($DryRun) { + [void]$exported.Add($listFile) + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would write Wi-Fi profile list file: {0}" -f $listFile) + } + + foreach ($wifiProfile in $profiles) { + [void]$exported.Add("PROFILE:$wifiProfile") + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would export Wi-Fi profile: {0}" -f $wifiProfile) + } + } + + return $exported.ToArray() + } + + New-DirectoryIfNotExist -Path $Dest + + WriteAllLines -Path $listFile -Contents $profiles -Encoding $script:Utf8NoBom + [void]$exported.Add($listFile) + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Exported Wi-Fi profile list: {0}" -f $listFile) + } + + $bulkSucceeded = $false + + try { + $before = @( + Get-ChildItem -Path $Dest -Filter '*.xml' -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + ) + + $bulkResult = Invoke-ExternalCommandSafe ` + -Name 'Export Wi-Fi profiles (bulk)' ` + -FilePath 'netsh.exe' ` + -ArgumentList @('wlan', 'export', 'profile', "folder=$Dest", 'key=clear') ` + -IgnoreExitCode + + $after = @( + Get-ChildItem -Path $Dest -Filter '*.xml' -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + ) + + $newFiles = @($after | Where-Object { $_ -notin $before }) + + if ($newFiles.Count -gt 0) { + foreach ($newFile in $newFiles) { + [void]$exported.Add($newFile) + } + + $bulkSucceeded = $true + + if ($canLog) { + foreach ($newFile in $newFiles) { + Write-NetCleanLog -Level INFO -Message ("Exported Wi-Fi profile to '{0}'" -f $newFile) + } + } + } + elseif ($canLog) { + Write-NetCleanLog -Level DEBUG -Message ("Bulk Wi-Fi export returned no new XML files. ExitCode={0}" -f $bulkResult.ExitCode) + } + } + catch { + $bulkSucceeded = $false + + if ($canLog) { + Write-NetCleanLog -Level WARN -Message ("Bulk Wi-Fi export failed: {0}" -f $_.Exception.Message) + } + } + + if (-not $bulkSucceeded) { + foreach ($wifiProfile in $profiles) { + try { + $before = @( + Get-ChildItem -Path $Dest -Filter '*.xml' -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + ) + + $profileResult = Invoke-ExternalCommandSafe ` + -Name ("Export Wi-Fi profile {0}" -f $wifiProfile) ` + -FilePath 'netsh.exe' ` + -ArgumentList @('wlan', 'export', 'profile', "name=$wifiProfile", "folder=$Dest", 'key=clear') ` + -IgnoreExitCode + + $after = @( + Get-ChildItem -Path $Dest -Filter '*.xml' -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + ) + + $newFiles = @($after | Where-Object { $_ -notin $before }) + + if ($newFiles.Count -eq 0) { + if ($canLog) { + Write-NetCleanLog -Level DEBUG -Message ("No XML exported for Wi-Fi profile '{0}'. ExitCode={1}" -f $wifiProfile, $profileResult.ExitCode) + } + continue + } + + foreach ($newFile in $newFiles) { + [void]$exported.Add($newFile) + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Exported Wi-Fi profile '{0}' to '{1}'" -f $wifiProfile, $newFile) + } + } + } + catch { + if ($canLog) { + Write-NetCleanLog -Level WARN -Message ("Per-profile Wi-Fi export failed for '{0}': {1}" -f $wifiProfile, $_.Exception.Message) + } + } + } + } + + return $exported.ToArray() +} + +<# +.SYNOPSIS +Export the Windows Firewall policy to a .wfw file. +.DESCRIPTION +Uses `netsh advfirewall export` to back up the firewall policy. Honors +`-DryRun` and returns the intended output path. +.PARAMETER Dest +Destination directory for the exported firewall policy. +.PARAMETER DryRun +Simulate export without running the external command. +.OUTPUTS +System.String +#> +function Export-FirewallPolicy { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter(Mandatory = $true)] + [string]$Dest, + + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if (-not $DryRun) { + New-DirectoryIfNotExist -Path $Dest + } + $file = Join-Path $Dest ("FirewallPolicy_{0}.wfw" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + + if ($canLog) { + if ($DryRun) { + Write-NetCleanLog -Level INFO -Message ("Would export firewall policy to: {0}" -f $file) + } + else { + Write-NetCleanLog -Level INFO -Message ("Exporting firewall policy to: {0}" -f $file) + } + } + + $result = Invoke-ExternalCommandSafe -Name 'Export firewall policy' -FilePath 'netsh.exe' -ArgumentList @('advfirewall', 'export', "`"$file`"") -DryRun:$DryRun + if (-not $result.Succeeded) { + if ($canLog) { + Write-NetCleanLog -Level ERROR -Message ("Firewall policy export failed: {0}" -f $result.Error) + } + throw $result.Error + } + + if ($canLog -and -not $DryRun) { + Write-NetCleanLog -Level INFO -Message ("Exported firewall policy: {0}" -f $file) + } + + return $file +} + +<# +.SYNOPSIS +Export protection inventory to JSON. +.DESCRIPTION +Writes the supplied inventory, or newly detected inventory when omitted, as +UTF-8 JSON. Honors `-DryRun` without creating directories or files. +.PARAMETER Dest +Destination directory for the inventory JSON file. +.PARAMETER Inventory +Optional inventory to serialize. Current protection inventory is detected when +the argument is omitted or null. +.PARAMETER DryRun +Return the intended output path without writing a file. +.OUTPUTS +System.String +#> +function Export-ProtectionInventory { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter(Mandatory = $true)] + [string]$Dest, + + [Parameter()] + [AllowNull()] + [object[]]$Inventory, + + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if (-not $DryRun) { + New-DirectoryIfNotExist -Path $Dest + } + + if (-not $PSBoundParameters.ContainsKey('Inventory') -or $null -eq $Inventory) { + Write-NetCleanLog -Level INFO -Message 'No inventory provided, performing detection to gather current protection inventory.' + $Inventory = @(Get-ProtectionInventory) + Write-NetCleanLog -Level INFO -Message ("Detected {0} inventory entries for export." -f @($Inventory).Count) + } + + $file = Join-Path $Dest ("ProtectionInventory_{0}.json" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + + if ($DryRun) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would export protection inventory to: {0}" -f $file) + } + return $file + } + + $json = $Inventory | ConvertTo-Json -Depth 8 + WriteAllText -Path $file -Contents $json -Encoding $script:Utf8NoBom + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Exported protection inventory to: {0}" -f $file) + } + + return $file +} + +<# +.SYNOPSIS +Export the protection registry map to JSON. +.DESCRIPTION +Derives the protection registry map from the supplied inventory and writes it +as UTF-8 JSON. Honors `-DryRun` without creating directories or files. +.PARAMETER Dest +Destination directory for the registry-map JSON file. +.PARAMETER Inventory +Optional inventory used to derive the registry map. +.PARAMETER DryRun +Return the intended output path without writing a file. +.OUTPUTS +System.String +#> +function Export-ProtectionRegistryMap { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter(Mandatory = $true)] + [string]$Dest, + + [Parameter()] + [AllowNull()] + [object[]]$Inventory, + + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if (-not $DryRun) { + New-DirectoryIfNotExist -Path $Dest + } + + $map = @(Get-ProtectionRegistryMap -Inventory $Inventory) + $file = Join-Path $Dest ("ProtectionRegistryMap_{0}.json" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + + if ($DryRun) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would export protection registry map to: {0}" -f $file) + } + return $file + } + + $json = $map | ConvertTo-Json -Depth 8 + WriteAllText -Path $file -Contents $json -Encoding $script:Utf8NoBom + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Exported protection registry map to: {0}" -f $file) + } + + return $file +} + +<# +.SYNOPSIS +Export sanitizable artifact list to JSON. +.DESCRIPTION +Serializes the list of sanitizable network artifacts to JSON in the destination folder. Honors `-DryRun`. +.PARAMETER Dest +Destination directory for the JSON file. +.PARAMETER Inventory +Optional inventory used to derive artifacts. +.PARAMETER DryRun +Simulate writing without creating files. +.OUTPUTS +Path to the JSON file. +#> +function Export-SanitizableNetworkArtifact { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter(Mandatory = $true)] + [string]$Dest, + + [Parameter()] + [AllowNull()] + [object[]]$Inventory, + + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if (-not $DryRun) { + New-DirectoryIfNotExist -Path $Dest + } + + $artifacts = @(Get-SanitizableNetworkArtifact -Inventory $Inventory) + $file = Join-Path $Dest ("SanitizableNetworkArtifact_{0}.json" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + + if ($DryRun) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would export sanitizable artifact inventory to: {0}" -f $file) + } + return $file + } + + $json = $artifacts | ConvertTo-Json -Depth 8 + WriteAllText -Path $file -Contents $json -Encoding $script:Utf8NoBom + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Exported sanitizable artifact inventory to: {0}" -f $file) + } + + return $file +} + +<# +.SYNOPSIS +Exports the network-adapter configuration that will be reset. +.DESCRIPTION +Writes a compact UTF-8 JSON snapshot of visible adapters, IPv4 DHCP state, +IPv4 addresses and routes, IPv4/IPv6 DNS servers, and the Windows IPv6 +DisabledComponents value. The snapshot is informational and is not restored +automatically because conference preparation intentionally returns unmanaged +adapters to DHCP and Quad9 Secure DNS. +.PARAMETER Dest +Private backup directory for the JSON snapshot. +.PARAMETER DryRun +Returns the planned output path without reading configuration or writing a file. +.OUTPUTS +System.String +#> +function Export-NetCleanAdapterConfiguration { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter(Mandatory = $true)] + [string]$Dest, + + [switch]$DryRun + ) + + $file = Join-Path $Dest ("AdapterConfiguration_{0}.json" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + if ($DryRun) { + return $file + } + + New-DirectoryIfNotExist -Path $Dest + + $adapters = @( + Get-NetAdapter -ErrorAction Stop | + Select-Object Name, + InterfaceDescription, + InterfaceIndex, + InterfaceGuid, + Status, + MacAddress, + LinkSpeed, + Virtual, + HardwareInterface + ) + + $interfaceIndices = [System.Collections.Generic.HashSet[uint32]]::new() + foreach ($adapter in $adapters) { + [void]$interfaceIndices.Add([uint32]$adapter.InterfaceIndex) + } + + $ipv4Interfaces = @( + Get-NetIPInterface -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $interfaceIndices.Contains([uint32]$_.InterfaceIndex) } | + Select-Object InterfaceAlias, + InterfaceIndex, + Dhcp, + ConnectionState, + InterfaceMetric + ) + + $ipv4Addresses = @( + Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $interfaceIndices.Contains([uint32]$_.InterfaceIndex) } | + Select-Object InterfaceAlias, + InterfaceIndex, + IPAddress, + PrefixLength, + PrefixOrigin, + SuffixOrigin, + AddressState, + SkipAsSource + ) + + $ipv4Routes = @( + Get-NetRoute -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $interfaceIndices.Contains([uint32]$_.InterfaceIndex) } | + Select-Object InterfaceAlias, + InterfaceIndex, + DestinationPrefix, + NextHop, + RouteMetric, + Protocol, + PolicyStore + ) + + $dnsServers = @( + Get-DnsClientServerAddress -ErrorAction Stop | + Where-Object { $interfaceIndices.Contains([uint32]$_.InterfaceIndex) } | + Select-Object InterfaceAlias, + InterfaceIndex, + AddressFamily, + ServerAddresses + ) + + $disabledComponents = 0 + $disabledComponentsPresent = $false + try { + $preference = Get-ItemProperty ` + -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters' ` + -Name 'DisabledComponents' ` + -ErrorAction Stop + if ($preference.PSObject.Properties.Name -contains 'DisabledComponents') { + $disabledComponents = [uint32]$preference.DisabledComponents + $disabledComponentsPresent = $true + } + } + catch { + Write-Verbose "IPv6 preference was not explicitly configured: $($_.Exception.Message)" + } + + $snapshot = [ordered]@{ + CapturedAt = (Get-Date).ToString('s') + Adapters = $adapters + IPv4Interfaces = $ipv4Interfaces + IPv4Addresses = $ipv4Addresses + IPv4Routes = $ipv4Routes + DnsServers = $dnsServers + IPv6Preference = [ordered]@{ + DisabledComponentsPresent = $disabledComponentsPresent + DisabledComponents = $disabledComponents + } + } + + $json = $snapshot | ConvertTo-Json -Depth 8 + WriteAllText -Path $file -Contents $json -Encoding $script:Utf8NoBom + return $file +} + +<# +.SYNOPSIS +Export the NetClean manifest containing backup and summary metadata. +.DESCRIPTION +Serializes the manifest hashtable to JSON in the destination directory. Honors `-DryRun` to avoid filesystem writes. +.PARAMETER Dest +Destination directory for the manifest file. +.PARAMETER Manifest +Hashtable describing backup artifacts and summary information. +.PARAMETER DryRun +If specified, operations are simulated and no files are written. +.EXAMPLE +$manifest = @{ + ExampleKey = 'ExampleValue' +} +.OUTPUTS +Path to the manifest JSON file. +.NOTES +Exports the provided manifest to a JSON file in the specified destination. The manifest should contain relevant metadata about the backup and protection summary. The function returns the path to the manifest file, whether it was actually written or simulated via `-DryRun`. +#> +function Export-NetCleanManifest { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter(Mandatory = $true)] + [string]$Dest, + + [Parameter(Mandatory = $true)] + [hashtable]$Manifest, + + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if (-not $DryRun) { + New-DirectoryIfNotExist -Path $Dest + } + $file = Join-Path $Dest ("RestoreManifest_{0}.json" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + + if ($DryRun) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would export restore manifest to: {0}" -f $file) + } + return $file + } + + $json = $Manifest | ConvertTo-Json -Depth 8 + WriteAllText -Path $file -Contents $json -Encoding $script:Utf8NoBom + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Exported restore manifest to: {0}" -f $file) + } + + return $file +} + +<# +.SYNOPSIS +Run the Phase 2 protection and backup workflow. +.DESCRIPTION +Creates the requested backups and metadata exports, then returns a new workflow +context containing the protection manifest and summary. Honors `-DryRun` +without creating directories, files, or external-command side effects. +.PARAMETER Context +Detection context returned by Phase 1. +.PARAMETER BackupPath +Destination directory for protection backups and metadata. +.PARAMETER DryRun +Plan backup operations without writing or invoking external commands. +.PARAMETER SkipFirewallBackup +Skip the Windows Firewall policy export. +.OUTPUTS +System.Object +#> +function Invoke-NetCleanPhase2Protect { + [CmdletBinding()] + [OutputType([System.Object])] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter(Mandatory = $true)] + [string]$BackupPath, + + [switch]$DryRun, + [switch]$SkipFirewallBackup + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Phase 2 protect started. BackupPath={0} DryRun={1}" -f $BackupPath, [bool]$DryRun) + } + + if (-not $DryRun) { + New-DirectoryIfNotExist -Path $BackupPath + Set-NetCleanPrivateDirectoryAcl -Path $BackupPath + } + + $inventory = @($Context.Inventory) + $protectedPaths = @($Context.ProtectedRegistryPaths | Sort-Object -Unique) + + $manifest = @{ + ModuleVersion = $script:NetCleanModuleVersion + BackupPath = $BackupPath + CreatedAt = (Get-Date).ToString('s') + ProtectionInventoryJson = $null + ProtectionRegistryMapJson = $null + SanitizableArtifactsJson = $null + AdapterConfigurationJson = $null + FirewallPolicyBackup = $null + NetworkListBackup = $null + WiFiExports = @() + ProtectedRegistryBackups = @() + } + + $manifest.ProtectionInventoryJson = Export-ProtectionInventory -Dest $BackupPath -Inventory $inventory -DryRun:$DryRun + $manifest.ProtectionRegistryMapJson = Export-ProtectionRegistryMap -Dest $BackupPath -Inventory $inventory -DryRun:$DryRun + $manifest.SanitizableArtifactsJson = Export-SanitizableNetworkArtifact -Dest $BackupPath -Inventory $inventory -DryRun:$DryRun + $manifest.AdapterConfigurationJson = Export-NetCleanAdapterConfiguration -Dest $BackupPath -DryRun:$DryRun + $manifest.NetworkListBackup = Export-NetworkList -Dest $BackupPath -DryRun:$DryRun + if ($canLog) { + if ($DryRun) { + Write-NetCleanLog -Level INFO -Message ("Would export network list to: {0}" -f $manifest.NetworkListBackup) + } + else { + Write-NetCleanLog -Level INFO -Message ("Exported network list to: {0}" -f $manifest.NetworkListBackup) + } + } + + $manifest.WiFiExports = @(Export-WiFiProfile -Dest $BackupPath -DryRun:$DryRun) + + if (-not $SkipFirewallBackup) { + try { + $manifest.FirewallPolicyBackup = Export-FirewallPolicy -Dest $BackupPath -DryRun:$DryRun + } + catch { + $manifest.FirewallPolicyBackup = $null + if ($canLog) { + Write-NetCleanLog -Level WARN -Message ("Firewall policy backup failed or was skipped due to error: {0}" -f $_.Exception.Message) + } + } + } + else { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'Skipping firewall policy backup by option.' + } + } + + if ($protectedPaths.Count -gt 0) { + $manifest.ProtectedRegistryBackups = @(Export-ProtectedRegistryKey -Paths $protectedPaths -Dest $BackupPath -DryRun:$DryRun) + + if ($canLog) { + if ($DryRun) { + Write-NetCleanLog -Level INFO -Message ("Would export protected registry backups for {0} paths." -f $protectedPaths.Count) + } + else { + Write-NetCleanLog -Level INFO -Message ("Exported protected registry backups for {0} paths." -f $protectedPaths.Count) + } + } + } + else { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'No protected registry paths required backup.' + } + } + + $manifestFile = Export-NetCleanManifest -Dest $BackupPath -Manifest $manifest -DryRun:$DryRun + + # Log detailed backup/export results and restoration instructions + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Protection manifest created: {0}" -f $manifestFile) + + if ($manifest.ProtectionInventoryJson) { Write-NetCleanLog -Level INFO -Message ("Protection inventory file: {0}" -f $manifest.ProtectionInventoryJson) } + if ($manifest.ProtectionRegistryMapJson) { Write-NetCleanLog -Level INFO -Message ("Protection registry map file: {0}" -f $manifest.ProtectionRegistryMapJson) } + if ($manifest.SanitizableArtifactsJson) { Write-NetCleanLog -Level INFO -Message ("Sanitizable artifacts file: {0}" -f $manifest.SanitizableArtifactsJson) } + if ($manifest.AdapterConfigurationJson) { Write-NetCleanLog -Level INFO -Message ("Adapter configuration snapshot: {0}" -f $manifest.AdapterConfigurationJson) } + if ($manifest.NetworkListBackup) { Write-NetCleanLog -Level INFO -Message ("NetworkList backup: {0}" -f $manifest.NetworkListBackup) } + + if ($manifest.WiFiExports -and $manifest.WiFiExports.Count -gt 0) { + foreach ($e in $manifest.WiFiExports) { + Write-NetCleanLog -Level INFO -Message ("Wi-Fi export: {0}" -f $e) + } + Write-NetCleanLog -Level INFO -Message ('To restore Wi-Fi profiles, run: netsh wlan add profile filename="" for each exported XML, or use the provided examples\restore-wifi-profiles.ps1 script.') + } + + if ($manifest.FirewallPolicyBackup) { + Write-NetCleanLog -Level INFO -Message ("Firewall policy backup: {0}" -f $manifest.FirewallPolicyBackup) + Write-NetCleanLog -Level INFO -Message ('To restore firewall policy, run: netsh advfirewall import ".wfw"') + } + + if ($manifest.ProtectedRegistryBackups -and $manifest.ProtectedRegistryBackups.Count -gt 0) { + foreach ($reg in $manifest.ProtectedRegistryBackups) { + if ($reg -is [string] -and $reg.StartsWith('ERROR:')) { + Write-NetCleanLog -Level WARN -Message ("Registry backup error: {0}" -f $reg) + } + else { + Write-NetCleanLog -Level INFO -Message ("Protected registry backup file: {0}" -f $reg) + } + } + Write-NetCleanLog -Level INFO -Message ('To restore registry keys, use: reg.exe import ".reg" (run as Administrator).') + } + } + + $newContext = [pscustomobject]@{} + foreach ($p in $Context.PSObject.Properties) { + Add-Member -InputObject $newContext -NotePropertyName $p.Name -NotePropertyValue $p.Value + } + + Add-Member -InputObject $newContext -NotePropertyName Phase -NotePropertyValue 'Protect' -Force + Add-Member -InputObject $newContext -NotePropertyName BackupPath -NotePropertyValue $BackupPath -Force + Add-Member -InputObject $newContext -NotePropertyName Protect -NotePropertyValue ([pscustomobject]@{ + Manifest = $manifest + ManifestFile = $manifestFile + Summary = [pscustomobject]@{ + ProtectedRegistryPathCount = $protectedPaths.Count + AdapterConfigurationBackupCount = @($manifest.AdapterConfigurationJson | Where-Object { $_ }).Count + WiFiBackupCount = @($manifest.WiFiExports).Count + ProtectedRegistryBackupCount = @($manifest.ProtectedRegistryBackups).Count + } + }) -Force + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Phase 2 protect complete. Manifest={0}" -f $manifestFile) + } + + return $newContext +} + +Export-ModuleMember -Function @( + 'Invoke-NetCleanPhase2Protect', + 'Export-ProtectedRegistryKey', + 'Export-NetworkList', + 'Get-WiFiProfileName', + 'Export-WiFiProfile', + 'Export-FirewallPolicy', + 'Export-ProtectionInventory', + 'Export-ProtectionRegistryMap', + 'Export-SanitizableNetworkArtifact', + 'Export-NetCleanManifest' +) -Alias @( + 'Backup-NetworkList', + 'Backup-ProtectedRegistryKeys', + 'Backup-WiFiProfiles' +) diff --git a/Modules/NetCleanPhase3.ps1 b/Modules/NetCleanPhase3.ps1 new file mode 100644 index 0000000..fedf38d --- /dev/null +++ b/Modules/NetCleanPhase3.ps1 @@ -0,0 +1,1896 @@ +# --------------------------------------------------------------------------- +# Phase 3 - Clean helpers +# --------------------------------------------------------------------------- + +<# +.SYNOPSIS +Removes Wi-Fi profiles safely (supports -WhatIf). +.DESCRIPTION +Deletes all user Wi-Fi profiles unless protected; supports `-DryRun`, `-WhatIf` and `-Confirm`. +.PARAMETER DryRun +If specified, operations are simulated and no destructive actions are performed. +.EXAMPLE +Remove-WiFiProfilesSafe -DryRun +#> +function Remove-WiFiProfilesSafe { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([pscustomobject])] + param( + [switch]$DryRun, + + [string[]]$WifiProfiles + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if ($PSBoundParameters.ContainsKey('WifiProfiles')) { + $profiles = @($WifiProfiles) + } + else { + $profiles = @(Get-WiFiProfileName) + } + + $removed = [System.Collections.Generic.List[string]]::new() + $operations = [System.Collections.Generic.List[object]]::new() + + if ($profiles.Count -eq 0) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'No Wi-Fi profiles found to remove.' + } + + return [pscustomobject]@{ + Removed = 0 + Profiles = @() + Operations = @() + } + } + + if ($DryRun) { + foreach ($wifiProfile in $profiles) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would remove Wi-Fi profile: {0}" -f $wifiProfile) + } + + $operations.Add([pscustomobject]@{ + Name = $wifiProfile + Succeeded = $true + Skipped = $false + Reason = 'DryRun' + }) + + [void]$removed.Add($wifiProfile) + } + } + else { + $toProcess = @() + + foreach ($wifiProfile in $profiles) { + if (-not $PSCmdlet.ShouldProcess("Wi-Fi profile '$wifiProfile'", 'Delete')) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("WhatIf/ShouldProcess prevented Wi-Fi profile removal: {0}" -f $wifiProfile) + } + + $operations.Add([pscustomobject]@{ + Name = $wifiProfile + Succeeded = $false + Skipped = $true + Reason = 'WhatIf' + }) + + continue + } + + $toProcess += $wifiProfile + } + + if ($toProcess.Count -gt 0) { + $sb = { + param($p) + + & netsh.exe wlan delete profile name="$p" 2>&1 | Out-Null + + if ($LASTEXITCODE -eq 0) { + [pscustomobject]@{ + Name = $p + Succeeded = $true + Skipped = $false + Reason = 'Removed' + } + } + else { + [pscustomobject]@{ + Name = $p + Succeeded = $false + Skipped = $false + Reason = 'Failed' + } + } + } + + try { + $res = Invoke-InParallel ` + -ScriptBlock $sb ` + -InputObjects $toProcess ` + -ThrottleLimit ([System.Math]::Max(1, [System.Environment]::ProcessorCount)) + } + catch { + if ($canLog) { + Write-NetCleanLog -Level WARN -Message ("Parallel Wi-Fi profile removal failed, falling back to sequential processing: {0}" -f $_.Exception.Message) + } + + $res = foreach ($wifiProfile in $toProcess) { + & netsh.exe wlan delete profile name="$wifiProfile" 2>&1 | Out-Null + + if ($LASTEXITCODE -eq 0) { + [pscustomobject]@{ + Name = $wifiProfile + Succeeded = $true + Skipped = $false + Reason = 'Removed' + } + } + else { + [pscustomobject]@{ + Name = $wifiProfile + Succeeded = $false + Skipped = $false + Reason = 'Failed' + } + } + } + } + + foreach ($r in $res) { + if ($null -eq $r) { + continue + } + + if ($r.Succeeded) { + [void]$removed.Add($r.Name) + } + + $operations.Add($r) + + if ($canLog) { + if ($r.Succeeded) { + Write-NetCleanLog -Level INFO -Message ("Removed Wi-Fi profile: {0}" -f $r.Name) + } + else { + Write-NetCleanLog -Level WARN -Message ("Failed to remove Wi-Fi profile '{0}': {1}" -f $r.Name, $r.Reason) + } + } + } + } + } + + return [pscustomobject]@{ + Removed = $removed.Count + Profiles = @($removed) + Operations = @($operations) + } +} + +<# +.SYNOPSIS +Configures the Quad9 Secure resolver set for DNS over HTTPS. +.DESCRIPTION +Adds or updates each Quad9 IPv4 and IPv6 resolver in the Windows encrypted-DNS +table, enabling automatic DoH upgrade without plaintext DNS fallback. Older +Windows versions without the required DNS client commands are reported as +unsupported without preventing static Quad9 DNS configuration. +.PARAMETER ServerAddresses +Quad9 resolver addresses to configure. +#> +function Set-NetCleanQuad9Doh { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string[]]$ServerAddresses + ) + + if (-not $PSCmdlet.ShouldProcess( + 'Windows DNS client', + 'Configure the Quad9 DNS-over-HTTPS resolver table' + )) { + return [pscustomobject]@{ + Supported = $true + ConfiguredCount = 0 + FailedCount = 0 + Reason = 'WhatIf' + Operations = @() + } + } + + $requiredCommands = @( + 'Get-DnsClientDohServerAddress', + 'Set-DnsClientDohServerAddress', + 'Add-DnsClientDohServerAddress' + ) + + foreach ($commandName in $requiredCommands) { + if (-not (Get-Command $commandName -ErrorAction SilentlyContinue)) { + return [pscustomobject]@{ + Supported = $false + ConfiguredCount = 0 + FailedCount = 0 + Reason = 'UnsupportedWindowsVersion' + Operations = @() + } + } + } + + $operations = [System.Collections.Generic.List[object]]::new() + $template = 'https://dns.quad9.net/dns-query' + + foreach ($serverAddress in $ServerAddresses) { + try { + $existing = @( + Get-DnsClientDohServerAddress ` + -ServerAddress $serverAddress ` + -ErrorAction SilentlyContinue + ) + + if ($existing.Count -gt 0) { + Set-DnsClientDohServerAddress ` + -ServerAddress $serverAddress ` + -DohTemplate $template ` + -AutoUpgrade $true ` + -AllowFallbackToUdp $false ` + -ErrorAction Stop + $action = 'Updated' + } + else { + Add-DnsClientDohServerAddress ` + -ServerAddress $serverAddress ` + -DohTemplate $template ` + -AutoUpgrade $true ` + -AllowFallbackToUdp $false ` + -ErrorAction Stop + $action = 'Added' + } + + $operations.Add([pscustomobject]@{ + ServerAddress = $serverAddress + Succeeded = $true + Action = $action + Error = $null + }) + } + catch { + $operations.Add([pscustomobject]@{ + ServerAddress = $serverAddress + Succeeded = $false + Action = 'Failed' + Error = $_.Exception.Message + }) + } + } + + return [pscustomobject]@{ + Supported = $true + ConfiguredCount = @($operations | Where-Object Succeeded).Count + FailedCount = @($operations | Where-Object { -not $_.Succeeded }).Count + Reason = 'Configured' + Operations = $operations.ToArray() + } +} + +<# +.SYNOPSIS +Sets the Microsoft-recommended Windows preference for IPv4 over IPv6. +.DESCRIPTION +Sets DisabledComponents to 0x20. IPv6 remains enabled for Windows components +and IPv6-only connectivity; the preference takes full effect after restart. +#> +function Set-NetCleanIPv4Preference { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([pscustomobject])] + param() + + if (-not $PSCmdlet.ShouldProcess( + 'Windows IP stack', + 'Set DisabledComponents to prefer IPv4 while retaining IPv6' + )) { + return [pscustomobject]@{ + Succeeded = $false + Skipped = $true + Reason = 'WhatIf' + Error = $null + } + } + + try { + Set-ItemProperty ` + -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters' ` + -Name 'DisabledComponents' ` + -Value 32 ` + -Type DWord ` + -Force ` + -ErrorAction Stop + + return [pscustomobject]@{ + Succeeded = $true + Reason = 'Configured' + Error = $null + } + } + catch { + return [pscustomobject]@{ + Succeeded = $false + Reason = 'Failed' + Error = $_.Exception.Message + } + } +} + +<# +.SYNOPSIS +Resets eligible adapter addressing and DNS for conference preparation. +.DESCRIPTION +Plans or applies IPv4 DHCP and Quad9 Secure DNS to visible adapters while +preserving organization-managed devices and adapter GUIDs associated with +detected VPN, security, and virtualization products. IPv6 remains enabled; +Windows is configured to prefer IPv4 after restart. +.PARAMETER Context +Detection context containing device-management state and protected adapter GUIDs. +.PARAMETER Adapters +Optional adapter inventory. When omitted, visible Windows adapters are enumerated. +.PARAMETER DryRun +Returns the planned changes without modifying the computer. +#> +function Reset-NetCleanAdapterConfigurationSafe { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter()] + [AllowEmptyCollection()] + [object[]]$Adapters, + + [switch]$DryRun + ) + + $dnsServers = @( + '9.9.9.9', + '149.112.112.112', + '2620:fe::fe', + '2620:fe::9' + ) + + if (-not $PSBoundParameters.ContainsKey('Adapters')) { + $Adapters = @(Get-NetAdapter -ErrorAction Stop) + } + + $isManaged = ( + $Context.PSObject.Properties.Name -contains 'ManagementState' -and + $Context.ManagementState -and + $Context.ManagementState.IsManaged + ) + + $protectedGuids = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + + if ($Context.PSObject.Properties.Name -contains 'ProtectedInterfaceGuids') { + foreach ($guid in @($Context.ProtectedInterfaceGuids)) { + if (-not [string]::IsNullOrWhiteSpace($guid)) { + [void]$protectedGuids.Add($guid.Trim('{}')) + } + } + } + + $adapterPlans = [System.Collections.Generic.List[object]]::new() + foreach ($adapter in @($Adapters)) { + $adapterGuid = if ($adapter.InterfaceGuid) { + ([string]$adapter.InterfaceGuid).Trim('{}') + } + else { + $null + } + + $skipReason = if ($isManaged) { + 'ManagedDevice' + } + elseif ($adapterGuid -and $protectedGuids.Contains($adapterGuid)) { + 'ProtectedAdapter' + } + else { + $null + } + + $adapterPlans.Add([pscustomobject]@{ + Adapter = $adapter + AdapterGuid = $adapterGuid + SkipReason = $skipReason + }) + } + + $eligibleCount = @($adapterPlans | Where-Object { -not $_.SkipReason }).Count + + if ($isManaged) { + $preferenceResult = [pscustomobject]@{ + Succeeded = $true + Skipped = $true + Reason = 'ManagedDevice' + Error = $null + } + $dohResult = [pscustomobject]@{ + Supported = $true + ConfiguredCount = 0 + FailedCount = 0 + Reason = 'ManagedDevice' + Operations = @() + } + } + elseif ($DryRun) { + $preferenceResult = [pscustomobject]@{ + Succeeded = $true + Skipped = $false + Reason = 'DryRun' + Error = $null + } + $dohResult = [pscustomobject]@{ + Supported = $true + ConfiguredCount = $dnsServers.Count + FailedCount = 0 + Reason = 'DryRun' + Operations = @() + } + } + else { + if ($PSCmdlet.ShouldProcess( + 'Windows IP stack', + 'Prefer IPv4 over IPv6 while keeping IPv6 enabled' + )) { + $preferenceResult = Set-NetCleanIPv4Preference -Confirm:$false + $preferenceResult | Add-Member -NotePropertyName Skipped -NotePropertyValue $false + } + else { + $preferenceResult = [pscustomobject]@{ + Succeeded = $false + Skipped = $true + Reason = 'WhatIf' + Error = $null + } + } + + if ($eligibleCount -eq 0) { + $dohResult = [pscustomobject]@{ + Supported = $true + ConfiguredCount = 0 + FailedCount = 0 + Reason = 'NoEligibleAdapters' + Operations = @() + } + } + elseif ($PSCmdlet.ShouldProcess( + 'Windows DNS client', + 'Configure Quad9 DNS over HTTPS without plaintext fallback' + )) { + $dohResult = Set-NetCleanQuad9Doh ` + -ServerAddresses $dnsServers ` + -Confirm:$false + } + else { + $dohResult = [pscustomobject]@{ + Supported = $true + ConfiguredCount = 0 + FailedCount = 0 + Reason = 'WhatIf' + Operations = @() + } + } + } + + $operations = [System.Collections.Generic.List[object]]::new() + + foreach ($plan in $adapterPlans) { + $adapter = $plan.Adapter + $adapterGuid = $plan.AdapterGuid + $skipReason = $plan.SkipReason + + if ($skipReason) { + $operations.Add([pscustomobject]@{ + Name = $adapter.Name + InterfaceIndex = $adapter.InterfaceIndex + InterfaceGuid = $adapterGuid + DhcpEnabled = $false + DnsServers = @() + Succeeded = $true + Skipped = $true + DryRun = [bool]$DryRun + Reason = $skipReason + Error = $null + }) + continue + } + + if ($DryRun) { + $operations.Add([pscustomobject]@{ + Name = $adapter.Name + InterfaceIndex = $adapter.InterfaceIndex + InterfaceGuid = $adapterGuid + DhcpEnabled = $true + DnsServers = $dnsServers + Succeeded = $true + Skipped = $false + DryRun = $true + Reason = 'DryRun' + Error = $null + }) + continue + } + + if (-not $PSCmdlet.ShouldProcess( + "Network adapter '$($adapter.Name)'", + 'Enable IPv4 DHCP and configure Quad9 Secure DNS' + )) { + $operations.Add([pscustomobject]@{ + Name = $adapter.Name + InterfaceIndex = $adapter.InterfaceIndex + InterfaceGuid = $adapterGuid + DhcpEnabled = $false + DnsServers = @() + Succeeded = $false + Skipped = $true + DryRun = $false + Reason = 'WhatIf' + Error = $null + }) + continue + } + + $dhcpResult = Invoke-ExternalCommandSafe ` + -Name ("Reset IPv4 address for {0}" -f $adapter.Name) ` + -FilePath 'netsh.exe' ` + -ArgumentList @( + 'interface', + 'ipv4', + 'set', + 'address', + ("name={0}" -f $adapter.InterfaceIndex), + 'source=dhcp' + ) + + if (-not $dhcpResult.Succeeded) { + $operations.Add([pscustomobject]@{ + Name = $adapter.Name + InterfaceIndex = $adapter.InterfaceIndex + InterfaceGuid = $adapterGuid + DhcpEnabled = $false + DnsServers = @() + Succeeded = $false + Skipped = $false + DryRun = $false + Reason = 'DhcpResetFailed' + Error = $dhcpResult.Error + }) + continue + } + + try { + Set-DnsClientServerAddress ` + -InterfaceIndex $adapter.InterfaceIndex ` + -ServerAddresses $dnsServers ` + -ErrorAction Stop + + $operations.Add([pscustomobject]@{ + Name = $adapter.Name + InterfaceIndex = $adapter.InterfaceIndex + InterfaceGuid = $adapterGuid + DhcpEnabled = $true + DnsServers = $dnsServers + Succeeded = $true + Skipped = $false + DryRun = $false + Reason = 'Configured' + Error = $null + }) + } + catch { + $operations.Add([pscustomobject]@{ + Name = $adapter.Name + InterfaceIndex = $adapter.InterfaceIndex + InterfaceGuid = $adapterGuid + DhcpEnabled = $true + DnsServers = @() + Succeeded = $false + Skipped = $false + DryRun = $false + Reason = 'DnsConfigurationFailed' + Error = $_.Exception.Message + }) + } + } + + return [pscustomobject]@{ + Provider = 'Quad9 Secure' + DnsServers = $dnsServers + PreferIPv4 = -not [bool]$isManaged + IPv4Preference = $preferenceResult + DnsOverHttps = $dohResult + RequiresRestart = (-not $isManaged -and -not $preferenceResult.Skipped) + ConfiguredCount = @($operations | Where-Object { $_.Succeeded -and -not $_.Skipped }).Count + SkippedCount = @($operations | Where-Object Skipped).Count + FailedCount = @($operations | Where-Object { -not $_.Succeeded }).Count + Succeeded = ( + @($operations | Where-Object { -not $_.Succeeded -and -not $_.Skipped }).Count -eq 0 -and + $preferenceResult.Succeeded -and + $dohResult.FailedCount -eq 0 + ) + Operations = $operations.ToArray() + } +} + +<# +.SYNOPSIS +Clears the DNS resolver cache (supports -WhatIf). +.DESCRIPTION +Invokes the platform command to flush the DNS resolver cache. Honors `-DryRun`, `-WhatIf` and `-Confirm`. +.PARAMETER DryRun +Simulate actions without making changes. +.EXAMPLE +Clear-DnsCacheSafe -DryRun +#> +function Clear-DnsCacheSafe { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.Object])] + param( + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if ($DryRun) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'Would flush DNS cache.' + } + + return [pscustomobject]@{ + Name = 'Flush DNS cache' + Succeeded = $true + Skipped = $false + Reason = 'DryRun' + DryRun = $true + } + } + + if (-not $PSCmdlet.ShouldProcess('DNS cache', 'Flush')) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'WhatIf/ShouldProcess prevented DNS cache flush.' + } + + return [pscustomobject]@{ + Name = 'Flush DNS cache' + Succeeded = $false + Skipped = $true + Reason = 'WhatIf' + } + } + + $result = Invoke-ExternalCommandSafe -Name 'Flush DNS cache' -FilePath 'ipconfig.exe' -ArgumentList @('/flushdns') -DryRun:$false + + if ($canLog) { + if ($result.Succeeded) { + Write-NetCleanLog -Level INFO -Message 'Flushed DNS cache.' + } + else { + Write-NetCleanLog -Level WARN -Message ("Failed to flush DNS cache: {0}" -f $result.Error) + } + } + + return $result +} + +<# +.SYNOPSIS +Clears the ARP cache (supports -WhatIf). +.DESCRIPTION +Attempts to clear the system ARP cache. Honors `-DryRun`, `-WhatIf` and `-Confirm`. +.PARAMETER DryRun +Simulate actions without making changes. +.EXAMPLE +Clear-ArpCacheSafe +#> +function Clear-ArpCacheSafe { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.Object])] + param( + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if ($DryRun) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'Would clear ARP cache.' + } + + return [pscustomobject]@{ + Name = 'Clear ARP cache' + Succeeded = $true + Skipped = $false + Reason = 'DryRun' + DryRun = $true + } + } + + if (-not $PSCmdlet.ShouldProcess('ARP cache', 'Clear')) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'WhatIf/ShouldProcess prevented ARP cache clear.' + } + + return [pscustomobject]@{ + Name = 'Clear ARP cache' + Succeeded = $false + Skipped = $true + Reason = 'WhatIf' + } + } + + $result = Invoke-ExternalCommandSafe -Name 'Clear ARP cache' -FilePath 'arp.exe' -ArgumentList @('-d', '*') -DryRun:$false -IgnoreExitCode + + if ($canLog) { + if ($result.Succeeded) { + Write-NetCleanLog -Level INFO -Message 'Cleared ARP cache.' + } + else { + Write-NetCleanLog -Level WARN -Message ("Failed to clear ARP cache: {0}" -f $result.Error) + } + } + + return $result +} + +<# +.SYNOPSIS +Removes a registry path if allowed (supports -WhatIf). +.DESCRIPTION +Safely removes a registry path unless it is protected. Honors `-DryRun`, `-WhatIf` and `-Confirm`. +.PARAMETER RegistryPath +The registry path to remove. +.PARAMETER Context +Operation context with protection information. +.PARAMETER DryRun +Simulate removal without making changes. +.EXAMPLE +Remove-RegistryPathSafe -RegistryPath 'HKCU:\Software\Foo' -Context $ctx -DryRun +#> +function Remove-RegistryPathSafe { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.Object])] + param( + [Parameter(Mandatory = $true)] + [Alias('Path')] + [string]$RegistryPath, + + [pscustomobject]$Context, + + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if (Test-RegistryPathProtected -Path $RegistryPath -Context $Context) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Skipping protected registry path: {0}" -f $RegistryPath) + } + + return [pscustomobject]@{ + RegistryPath = $RegistryPath + Removed = $false + Skipped = $true + Succeeded = $true + Reason = 'Protected' + DryRun = [bool]$DryRun + } + } + + $providerPath = $null + try { + $providerPath = Convert-RegToProviderPath -RegistryPath $RegistryPath + } + catch { + if ($canLog) { + Write-NetCleanLog -Level WARN -Message ("Skipping invalid registry path '{0}'." -f $RegistryPath) + } + + return [pscustomobject]@{ + RegistryPath = $RegistryPath + Removed = $false + Skipped = $true + Succeeded = $false + Reason = 'InvalidPath' + DryRun = [bool]$DryRun + } + } + + if (-not (Test-Path -LiteralPath $providerPath)) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Registry path not found, skipping: {0}" -f $RegistryPath) + } + + return [pscustomobject]@{ + RegistryPath = $RegistryPath + Removed = $false + Skipped = $true + Succeeded = $true + Reason = 'NotFound' + DryRun = [bool]$DryRun + } + } + + if ($DryRun) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would remove registry path: {0}" -f $RegistryPath) + } + + return [pscustomobject]@{ + RegistryPath = $RegistryPath + Removed = $true + Skipped = $false + Succeeded = $true + Reason = 'DryRun' + DryRun = $true + } + } + + if (-not $PSCmdlet.ShouldProcess($RegistryPath, 'Remove registry path')) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("WhatIf/ShouldProcess prevented removal of registry path: {0}" -f $RegistryPath) + } + + return [pscustomobject]@{ + RegistryPath = $RegistryPath + Removed = $false + Skipped = $true + Succeeded = $true + Reason = 'WhatIf' + DryRun = $false + } + } + + try { + Remove-Item -LiteralPath $providerPath -Recurse -Force -ErrorAction Stop + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Removed registry path: {0}" -f $RegistryPath) + } + + return [pscustomobject]@{ + RegistryPath = $RegistryPath + Removed = $true + Skipped = $false + Succeeded = $true + Reason = 'Removed' + DryRun = $false + } + } + catch { + if ($canLog) { + Write-NetCleanLog -Level WARN -Message ("Failed to remove registry path '{0}': {1}" -f $RegistryPath, $_.Exception.Message) + } + + return [pscustomobject]@{ + RegistryPath = $RegistryPath + Removed = $false + Skipped = $true + Succeeded = $false + Reason = $_.Exception.Message + DryRun = $false + } + } +} + +<# +.SYNOPSIS +Removes discovered network privacy artifacts. +.DESCRIPTION +Iterates discovered artifacts and removes related registry entries where allowed. Honors `-DryRun`, `-WhatIf` and `-Confirm`. +.PARAMETER Context +The protection/context object produced during detect/protect phases. +.PARAMETER DryRun +Simulate actions without making changes. +.EXAMPLE +Remove-NetworkPrivacyArtifactsSafe -Context $ctx -DryRun +#> +function Remove-NetworkPrivacyArtifactsSafe { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.Object])] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + $artifacts = @($Context.SanitizableArtifacts) + $results = New-Object System.Collections.Generic.List[object] + + if ($canLog) { + if ($DryRun) { + Write-NetCleanLog -Level INFO -Message ("Would process {0} sanitizable registry artifacts." -f $artifacts.Count) + } + else { + Write-NetCleanLog -Level INFO -Message ("Processing {0} sanitizable registry artifacts." -f $artifacts.Count) + } + } + + foreach ($artifact in $artifacts) { + if (-not ($artifact.PSObject.Properties.Name -contains 'RegistryPath') -or [string]::IsNullOrWhiteSpace($artifact.RegistryPath)) { + continue + } + + $results.Add((Remove-RegistryPathSafe -RegistryPath $artifact.RegistryPath -Context $Context -DryRun:$DryRun)) + } + + $summary = [pscustomobject]@{ + TotalCandidates = $artifacts.Count + RemovedCount = @($results | Where-Object { $_.Removed }).Count + SkippedCount = @($results | Where-Object { $_.Skipped }).Count + Results = $results.ToArray() + } + + if ($canLog) { + if ($DryRun) { + Write-NetCleanLog -Level INFO -Message ("Preview registry artifact cleanup summary: candidates={0} wouldRemove={1} skipped={2}" -f $summary.TotalCandidates, $summary.RemovedCount, $summary.SkippedCount) + } + else { + Write-NetCleanLog -Level INFO -Message ("Registry artifact cleanup summary: candidates={0} removed={1} skipped={2}" -f $summary.TotalCandidates, $summary.RemovedCount, $summary.SkippedCount) + } + } + + return $summary +} + +<# +.SYNOPSIS +Safely clears user network event logs. +.DESCRIPTION +Clears user-specific network event logs such as WLAN AutoConfig, NetworkProfile and DHCP Client operational logs. Honors `-DryRun`, `-WhatIf` and `-Confirm` to allow safe simulation of actions. +.PARAMETER DryRun +If specified, all operations are simulated and no actual changes are made to the system. Results will indicate what would have been done. +.EXAMPLE +Clear-NetworkEventLogsSafe -DryRun +.OUTPUTS +An array of results for each log cleared, indicating the log name, whether it was cleared, if it was a dry run, if the operation succeeded, and any error messages if applicable. +.NOTES +- Clearing event logs can result in loss of historical event data. It is recommended to perform these operations when a backup of important logs has been made or when the logs are not needed for troubleshooting. +#> +function Clear-NetworkEventLogsSafe { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.Object[]])] + param( + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + $logs = @( + # Deliberately narrow allowlist: identity, account, token, and + # user-device-registration event logs are outside cleanup scope. + 'Microsoft-Windows-WLAN-AutoConfig/Operational', + 'Microsoft-Windows-NetworkProfile/Operational', + 'Microsoft-Windows-DHCP-Client/Operational' + ) + + $results = [System.Collections.Generic.List[object]]::new() + + foreach ($log in $logs) { + if ($DryRun) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would clear event log: {0}" -f $log) + } + + $results.Add([pscustomobject]@{ + Name = "Clear event log $log" + LogName = $log + Succeeded = $true + Cleared = $false + DryRun = $true + Skipped = $false + Reason = 'DryRun' + ExitCode = 0 + Error = $null + CompletedAt = $null + }) + + continue + } + + if (-not $PSCmdlet.ShouldProcess($log, 'Clear event log')) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("WhatIf prevented clearing event log: {0}" -f $log) + } + + $results.Add([pscustomobject]@{ + Name = "Clear event log $log" + LogName = $log + Succeeded = $false + Cleared = $false + DryRun = $false + Skipped = $true + Reason = 'WhatIf' + ExitCode = $null + Error = $null + CompletedAt = $null + }) + + continue + } + + try { + $result = Invoke-ExternalCommandSafe ` + -Name ("Clear event log {0}" -f $log) ` + -FilePath 'wevtutil.exe' ` + -ArgumentList @('cl', $log) + + $results.Add([pscustomobject]@{ + Name = $result.Name + LogName = $log + Succeeded = [bool]$result.Succeeded + Cleared = [bool]$result.Succeeded + DryRun = $false + Skipped = $false + Reason = $(if ($result.Succeeded) { $null } else { 'CommandFailed' }) + ExitCode = $result.ExitCode + Error = $result.Error + CompletedAt = $(if ($result.Succeeded) { Get-Date } else { $null }) + }) + + if ($canLog) { + if ($result.Succeeded) { + Write-NetCleanLog -Level INFO -Message ("Cleared event log: {0}" -f $log) + } + else { + Write-NetCleanLog -Level WARN -Message ("Failed to clear event log '{0}': {1}" -f $log, $result.Error) + } + } + } + catch { + if ($canLog) { + Write-NetCleanLog -Level WARN -Message ("Exception clearing event log '{0}': {1}" -f $log, $_.Exception.Message) + } + + $results.Add([pscustomobject]@{ + Name = "Clear event log $log" + LogName = $log + Succeeded = $false + Cleared = $false + DryRun = $false + Skipped = $false + Reason = 'Exception' + ExitCode = -1 + Error = $_.Exception.Message + CompletedAt = $null + }) + } + } + + return $results.ToArray() +} + +<# +.SYNOPSIS +Safely clears user network artifacts from the registry. +.DESCRIPTION +Removes only the explicitly allowed Explorer history keys below. Workplace +registration, Web Account Manager, BrokerPlugin, token, credential, Windows +Hello, and other identity stores are outside cleanup scope. Honors `-DryRun`, +`-WhatIf`, and `-Confirm` to allow safe simulation of actions. +.PARAMETER DryRun +If specified, all operations are simulated and no actual changes are made to the system. Results will indicate what would have been done. +.EXAMPLE +Clear-UserNetworkArtifactsSafe -DryRun +.OUTPUTS +An array of results for each artifact path processed, indicating the path, whether it was removed, if it was a dry run, if the operation succeeded, and any error messages if applicable. +.NOTES +- This function targets specific user registry paths known to store network-related artifacts. It is designed to be safe and cautious, avoiding any protected paths and providing detailed results for each attempted removal. +#> +function Clear-UserNetworkArtifactsSafe { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.Object[]])] + param( + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + $paths = @( + # Deliberately narrow allowlist. Do not add identity, SSO, token, + # credential, or Workplace registration locations. + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU', + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths', + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\RecentDocs' + ) + + $results = [System.Collections.Generic.List[object]]::new() + + foreach ($path in $paths) { + + if ($DryRun) { + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would remove user network artifact path: {0}" -f $path) + } + + $results.Add([pscustomobject]@{ + Path = $path + Removed = $false + DryRun = $true + Succeeded = $true + Reason = 'DryRun' + }) + + continue + } + + if (-not (Test-Path -LiteralPath $path)) { + + $results.Add([pscustomobject]@{ + Path = $path + Removed = $false + DryRun = $false + Succeeded = $true + Reason = 'NotFound' + }) + + continue + } + + if (-not $PSCmdlet.ShouldProcess($path, 'Remove user network artifact path')) { + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("WhatIf prevented clearing user network artifact path: {0}" -f $path) + } + + $results.Add([pscustomobject]@{ + Path = $path + Removed = $false + DryRun = $false + Succeeded = $false + Reason = 'WhatIf' + }) + + continue + } + + try { + + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Removed user network artifact path: {0}" -f $path) + } + + $results.Add([pscustomobject]@{ + Path = $path + Removed = $true + DryRun = $false + Succeeded = $true + Reason = $null + }) + } + catch { + + if ($canLog) { + Write-NetCleanLog -Level WARN -Message ("Failed removing user network artifact path '{0}': {1}" -f $path, $_.Exception.Message) + } + + $results.Add([pscustomobject]@{ + Path = $path + Removed = $false + DryRun = $false + Succeeded = $false + Reason = $_.Exception.Message + }) + } + } + + return $results.ToArray() +} + +<# +.SYNOPSIS +Performs advanced network repairs by resetting Winsock and TCP/IP stacks. +.DESCRIPTION +Executes a series of commands to reset the Winsock catalog and TCP/IP stacks for both IPv4 and IPv6. These operations can resolve a variety of network issues related to corrupted network configurations. Honors `-DryRun` to simulate actions without making changes. +.PARAMETER DryRun +If specified, all operations are simulated and no actual changes are made to the system. Results will indicate what would have been done. +.EXAMPLE +Invoke-AdvancedNetworkRepair -DryRun +.OUTPUTS +An array of results for each repair command executed, indicating the name of the command, whether it succeeded, if it was a dry run, and any error messages if applicable. +.NOTES +- Resetting Winsock and TCP/IP stacks can disrupt network connectivity until the system is restarted. It is recommended to perform these operations when a restart can be accommodated. +#> +function Invoke-AdvancedNetworkRepair { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.Object[]])] + param( + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + $commands = @( + [pscustomobject]@{ + Name = 'Reset Winsock' + FilePath = 'netsh.exe' + ArgumentList = @('winsock', 'reset') + }, + [pscustomobject]@{ + Name = 'Reset IPv4 stack' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'ip', 'reset') + }, + [pscustomobject]@{ + Name = 'Reset IPv6 stack' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'ipv6', 'reset') + } + ) + + $results = [System.Collections.Generic.List[object]]::new() + + foreach ($cmd in $commands) { + if ($DryRun) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would perform advanced network repair action: {0}" -f $cmd.Name) + } + + $results.Add([pscustomobject]@{ + Name = $cmd.Name + FilePath = $cmd.FilePath + Arguments = ($cmd.ArgumentList -join ' ') + Succeeded = $true + Applied = $false + DryRun = $true + Skipped = $false + Reason = 'DryRun' + ExitCode = 0 + Error = $null + }) + + continue + } + + if (-not $PSCmdlet.ShouldProcess($cmd.Name, 'Perform advanced network repair action')) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("WhatIf prevented advanced network repair action: {0}" -f $cmd.Name) + } + + $results.Add([pscustomobject]@{ + Name = $cmd.Name + FilePath = $cmd.FilePath + Arguments = ($cmd.ArgumentList -join ' ') + Succeeded = $false + Applied = $false + DryRun = $false + Skipped = $true + Reason = 'WhatIf' + ExitCode = $null + Error = $null + }) + + continue + } + + try { + $result = Invoke-ExternalCommandSafe ` + -Name $cmd.Name ` + -FilePath $cmd.FilePath ` + -ArgumentList $cmd.ArgumentList + + $results.Add([pscustomobject]@{ + Name = $result.Name + FilePath = $cmd.FilePath + Arguments = ($cmd.ArgumentList -join ' ') + Succeeded = [bool]$result.Succeeded + Applied = [bool]$result.Succeeded + DryRun = $false + Skipped = $false + Reason = $(if ($result.Succeeded) { $null } else { 'CommandFailed' }) + ExitCode = $result.ExitCode + Error = $result.Error + }) + + if ($canLog) { + if ($result.Succeeded) { + Write-NetCleanLog -Level INFO -Message ("Completed advanced network repair action: {0}" -f $cmd.Name) + } + else { + Write-NetCleanLog -Level WARN -Message ("Failed advanced network repair action '{0}': {1}" -f $cmd.Name, $result.Error) + } + } + } + catch { + if ($canLog) { + Write-NetCleanLog -Level WARN -Message ("Exception during advanced network repair action '{0}': {1}" -f $cmd.Name, $_.Exception.Message) + } + + $results.Add([pscustomobject]@{ + Name = $cmd.Name + FilePath = $cmd.FilePath + Arguments = ($cmd.ArgumentList -join ' ') + Succeeded = $false + Applied = $false + DryRun = $false + Skipped = $false + Reason = 'Exception' + ExitCode = -1 + Error = $_.Exception.Message + }) + } + } + + return $results.ToArray() +} + +<# + .SYNOPSIS + Prompts the user to select a network performance tuning profile. + .DESCRIPTION + Displays a list of available network performance tuning profiles and allows the user to make a selection. + .OUTPUTS + [string] The selected profile name. +#> +function Read-NetCleanPerformanceProfileSelection { + [CmdletBinding()] + [OutputType([string])] + param() + + while ($true) { + Write-Information '' -InformationAction Continue + Write-Information 'Network Performance Tuning Profiles' -InformationAction Continue + Write-Information '-----------------------------------' -InformationAction Continue + Write-Information '1. Conservative' -InformationAction Continue + Write-Information ' Safe baseline tuning with minimal change.' -InformationAction Continue + Write-Information ' Changes:' -InformationAction Continue + Write-Information ' - TCP autotuning = normal' -InformationAction Continue + Write-Information ' Why:' -InformationAction Continue + Write-Information ' - Restores a stable, low-risk TCP setting for most systems.' -InformationAction Continue + Write-Information '' -InformationAction Continue + + Write-Information '2. Optimal' -InformationAction Continue + Write-Information ' Balanced general-use broadband tuning.' -InformationAction Continue + Write-Information ' Changes:' -InformationAction Continue + Write-Information ' - TCP autotuning = normal' -InformationAction Continue + Write-Information ' - ECN = enabled' -InformationAction Continue + Write-Information ' - TCP timestamps = disabled' -InformationAction Continue + Write-Information ' Why:' -InformationAction Continue + Write-Information ' - Aims for good general throughput and modern TCP behavior.' -InformationAction Continue + Write-Information '' -InformationAction Continue + + Write-Information '3. Gaming' -InformationAction Continue + Write-Information ' Lower-latency focused tuning.' -InformationAction Continue + Write-Information ' Changes:' -InformationAction Continue + Write-Information ' - TCP autotuning = normal' -InformationAction Continue + Write-Information ' - ECN = disabled' -InformationAction Continue + Write-Information ' - TCP timestamps = disabled' -InformationAction Continue + Write-Information ' Why:' -InformationAction Continue + Write-Information ' - Prioritizes simpler, latency-oriented TCP behavior.' -InformationAction Continue + Write-Information '' -InformationAction Continue + + Write-Information '4. Restore Default' -InformationAction Continue + Write-Information ' Restore NetClean-supported baseline values.' -InformationAction Continue + Write-Information ' Changes:' -InformationAction Continue + Write-Information ' - Reverts tuning changes made by NetClean profiles' -InformationAction Continue + Write-Information ' Why:' -InformationAction Continue + Write-Information ' - Gives you a rollback path if tuning does not help.' -InformationAction Continue + Write-Information '' -InformationAction Continue + + Write-Information '5. Cancel' -InformationAction Continue + Write-Information '' -InformationAction Continue + + $choice = Read-Host 'Select a profile (1-5)' + + switch ($choice) { + '1' { return 'Conservative' } + '2' { return 'Optimal' } + '3' { return 'Gaming' } + '4' { return 'Default' } + '5' { return 'Cancel' } + default { + Write-Information '' -InformationAction Continue + Write-Information 'Invalid selection. Please choose 1 through 5.' -InformationAction Continue + } + } + } +} + +<# +.SYNOPSIS +Performs conservative performance tuning by enabling normal autotuning, RSS, and ECN. +.DESCRIPTION +Executes a set of commands to enable normal autotuning, Receive Side Scaling (RSS), and Explicit Congestion Notification (ECN) capability. These settings can improve network performance in many scenarios while maintaining broad compatibility. Honors `-DryRun` to simulate actions without making changes. +.PARAMETER DryRun +If specified, all operations are simulated and no actual changes are made to the system. Results will indicate what would have been done. +.EXAMPLE +Invoke-NetworkPerformanceTune -DryRun +.OUTPUTS +An array of results for each performance tuning command executed, indicating the name of the command, whether it succeeded, if it was a dry run, and any error messages if applicable. +.NOTES +- These performance tuning steps are generally safe and can provide benefits in typical network environments, but results may vary based on specific hardware and drivers. +#> +function Invoke-NetworkPerformanceTune { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.Object[]])] + param( + [ValidateSet('Conservative', 'Optimal', 'Gaming', 'Default')] + [string]$PerformanceProfile = 'Conservative', + + [switch]$DryRun + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + switch ($PerformanceProfile) { + 'Conservative' { + $commands = @( + [pscustomobject]@{ + Name = 'Set TCP autotuning to normal' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'tcp', 'set', 'global', 'autotuninglevel=normal') + Why = 'Restores stable receive-window scaling behavior.' + } + ) + } + + 'Optimal' { + $commands = @( + [pscustomobject]@{ + Name = 'Set TCP autotuning to normal' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'tcp', 'set', 'global', 'autotuninglevel=normal') + Why = 'Keeps adaptive receive-window sizing enabled.' + }, + [pscustomobject]@{ + Name = 'Enable ECN capability' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'tcp', 'set', 'global', 'ecncapability=enabled') + Why = 'Allows ECN-capable congestion signaling where supported.' + }, + [pscustomobject]@{ + Name = 'Disable TCP timestamps' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'tcp', 'set', 'global', 'timestamps=disabled') + Why = 'Reduces header overhead for most common client workloads.' + } + ) + } + + 'Gaming' { + $commands = @( + [pscustomobject]@{ + Name = 'Set TCP autotuning to normal' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'tcp', 'set', 'global', 'autotuninglevel=normal') + Why = 'Maintains modern TCP scaling without over-constraining throughput.' + }, + [pscustomobject]@{ + Name = 'Disable ECN capability' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'tcp', 'set', 'global', 'ecncapability=disabled') + Why = 'Avoids dependency on ECN behavior across network paths.' + }, + [pscustomobject]@{ + Name = 'Disable TCP timestamps' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'tcp', 'set', 'global', 'timestamps=disabled') + Why = 'Keeps packet overhead and TCP options simpler.' + } + ) + } + + 'Default' { + $commands = @( + [pscustomobject]@{ + Name = 'Set TCP autotuning to normal' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'tcp', 'set', 'global', 'autotuninglevel=normal') + Why = 'Restores the NetClean baseline autotuning state.' + }, + [pscustomobject]@{ + Name = 'Disable ECN capability' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'tcp', 'set', 'global', 'ecncapability=disabled') + Why = 'Restores the NetClean baseline ECN state.' + }, + [pscustomobject]@{ + Name = 'Disable TCP timestamps' + FilePath = 'netsh.exe' + ArgumentList = @('int', 'tcp', 'set', 'global', 'timestamps=disabled') + Why = 'Restores the NetClean baseline timestamp state.' + } + ) + } + } + + $results = [System.Collections.Generic.List[object]]::new() + + foreach ($cmd in $commands) { + if ($DryRun) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Would apply tuning profile '{0}' action: {1}" -f $PerformanceProfile, $cmd.Name) + } + + $results.Add([pscustomobject]@{ + Profile = $PerformanceProfile + Name = $cmd.Name + FilePath = $cmd.FilePath + Arguments = ($cmd.ArgumentList -join ' ') + Why = $cmd.Why + Succeeded = $true + Applied = $false + DryRun = $true + Skipped = $false + Reason = 'DryRun' + ExitCode = 0 + Error = $null + }) + + continue + } + + if (-not $PSCmdlet.ShouldProcess($cmd.Name, "Apply network tuning profile '$PerformanceProfile'")) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("WhatIf prevented tuning profile '{0}' action: {1}" -f $PerformanceProfile, $cmd.Name) + } + + $results.Add([pscustomobject]@{ + Profile = $PerformanceProfile + Name = $cmd.Name + FilePath = $cmd.FilePath + Arguments = ($cmd.ArgumentList -join ' ') + Why = $cmd.Why + Succeeded = $false + Applied = $false + DryRun = $false + Skipped = $true + Reason = 'WhatIf' + ExitCode = $null + Error = $null + }) + + continue + } + + try { + $result = Invoke-ExternalCommandSafe ` + -Name $cmd.Name ` + -FilePath $cmd.FilePath ` + -ArgumentList $cmd.ArgumentList ` + -IgnoreExitCode + + $results.Add([pscustomobject]@{ + Profile = $PerformanceProfile + Name = $result.Name + FilePath = $cmd.FilePath + Arguments = ($cmd.ArgumentList -join ' ') + Why = $cmd.Why + Succeeded = [bool]$result.Succeeded + Applied = [bool]$result.Succeeded + DryRun = $false + Skipped = $false + Reason = $(if ($result.Succeeded) { $null } else { 'CommandFailed' }) + ExitCode = $result.ExitCode + Error = $result.Error + }) + + if ($canLog) { + if ($result.Succeeded) { + Write-NetCleanLog -Level INFO -Message ("Applied tuning profile '{0}' action: {1}" -f $PerformanceProfile, $cmd.Name) + } + else { + Write-NetCleanLog -Level WARN -Message ("Failed tuning profile '{0}' action '{1}': {2}" -f $PerformanceProfile, $cmd.Name, $result.Error) + } + } + } + catch { + if ($canLog) { + Write-NetCleanLog -Level WARN -Message ("Exception applying tuning profile '{0}' action '{1}': {2}" -f $PerformanceProfile, $cmd.Name, $_.Exception.Message) + } + + $results.Add([pscustomobject]@{ + Profile = $PerformanceProfile + Name = $cmd.Name + FilePath = $cmd.FilePath + Arguments = ($cmd.ArgumentList -join ' ') + Why = $cmd.Why + Succeeded = $false + Applied = $false + DryRun = $false + Skipped = $false + Reason = 'Exception' + ExitCode = -1 + Error = $_.Exception.Message + }) + } + } + + return $results.ToArray() +} + +<# +.SYNOPSIS +Performs cleaning operations to remove network privacy artifacts and reset network state. +.DESCRIPTION +Based on the provided context and mode, executes cleaning operations such as removing Wi-Fi profiles, resetting eligible adapters to IPv4 DHCP and Quad9 Secure DNS, flushing DNS and ARP caches, removing registry artifacts, and optionally performing advanced repairs and performance tuning. Each operation supports `-DryRun` to simulate actions without making changes. Returns an updated context object containing details of the operations and their results. +.PARAMETER Context +The context object produced during the detect/protect phases, containing inventory and protection information. +.PARAMETER Mode +Determines the cleaning mode and which operations to perform. Supported values are: +- 'Preview': Minimal cleaning for previewing potential changes. +.PARAMETER DryRun +If specified, all operations are simulated and no actual changes are made to the system. Results will indicate what would have been done. +.PARAMETER SkipWifi +If specified, Wi-Fi profile removal will be skipped. +.PARAMETER SkipDnsFlush +If specified, DNS cache flushing will be skipped. +.PARAMETER SkipEventLogs +If specified, network event log clearing will be skipped. +.PARAMETER SkipUserArtifacts +If specified, user network artifact clearing will be skipped. +.PARAMETER PerformanceProfile +Specifies the validated performance profile used when Mode is PerformanceTune. +.EXAMPLE +Invoke-NetCleanPhase3Clean -Context $ctx -Mode 'SafeConferencePrep' -DryRun +.OUTPUTS +An updated context object containing adapter configuration, Wi-Fi removal, cache clearing, registry artifact removal, event-log clearing, user artifact clearing, and any selected repair or performance-tuning operations. +.NOTES +- Ensure that the context object provided contains the necessary inventory and protection information for accurate cleaning operations. +#> +function Invoke-NetCleanPhase3Clean { + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.Object])] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [ValidateSet('Preview', 'SafeConferencePrep', 'AdvancedRepair', 'PerformanceTune')] + [string]$Mode = 'SafeConferencePrep', + + [switch]$DryRun, + [switch]$SkipWifi, + [switch]$SkipDnsFlush, + [switch]$SkipEventLogs, + [switch]$SkipUserArtifacts, + [ValidateSet('Conservative', 'Optimal', 'Gaming', 'Default')] + [string]$PerformanceProfile + ) + + $canLog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if ($Mode -eq 'PerformanceTune' -and [string]::IsNullOrWhiteSpace($PerformanceProfile)) { + throw "PerformanceProfile is required when Mode is 'PerformanceTune'." + } + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Phase 3 clean started. Mode={0} DryRun={1}" -f $Mode, [bool]$DryRun) + } + + $newContext = [pscustomobject]@{} + foreach ($p in $Context.PSObject.Properties) { + Add-Member -InputObject $newContext -NotePropertyName $p.Name -NotePropertyValue $p.Value + } + + if ($SkipWifi) { + $wifiResult = [pscustomobject]@{ + Removed = 0 + Profiles = @() + Operations = @() + Skipped = $true + Reason = 'SkippedByOption' + } + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'Skipping Wi-Fi profile cleanup by option.' + } + } + else { + $profilesToRemove = @() + if ($Context -and $Context.PSObject.Properties.Name -contains 'Protect' -and $Context.Protect.PSObject.Properties.Name -contains 'Summary' -and $Context.Protect.Summary.PSObject.Properties.Name -contains 'WiFiProfilesFound') { + $profilesToRemove += @($Context.Protect.Summary.WiFiProfilesFound) + } + + $profilesToRemove += @(Get-WiFiProfileName) + $profilesToRemove = @(Get-UniqueNonEmptyString -InputObject $profilesToRemove) + $wifiResult = Remove-WiFiProfilesSafe -DryRun:$DryRun -WifiProfiles $profilesToRemove + } + + if ($SkipDnsFlush) { + $dnsResult = [pscustomobject]@{ + Name = 'Flush DNS cache' + Succeeded = $true + DryRun = [bool]$DryRun + Skipped = $true + Reason = 'SkippedByOption' + } + + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'Skipping DNS cache flush by option.' + } + } + else { + $dnsResult = Clear-DnsCacheSafe -DryRun:$DryRun + } + + $arpResult = Clear-ArpCacheSafe -DryRun:$DryRun + $artifacts = Remove-NetworkPrivacyArtifactsSafe -Context $newContext -DryRun:$DryRun + $nlaResults = @() + + if ($SkipEventLogs) { + $logResults = @() + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'Skipping network event log cleanup by option.' + } + } + else { + $logResults = @(Clear-NetworkEventLogsSafe -DryRun:$DryRun) + } + + if ($SkipUserArtifacts) { + $userResults = @() + if ($canLog) { + Write-NetCleanLog -Level INFO -Message 'Skipping user network artifact cleanup by option.' + } + } + else { + $userResults = @(Clear-UserNetworkArtifactsSafe -DryRun:$DryRun) + } + + $adapterResult = Reset-NetCleanAdapterConfigurationSafe ` + -Context $Context ` + -DryRun:$DryRun ` + -Confirm:$false + + $advancedRepair = @() + if ($Mode -eq 'AdvancedRepair') { + $advancedRepair = @(Invoke-AdvancedNetworkRepair -DryRun:$DryRun) + } + + $tuningResults = @() + if ($Mode -eq 'PerformanceTune') { + $tuningResults = @( + Invoke-NetworkPerformanceTune ` + -PerformanceProfile $PerformanceProfile ` + -DryRun:$DryRun + ) + } + + Add-Member -InputObject $newContext -NotePropertyName Phase -NotePropertyValue 'Clean' -Force + Add-Member -InputObject $newContext -NotePropertyName Clean -NotePropertyValue ([pscustomobject]@{ + Mode = $Mode + DryRun = [bool]$DryRun + WiFi = $wifiResult + Dns = $dnsResult + Arp = $arpResult + AdapterConfiguration = $adapterResult + RegistryArtifacts = $artifacts + Nla = @($nlaResults) + EventLogs = @($logResults) + UserArtifacts = @($userResults) + AdvancedRepair = @($advancedRepair) + PerformanceTuning = @($tuningResults) + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = $wifiResult.Removed + RegistryArtifactsRemoved = $artifacts.RemovedCount + EventLogsTouched = @($logResults).Count + UserArtifactsTouched = @($userResults | Where-Object { $_.Removed }).Count + AdaptersConfigured = $adapterResult.ConfiguredCount + AdaptersSkipped = $adapterResult.SkippedCount + AdapterFailures = $adapterResult.FailedCount + PreferIPv4 = $adapterResult.PreferIPv4 + AdapterRestartRequired = $adapterResult.RequiresRestart + AdvancedRepairActions = @($advancedRepair).Count + PerformanceTuningActions = @($tuningResults).Count + } + }) -Force + + if ($canLog) { + if ($DryRun) { + Write-NetCleanLog -Level INFO -Message ("Preview summary: WiFiWouldRemove={0} AdaptersWouldConfigure={1} AdapterFailures={2} RegistryWouldRemove={3} EventLogsTouched={4} UserArtifactsTouched={5} AdvancedRepairActions={6} PerformanceTuningActions={7}" -f ` + $wifiResult.Removed, + $adapterResult.ConfiguredCount, + $adapterResult.FailedCount, + $artifacts.RemovedCount, + @($logResults).Count, + @($userResults | Where-Object { $_.Removed }).Count, + @($advancedRepair).Count, + @($tuningResults).Count) + + Write-NetCleanLog -Level INFO -Message 'Preview complete. No changes were made.' + } + else { + Write-NetCleanLog -Level INFO -Message ("Phase 3 clean complete. WiFiRemoved={0} AdaptersConfigured={1} AdapterFailures={2} RegistryRemoved={3} EventLogsTouched={4} UserArtifactsTouched={5} AdvancedRepairActions={6} PerformanceTuningActions={7}" -f ` + $wifiResult.Removed, + $adapterResult.ConfiguredCount, + $adapterResult.FailedCount, + $artifacts.RemovedCount, + @($logResults).Count, + @($userResults | Where-Object { $_.Removed }).Count, + @($advancedRepair).Count, + @($tuningResults).Count) + } + } + + # Detailed logging of cleaning actions for auditability + if ($canLog) { + # Wi-Fi removals + if ($wifiResult.Profiles -and $wifiResult.Profiles.Count -gt 0) { + foreach ($p in $wifiResult.Profiles) { + Write-NetCleanLog -Level INFO -Message ("Wi-Fi profile removed or would be removed: {0}" -f $p) + } + } + + # Registry artifact removals summary + if ($artifacts.Results -and $artifacts.Results.Count -gt 0) { + foreach ($r in $artifacts.Results) { + $status = if ($r.Removed) { 'Removed' } elseif ($r.Skipped) { "Skipped: $($r.Reason)" } else { "Failed: $($r.Reason)" } + Write-NetCleanLog -Level INFO -Message ("Registry artifact: {0} => {1}" -f $r.RegistryPath, $status) + } + } + + # Event logs (be defensive: test for properties before accessing them) + foreach ($l in @($logResults)) { + $cmd = $null + if ($null -ne $l) { + if ($l.PSObject.Properties.Name -contains 'Command') { $cmd = $l.Command } + elseif ($l.PSObject.Properties.Name -contains 'Name') { $cmd = $l.Name } + elseif ($l.PSObject.Properties.Name -contains 'LogName') { $cmd = $l.LogName } + } + + $status = '(unknown)' + if ($l -and $l.PSObject.Properties.Name -contains 'Succeeded') { + $status = if ($l.Succeeded) { 'OK' } else { "ERR: $($l.Error)" } + } + + $commandDisplay = if ([string]::IsNullOrWhiteSpace($cmd)) { '(unknown)' } else { $cmd } + Write-NetCleanLog -Level INFO -Message ("Event log operation: {0} => {1}" -f $commandDisplay, $status) + } + + # User artifacts + foreach ($u in @($userResults)) { + $userStatus = if ($u.Succeeded) { 'OK' } else { "ERR: $($u.Reason)" } + Write-NetCleanLog -Level INFO -Message ("User artifact: {0} => {1}" -f $u.Path, $userStatus) + } + + foreach ($adapterOperation in @($adapterResult.Operations)) { + $adapterStatus = if ($adapterOperation.Skipped) { + "Skipped: $($adapterOperation.Reason)" + } + elseif ($adapterOperation.Succeeded) { + 'IPv4 DHCP and Quad9 DNS configured' + } + else { + "Failed: $($adapterOperation.Error)" + } + Write-NetCleanLog -Level INFO -Message ("Adapter configuration: {0} => {1}" -f $adapterOperation.Name, $adapterStatus) + } + + # Advanced repair and tuning actions + foreach ($a in @($advancedRepair)) { Write-NetCleanLog -Level INFO -Message ("Advanced repair action: {0} => ExitCode={1} Succeeded={2}" -f $a.Name, $a.ExitCode, $a.Succeeded) } + foreach ($t in @($tuningResults)) { Write-NetCleanLog -Level INFO -Message ("Performance tuning action: {0} => ExitCode={1} Succeeded={2}" -f $t.Name, $t.ExitCode, $t.Succeeded) } + } + + return $newContext +} + +Export-ModuleMember -Function @( + 'Invoke-NetCleanPhase3Clean', + 'Remove-WiFiProfilesSafe', + 'Clear-DnsCacheSafe', + 'Clear-ArpCacheSafe', + 'Remove-RegistryPathSafe', + 'Remove-NetworkPrivacyArtifactsSafe', + 'Clear-NetworkEventLogsSafe', + 'Clear-UserNetworkArtifactsSafe', + 'Invoke-AdvancedNetworkRepair', + 'Invoke-NetworkPerformanceTune' +) diff --git a/Modules/NetCleanPhase4.ps1 b/Modules/NetCleanPhase4.ps1 new file mode 100644 index 0000000..dc13100 --- /dev/null +++ b/Modules/NetCleanPhase4.ps1 @@ -0,0 +1,1122 @@ +# --------------------------------------------------------------------------- +# Phase 4 - Verify helpers +# --------------------------------------------------------------------------- + +<# +.SYNOPSIS +Independently verifies adapter changes made during conference preparation. +.DESCRIPTION +Re-reads Windows adapter, DNS, encrypted-DNS, and registry state. Verification +requires IPv4 DHCP, the exact Quad9 IPv4/IPv6 resolver set, the Microsoft IPv4 +preference value, and DoH auto-upgrade without plaintext fallback whenever the +corresponding change was applied. Dry runs and contexts without adapter changes +are reported as not applicable. +.PARAMETER Context +Clean-phase context containing the adapter operation ledger. +.OUTPUTS +System.Management.Automation.PSCustomObject +#> +function Test-NetCleanAdapterPostState { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if ( + $null -eq $Context.PSObject.Properties['Clean'] -or + -not $Context.Clean -or + $null -eq $Context.Clean.PSObject.Properties['AdapterConfiguration'] + ) { + return [pscustomobject]@{ + Applicable = $false + Passed = $true + Reason = 'NoAdapterChanges' + Checks = @() + } + } + + if ( + $Context.Clean.PSObject.Properties.Name -contains 'DryRun' -and + $Context.Clean.DryRun + ) { + return [pscustomobject]@{ + Applicable = $false + Passed = $true + Reason = 'DryRun' + Checks = @() + } + } + + $configuration = $Context.Clean.AdapterConfiguration + $expectedDns = @($configuration.DnsServers) + $checks = [System.Collections.Generic.List[object]]::new() + + foreach ($operation in @($configuration.Operations)) { + if ($operation.Skipped) { + continue + } + + if (-not $operation.Succeeded) { + $checks.Add([pscustomobject]@{ + Category = 'AdapterCommand' + Target = $operation.Name + Expected = 'Succeeded' + Actual = $operation.Reason + Passed = $false + Error = $operation.Error + }) + continue + } + + try { + $dhcpStates = @( + Get-NetIPInterface ` + -InterfaceIndex $operation.InterfaceIndex ` + -AddressFamily IPv4 ` + -ErrorAction Stop | + ForEach-Object { [string]$_.Dhcp } + ) + $dhcpPassed = ( + $dhcpStates.Count -gt 0 -and + @($dhcpStates | Where-Object { $_ -ne 'Enabled' }).Count -eq 0 + ) + $checks.Add([pscustomobject]@{ + Category = 'IPv4Dhcp' + Target = $operation.Name + Expected = 'Enabled' + Actual = ($dhcpStates -join ', ') + Passed = $dhcpPassed + Error = $null + }) + } + catch { + $checks.Add([pscustomobject]@{ + Category = 'IPv4Dhcp' + Target = $operation.Name + Expected = 'Enabled' + Actual = $null + Passed = $false + Error = $_.Exception.Message + }) + } + + try { + $actualDns = @( + Get-DnsClientServerAddress ` + -InterfaceIndex $operation.InterfaceIndex ` + -ErrorAction Stop | + ForEach-Object { $_.ServerAddresses } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique + ) + $dnsComparison = Compare-StringSet -Before $expectedDns -After $actualDns + $dnsPassed = ( + @($dnsComparison.Missing).Count -eq 0 -and + @($dnsComparison.Added).Count -eq 0 + ) + $checks.Add([pscustomobject]@{ + Category = 'DnsServers' + Target = $operation.Name + Expected = @($expectedDns | Sort-Object) + Actual = $actualDns + Passed = $dnsPassed + Error = $null + }) + } + catch { + $checks.Add([pscustomobject]@{ + Category = 'DnsServers' + Target = $operation.Name + Expected = $expectedDns + Actual = @() + Passed = $false + Error = $_.Exception.Message + }) + } + } + + $verifyPreference = ( + $configuration.PreferIPv4 -and + $configuration.PSObject.Properties.Name -contains 'IPv4Preference' -and + $configuration.IPv4Preference.Succeeded -and + -not $configuration.IPv4Preference.Skipped + ) + if ($verifyPreference) { + try { + $preference = Get-ItemProperty ` + -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters' ` + -Name 'DisabledComponents' ` + -ErrorAction Stop + $actualPreference = [uint32]$preference.DisabledComponents + $checks.Add([pscustomobject]@{ + Category = 'IPv4Preference' + Target = 'Windows IP stack' + Expected = 32 + Actual = $actualPreference + Passed = ($actualPreference -eq 32) + Error = $null + }) + } + catch { + $checks.Add([pscustomobject]@{ + Category = 'IPv4Preference' + Target = 'Windows IP stack' + Expected = 32 + Actual = $null + Passed = $false + Error = $_.Exception.Message + }) + } + } + + $verifyDoh = ( + $configuration.PSObject.Properties.Name -contains 'DnsOverHttps' -and + $configuration.DnsOverHttps.Supported -and + $configuration.DnsOverHttps.ConfiguredCount -gt 0 + ) + if ($verifyDoh) { + foreach ($serverAddress in $expectedDns) { + try { + $dohEntries = @( + Get-DnsClientDohServerAddress ` + -ServerAddress $serverAddress ` + -ErrorAction Stop + ) + $matchingEntry = @( + $dohEntries | + Where-Object { $_.ServerAddress -eq $serverAddress } + ) | Select-Object -First 1 + $dohPassed = ( + $null -ne $matchingEntry -and + $matchingEntry.DohTemplate -eq 'https://dns.quad9.net/dns-query' -and + [bool]$matchingEntry.AutoUpgrade -and + -not [bool]$matchingEntry.AllowFallbackToUdp + ) + $actualDoh = if ($matchingEntry) { + 'AutoUpgrade={0}; AllowFallbackToUdp={1}' -f ` + ([bool]$matchingEntry.AutoUpgrade), + ([bool]$matchingEntry.AllowFallbackToUdp) + } + else { + 'Missing' + } + $checks.Add([pscustomobject]@{ + Category = 'DnsOverHttps' + Target = $serverAddress + Expected = 'AutoUpgrade=True; AllowFallbackToUdp=False' + Actual = $actualDoh + Passed = $dohPassed + Error = $null + }) + } + catch { + $checks.Add([pscustomobject]@{ + Category = 'DnsOverHttps' + Target = $serverAddress + Expected = 'AutoUpgrade=True; AllowFallbackToUdp=False' + Actual = $null + Passed = $false + Error = $_.Exception.Message + }) + } + } + } + + return [pscustomobject]@{ + Applicable = $true + Passed = (@($checks | Where-Object { -not $_.Passed }).Count -eq 0) + Reason = 'Verified' + Checks = $checks.ToArray() + } +} + +<# +.SYNOPSIS +Verifies the non-adapter cleanup work recorded by Phase 3. +.DESCRIPTION +Re-reads persistent registry, user-artifact, and event-log state after cleanup. +DNS and ARP caches can legitimately repopulate immediately, while stack resets +and tuning changes may require a restart, so those volatile or deferred actions +are represented by their command-result ledger instead of a misleading empty- +state assertion. Wi-Fi and NetworkList absence are independently checked by +Test-NetCleanPostState. +.PARAMETER Context +Clean-phase context containing the operation ledger. +.OUTPUTS +System.Management.Automation.PSCustomObject +#> +function Test-NetCleanCleanupPostState { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if ( + $null -eq $Context.PSObject.Properties['Clean'] -or + -not $Context.Clean + ) { + return [pscustomobject]@{ + Applicable = $false + Passed = $true + Reason = 'NoCleanupResults' + Checks = @() + } + } + + if ( + $Context.Clean.PSObject.Properties.Name -contains 'DryRun' -and + $Context.Clean.DryRun + ) { + return [pscustomobject]@{ + Applicable = $false + Passed = $true + Reason = 'DryRun' + Checks = @() + } + } + + $checks = [System.Collections.Generic.List[object]]::new() + foreach ($propertyName in @('Dns', 'Arp')) { + if ($Context.Clean.PSObject.Properties.Name -notcontains $propertyName) { + continue + } + + $operation = $Context.Clean.$propertyName + if (-not $operation) { + continue + } + + $skippedByOption = ( + $operation.PSObject.Properties.Name -contains 'Skipped' -and + $operation.Skipped -and + $operation.PSObject.Properties.Name -contains 'Reason' -and + $operation.Reason -eq 'SkippedByOption' + ) + $succeeded = ( + $operation.PSObject.Properties.Name -contains 'Succeeded' -and + [bool]$operation.Succeeded + ) + $errorMessage = if ($operation.PSObject.Properties.Name -contains 'Error') { + $operation.Error + } + else { + $null + } + + $checks.Add([pscustomobject]@{ + Category = 'VolatileCacheAction' + Target = $operation.Name + VerificationType = 'CommandResult' + Expected = 'Succeeded or explicitly skipped' + Actual = if ($skippedByOption) { 'SkippedByOption' } elseif ($succeeded) { 'Succeeded' } else { 'Failed' } + Passed = ($succeeded -or $skippedByOption) + Error = $errorMessage + }) + } + + if ( + $Context.Clean.PSObject.Properties.Name -contains 'RegistryArtifacts' -and + $Context.Clean.RegistryArtifacts -and + $Context.Clean.RegistryArtifacts.PSObject.Properties.Name -contains 'Results' + ) { + foreach ($operation in @($Context.Clean.RegistryArtifacts.Results)) { + if (-not $operation) { + continue + } + + $target = $operation.RegistryPath + $reason = if ($operation.PSObject.Properties.Name -contains 'Reason') { + $operation.Reason + } + else { + $null + } + $succeeded = ( + $operation.PSObject.Properties.Name -contains 'Succeeded' -and + [bool]$operation.Succeeded + ) + $protected = ( + $operation.PSObject.Properties.Name -contains 'Skipped' -and + $operation.Skipped -and + $reason -eq 'Protected' + ) + $shouldBeAbsent = ( + $succeeded -and + ( + ($operation.PSObject.Properties.Name -contains 'Removed' -and $operation.Removed) -or + $reason -eq 'NotFound' + ) + ) + + if ($protected) { + $checks.Add([pscustomobject]@{ + Category = 'RegistryArtifact' + Target = $target + VerificationType = 'ProtectionBoundary' + Expected = 'Preserved' + Actual = 'Protected' + Passed = $true + Error = $null + }) + continue + } + + if ($shouldBeAbsent) { + try { + $exists = Test-RegistryPathExist -RegistryPath $target -ThrowOnError + $checks.Add([pscustomobject]@{ + Category = 'RegistryArtifact' + Target = $target + VerificationType = 'IndependentState' + Expected = 'Absent' + Actual = if ($exists) { 'Present' } else { 'Absent' } + Passed = (-not $exists) + Error = $null + }) + } + catch { + $checks.Add([pscustomobject]@{ + Category = 'RegistryArtifact' + Target = $target + VerificationType = 'IndependentState' + Expected = 'Absent' + Actual = 'Unknown' + Passed = $false + Error = $_.Exception.Message + }) + } + continue + } + + $checks.Add([pscustomobject]@{ + Category = 'RegistryArtifact' + Target = $target + VerificationType = 'CommandResult' + Expected = 'Removed, absent, or protected' + Actual = if ($reason) { $reason } else { 'Failed' } + Passed = $false + Error = if ($succeeded) { $null } else { $reason } + }) + } + } + + if ($Context.Clean.PSObject.Properties.Name -contains 'UserArtifacts') { + foreach ($operation in @($Context.Clean.UserArtifacts)) { + if (-not $operation) { + continue + } + + $reason = if ($operation.PSObject.Properties.Name -contains 'Reason') { + $operation.Reason + } + else { + $null + } + $succeeded = ( + $operation.PSObject.Properties.Name -contains 'Succeeded' -and + [bool]$operation.Succeeded + ) + $shouldBeAbsent = ( + $succeeded -and + ( + ($operation.PSObject.Properties.Name -contains 'Removed' -and $operation.Removed) -or + $reason -eq 'NotFound' + ) + ) + + if ($shouldBeAbsent) { + try { + $exists = Test-Path -LiteralPath $operation.Path -ErrorAction Stop + $checks.Add([pscustomobject]@{ + Category = 'UserArtifact' + Target = $operation.Path + VerificationType = 'IndependentState' + Expected = 'Absent' + Actual = if ($exists) { 'Present' } else { 'Absent' } + Passed = (-not $exists) + Error = $null + }) + } + catch { + $checks.Add([pscustomobject]@{ + Category = 'UserArtifact' + Target = $operation.Path + VerificationType = 'IndependentState' + Expected = 'Absent' + Actual = 'Unknown' + Passed = $false + Error = $_.Exception.Message + }) + } + continue + } + + $checks.Add([pscustomobject]@{ + Category = 'UserArtifact' + Target = $operation.Path + VerificationType = 'CommandResult' + Expected = 'Removed or absent' + Actual = if ($reason) { $reason } else { 'Failed' } + Passed = $false + Error = if ($succeeded) { $null } else { $reason } + }) + } + } + + if ($Context.Clean.PSObject.Properties.Name -contains 'EventLogs') { + foreach ($operation in @($Context.Clean.EventLogs)) { + if (-not $operation) { + continue + } + + $cleared = ( + $operation.PSObject.Properties.Name -contains 'Cleared' -and + [bool]$operation.Cleared + ) + $succeeded = ( + $operation.PSObject.Properties.Name -contains 'Succeeded' -and + [bool]$operation.Succeeded + ) + $completedAt = if ($operation.PSObject.Properties.Name -contains 'CompletedAt') { + $operation.CompletedAt + } + else { + $null + } + + if ($cleared -and $succeeded -and $completedAt) { + try { + $priorEvents = @( + Get-WinEvent ` + -FilterHashtable @{ + LogName = $operation.LogName + EndTime = $completedAt + } ` + -MaxEvents 1 ` + -ErrorAction Stop + ) + $checks.Add([pscustomobject]@{ + Category = 'EventLog' + Target = $operation.LogName + VerificationType = 'IndependentState' + Expected = 'No events at or before cleanup completion' + Actual = if ($priorEvents.Count -eq 0) { 'Absent' } else { 'Present' } + Passed = ($priorEvents.Count -eq 0) + Error = $null + }) + } + catch { + $noEventsFound = $_.FullyQualifiedErrorId -like 'NoMatchingEventsFound*' + $checks.Add([pscustomobject]@{ + Category = 'EventLog' + Target = $operation.LogName + VerificationType = 'IndependentState' + Expected = 'No events at or before cleanup completion' + Actual = if ($noEventsFound) { 'Absent' } else { 'Unknown' } + Passed = $noEventsFound + Error = if ($noEventsFound) { $null } else { $_.Exception.Message } + }) + } + continue + } + + $errorMessage = if ($operation.PSObject.Properties.Name -contains 'Error') { + $operation.Error + } + else { + $null + } + $checks.Add([pscustomobject]@{ + Category = 'EventLog' + Target = if ($operation.PSObject.Properties.Name -contains 'LogName') { $operation.LogName } else { $operation.Name } + VerificationType = 'CommandResult' + Expected = 'Cleared with completion timestamp' + Actual = if (-not $succeeded) { 'Failed' } elseif (-not $cleared) { 'NotCleared' } else { 'MissingCompletionTime' } + Passed = $false + Error = $errorMessage + }) + } + } + + foreach ($operationGroup in @( + [pscustomobject]@{ Property = 'AdvancedRepair'; Category = 'AdvancedRepairAction' }, + [pscustomobject]@{ Property = 'PerformanceTuning'; Category = 'PerformanceTuningAction' } + )) { + if ($Context.Clean.PSObject.Properties.Name -notcontains $operationGroup.Property) { + continue + } + + foreach ($operation in @($Context.Clean.($operationGroup.Property))) { + if (-not $operation) { + continue + } + + $succeeded = ( + $operation.PSObject.Properties.Name -contains 'Succeeded' -and + [bool]$operation.Succeeded + ) + $errorMessage = if ($operation.PSObject.Properties.Name -contains 'Error') { + $operation.Error + } + else { + $null + } + $checks.Add([pscustomobject]@{ + Category = $operationGroup.Category + Target = $operation.Name + VerificationType = 'CommandResult' + Expected = 'Succeeded' + Actual = if ($succeeded) { 'Succeeded' } else { 'Failed' } + Passed = $succeeded + Error = $errorMessage + }) + } + } + + $wifiCleanupExpected = ( + $Context.Clean.PSObject.Properties.Name -contains 'WiFi' -and + $Context.Clean.WiFi -and + -not ( + $Context.Clean.WiFi.PSObject.Properties.Name -contains 'Skipped' -and + $Context.Clean.WiFi.Skipped + ) + ) + if ($wifiCleanupExpected) { + try { + $physicalAdapters = @(Get-NetAdapter -Physical -ErrorAction Stop) + $wiredAdapters = [System.Collections.Generic.List[object]]::new() + $wifiAdapters = [System.Collections.Generic.List[object]]::new() + + foreach ($adapter in $physicalAdapters) { + $mediaTypes = [System.Collections.Generic.List[string]]::new() + foreach ($propertyName in @('MediaType', 'PhysicalMediaType')) { + if ( + $adapter.PSObject.Properties.Name -contains $propertyName -and + -not [string]::IsNullOrWhiteSpace([string]$adapter.$propertyName) + ) { + $mediaTypes.Add([string]$adapter.$propertyName) + } + } + + $ndisMedium = if ($adapter.PSObject.Properties.Name -contains 'NdisPhysicalMedium') { + [int]$adapter.NdisPhysicalMedium + } + else { + -1 + } + $isWired = ( + $mediaTypes -contains '802.3' -or + $ndisMedium -eq 14 + ) + $isWifi = ( + $mediaTypes -contains 'Native 802.11' -or + $mediaTypes -contains '802.11' -or + $mediaTypes -contains 'Wireless LAN' -or + $ndisMedium -in @(1, 9) + ) + + if ($isWired) { + $wiredAdapters.Add($adapter) + } + elseif ($isWifi) { + $wifiAdapters.Add($adapter) + } + } + + $connectedWired = @($wiredAdapters | Where-Object { $_.Status -eq 'Up' }) + $connectedWifi = @($wifiAdapters | Where-Object { $_.Status -eq 'Up' }) + $checks.Add([pscustomobject]@{ + Category = 'WiFiConnection' + Target = 'Physical Wi-Fi adapters' + VerificationType = 'IndependentState' + Applicable = $true + Expected = 'Disconnected' + Actual = if ($connectedWifi.Count -eq 0) { 'Disconnected' } else { @($connectedWifi.Name) } + Passed = ($connectedWifi.Count -eq 0) + Error = $null + }) + + if ($connectedWired.Count -gt 0) { + foreach ($category in @('DnsCache', 'ArpCache')) { + $checks.Add([pscustomobject]@{ + Category = $category + Target = 'Local cache' + VerificationType = 'ConditionalState' + Applicable = $false + Expected = 'Not evaluated while wired LAN is connected' + Actual = 'WiredLanConnected' + Passed = $true + Error = $null + }) + } + } + else { + $dnsWasApplied = ( + $Context.Clean.PSObject.Properties.Name -contains 'Dns' -and + $Context.Clean.Dns -and + $Context.Clean.Dns.PSObject.Properties.Name -contains 'Succeeded' -and + $Context.Clean.Dns.Succeeded -and + -not ( + $Context.Clean.Dns.PSObject.Properties.Name -contains 'Skipped' -and + $Context.Clean.Dns.Skipped + ) + ) + if ($dnsWasApplied) { + try { + $dnsEntries = @(Get-DnsClientCache -ErrorAction Stop) + $checks.Add([pscustomobject]@{ + Category = 'DnsCache' + Target = 'DNS client cache' + VerificationType = 'ConditionalState' + Applicable = $true + Expected = 'Empty when no wired LAN is connected' + Actual = $dnsEntries.Count + Passed = ($dnsEntries.Count -eq 0) + Error = $null + }) + } + catch { + $checks.Add([pscustomobject]@{ + Category = 'DnsCache' + Target = 'DNS client cache' + VerificationType = 'ConditionalState' + Applicable = $true + Expected = 'Empty when no wired LAN is connected' + Actual = 'Unknown' + Passed = $false + Error = $_.Exception.Message + }) + } + } + + $arpWasApplied = ( + $Context.Clean.PSObject.Properties.Name -contains 'Arp' -and + $Context.Clean.Arp -and + $Context.Clean.Arp.PSObject.Properties.Name -contains 'Succeeded' -and + $Context.Clean.Arp.Succeeded -and + -not ( + $Context.Clean.Arp.PSObject.Properties.Name -contains 'Skipped' -and + $Context.Clean.Arp.Skipped + ) + ) + if ($arpWasApplied) { + try { + $physicalInterfaceIndexes = @( + $physicalAdapters | + Where-Object { + $_.PSObject.Properties.Name -contains 'InterfaceIndex' -and + $null -ne $_.InterfaceIndex + } | + Select-Object -ExpandProperty InterfaceIndex -Unique + ) + $dynamicNeighbors = if ($physicalInterfaceIndexes.Count -eq 0) { + @() + } + else { + @( + Get-NetNeighbor ` + -InterfaceIndex $physicalInterfaceIndexes ` + -AddressFamily IPv4 ` + -PolicyStore ActiveStore ` + -ErrorAction Stop | + Where-Object { [string]$_.State -ne 'Permanent' } + ) + } + $dynamicNeighborCount = @($dynamicNeighbors).Count + $checks.Add([pscustomobject]@{ + Category = 'ArpCache' + Target = 'Physical-adapter IPv4 neighbor cache' + VerificationType = 'ConditionalState' + Applicable = $true + Expected = 'No dynamic entries when no wired LAN is connected' + Actual = @($dynamicNeighbors | ForEach-Object { $_.IPAddress }) + Passed = ($dynamicNeighborCount -eq 0) + Error = $null + }) + } + catch { + $checks.Add([pscustomobject]@{ + Category = 'ArpCache' + Target = 'Physical-adapter IPv4 neighbor cache' + VerificationType = 'ConditionalState' + Applicable = $true + Expected = 'No dynamic entries when no wired LAN is connected' + Actual = 'Unknown' + Passed = $false + Error = $_.Exception.Message + }) + } + } + } + } + catch { + $checks.Add([pscustomobject]@{ + Category = 'ConnectivityDetection' + Target = 'Physical network adapters' + VerificationType = 'IndependentState' + Applicable = $true + Expected = 'Adapter state available' + Actual = 'Unknown' + Passed = $false + Error = $_.Exception.Message + }) + } + } + + return [pscustomobject]@{ + Applicable = $true + Passed = (@($checks | Where-Object { -not $_.Passed }).Count -eq 0) + Reason = 'Verified' + Checks = $checks.ToArray() + } +} + +<# +.SYNOPSIS +Performs post-cleaning state verification by comparing inventories before and after cleaning. +.DESCRIPTION +Compares protected inventory before and after cleaning and checks that saved + Wi-Fi and Windows NetworkList profiles no longer remain, and independently + verifies adapter changes recorded by the clean phase. Verification succeeds + only when protected inventory was preserved, targeted traces were removed, + and every applicable adapter post-state check passes. +.PARAMETER Context +The context object containing the pre-cleaning inventory and other relevant information. +.EXAMPLE +Test-NetCleanPostState -Context $ctx +.OUTPUTS +A custom object containing protected-inventory comparisons, remaining privacy +artifacts, and an overall pass/fail status. +.NOTES +- This function assumes that the pre-cleaning inventory was accurately captured during the detect/protect phases. Ensure that those phases completed successfully for reliable verification results. +#> +function Test-NetCleanPostState { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + $canlog = $null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue) + + if ($canlog) { Write-NetCleanLog -Level INFO -Message 'Phase 4 verify started.' } + + $preInventory = @($Context.Inventory) + $postInventory = @(Get-ProtectionInventory) + + $preVendors = @($preInventory | Select-Object -ExpandProperty Vendor -Unique | Sort-Object) + $postVendors = @($postInventory | Select-Object -ExpandProperty Vendor -Unique | Sort-Object) + + $preGuids = @(Get-ProtectedInterfaceGuidSet -Inventory $preInventory) + $postGuids = @(Get-ProtectedInterfaceGuidSet -Inventory $postInventory) + + $vendorComparison = Compare-StringSet -Before $preVendors -After $postVendors + $guidComparison = Compare-StringSet -Before $preGuids -After $postGuids + + $preServices = @( + $preInventory | + ForEach-Object { $_.Services } | + Where-Object { $_ } | + Sort-Object -Unique + ) + + foreach ($svc in $preServices) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Pre-cleaning protected service: {0}" -f $svc) + } + } + + $postServices = @( + $postInventory | + ForEach-Object { $_.Services } | + Where-Object { $_ } | + Sort-Object -Unique + ) + + foreach ($svc in $postServices) { + if ($canLog) { + Write-NetCleanLog -Level INFO -Message ("Post-cleaning protected service: {0}" -f $svc) + } + } + + if ($canlog) { Write-NetCleanLog -Level INFO -Message 'Phase 4 verify completed (inventory gathered).' } + + $serviceComparison = Compare-StringSet -Before $preServices -After $postServices + $isDryRun = ( + $Context.PSObject.Properties.Name -contains 'Clean' -and + $Context.Clean -and + $Context.Clean.PSObject.Properties.Name -contains 'DryRun' -and + $Context.Clean.DryRun + ) + $remainingWiFiProfiles = @() + $remainingNetworkProfiles = @() + if (-not $isDryRun) { + $remainingWiFiProfiles = @(Get-WiFiProfileName) + $remainingNetworkProfiles = @(Get-NetworkListProfileName) + } + $adapterVerification = Test-NetCleanAdapterPostState -Context $Context + $cleanupVerification = Test-NetCleanCleanupPostState -Context $Context + + return [pscustomobject]@{ + VerificationMode = if ($isDryRun) { 'Planned' } else { 'Observed' } + PreInventory = $preInventory + PostInventory = $postInventory + VendorComparison = $vendorComparison + GuidComparison = $guidComparison + ServiceComparison = $serviceComparison + RemainingWiFiProfiles = $remainingWiFiProfiles + RemainingNetworkProfiles = $remainingNetworkProfiles + AdapterVerification = $adapterVerification + CleanupVerification = $cleanupVerification + Passed = ( + @($vendorComparison.Missing).Count -eq 0 -and + @($guidComparison.Missing).Count -eq 0 -and + @($serviceComparison.Missing).Count -eq 0 -and + $remainingWiFiProfiles.Count -eq 0 -and + $remainingNetworkProfiles.Count -eq 0 -and + $adapterVerification.Passed -and + $cleanupVerification.Passed + ) + } +} + +<# +.SYNOPSIS +Writes a machine-readable post-cleanup verification ledger. +.DESCRIPTION +Serializes the independently observed protection, privacy-artifact, and adapter +checks to UTF-8 JSON in the private backup directory. The report contains no +pre-cleaning inventory beyond missing-item names needed to explain failures. +.PARAMETER Dest +Private backup directory that receives the report. +.PARAMETER Verification +Post-state verification object returned by Test-NetCleanPostState. +.PARAMETER DryRun +Returns the planned path without creating or changing files. +.OUTPUTS +System.String +#> +function Export-NetCleanVerificationReport { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter(Mandatory = $true)] + [string]$Dest, + + [Parameter(Mandatory = $true)] + [pscustomobject]$Verification, + + [switch]$DryRun + ) + + $file = Join-Path $Dest ("VerificationReport_{0}.json" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + if ($DryRun) { + return $file + } + + New-DirectoryIfNotExist -Path $Dest + Set-NetCleanPrivateDirectoryAcl -Path $Dest + + $report = [ordered]@{ + VerifiedAt = (Get-Date).ToString('s') + VerificationMode = $Verification.VerificationMode + Passed = [bool]$Verification.Passed + ProtectedInventory = [ordered]@{ + MissingVendors = @($Verification.VendorComparison.Missing) + MissingInterfaceGuids = @($Verification.GuidComparison.Missing) + MissingServices = @($Verification.ServiceComparison.Missing) + } + PrivacyArtifacts = [ordered]@{ + RemainingWiFiProfiles = @($Verification.RemainingWiFiProfiles) + RemainingNetworkProfiles = @($Verification.RemainingNetworkProfiles) + } + AdapterVerification = $Verification.AdapterVerification + CleanupVerification = $Verification.CleanupVerification + } + + $json = $report | ConvertTo-Json -Depth 10 + WriteAllText -Path $file -Contents $json -Encoding $script:Utf8NoBom + return $file +} + +<# +.SYNOPSIS +Performs verification checks after cleaning to assess the state of the system. +.DESCRIPTION +Confirms that protected inventory remains intact and that saved Wi-Fi and +Windows NetworkList profiles were removed. +.PARAMETER Context +The context object containing the pre- and post-cleaning inventories. +.EXAMPLE +Invoke-NetCleanPhase4Verify -Context $ctx +.OUTPUTS +A context object enriched with verification results, including comparisons of vendors, GUIDs, and services, and a summary of the verification outcome. +.NOTES +- This function relies on the integrity of the inventories collected during the detect and protect phases. Ensure that those phases completed successfully for accurate verification. +#> +function Invoke-NetCleanPhase4Verify { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { Write-NetCleanLog -Level INFO -Message 'Invoke-NetCleanPhase4Verify: starting verification.' } + + $verification = Test-NetCleanPostState -Context $Context + $verificationReport = $null + if ( + $Context.PSObject.Properties.Name -contains 'BackupPath' -and + -not [string]::IsNullOrWhiteSpace($Context.BackupPath) + ) { + $verificationReport = Export-NetCleanVerificationReport ` + -Dest $Context.BackupPath ` + -Verification $verification ` + -DryRun:($verification.VerificationMode -eq 'Planned') + } + + $newContext = [pscustomobject]@{} + foreach ($p in $Context.PSObject.Properties) { + Add-Member -InputObject $newContext -NotePropertyName $p.Name -NotePropertyValue $p.Value + } + + Add-Member -InputObject $newContext -NotePropertyName Phase -NotePropertyValue 'Verify' -Force + Add-Member -InputObject $newContext -NotePropertyName Verify -NotePropertyValue ([pscustomobject]@{ + Passed = $verification.Passed + VendorComparison = $verification.VendorComparison + GuidComparison = $verification.GuidComparison + ServiceComparison = $verification.ServiceComparison + RemainingWiFiProfiles = $verification.RemainingWiFiProfiles + RemainingNetworkProfiles = $verification.RemainingNetworkProfiles + AdapterVerification = $verification.AdapterVerification + CleanupVerification = $verification.CleanupVerification + VerificationReport = $verificationReport + Summary = [pscustomobject]@{ + MissingVendorsCount = @($verification.VendorComparison.Missing).Count + MissingGuidCount = @($verification.GuidComparison.Missing).Count + MissingServiceCount = @($verification.ServiceComparison.Missing).Count + RemainingWiFiProfileCount = @($verification.RemainingWiFiProfiles).Count + RemainingNetworkProfileCount = @($verification.RemainingNetworkProfiles).Count + AdapterCheckFailureCount = @( + $verification.AdapterVerification.Checks | + Where-Object { -not $_.Passed } + ).Count + CleanupCheckFailureCount = @( + $verification.CleanupVerification.Checks | + Where-Object { -not $_.Passed } + ).Count + Passed = $verification.Passed + } + }) -Force + + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { Write-NetCleanLog -Level INFO -Message ('Invoke-NetCleanPhase4Verify: verification complete. Passed={0}' -f $verification.Passed) } + + # Detailed verification logging + if ($null -ne (Get-Command Write-NetCleanLog -ErrorAction SilentlyContinue)) { + if ($verification.VendorComparison -and $verification.VendorComparison.Missing.Count -gt 0) { + Write-NetCleanLog -Level WARN -Message ("Verification: Missing vendors: {0}" -f ($verification.VendorComparison.Missing -join ', ')) + } + else { + Write-NetCleanLog -Level INFO -Message 'Verification: No missing vendors detected.' + } + + if ($verification.GuidComparison -and $verification.GuidComparison.Missing.Count -gt 0) { + Write-NetCleanLog -Level WARN -Message ("Verification: Missing GUIDs: {0}" -f ($verification.GuidComparison.Missing -join ', ')) + } + else { + Write-NetCleanLog -Level INFO -Message 'Verification: No missing protected GUIDs detected.' + } + + if ($verification.ServiceComparison -and $verification.ServiceComparison.Missing.Count -gt 0) { + Write-NetCleanLog -Level WARN -Message ("Verification: Missing services: {0}" -f ($verification.ServiceComparison.Missing -join ', ')) + } + else { + Write-NetCleanLog -Level INFO -Message 'Verification: No missing protected services detected.' + } + + if (@($verification.RemainingWiFiProfiles).Count -gt 0) { + Write-NetCleanLog -Level WARN -Message ("Verification: Remaining Wi-Fi profiles: {0}" -f ($verification.RemainingWiFiProfiles -join ', ')) + } + + if (@($verification.RemainingNetworkProfiles).Count -gt 0) { + Write-NetCleanLog -Level WARN -Message ("Verification: Remaining NetworkList profiles: {0}" -f ($verification.RemainingNetworkProfiles -join ', ')) + } + + foreach ($check in @($verification.AdapterVerification.Checks)) { + $level = if ($check.Passed) { 'INFO' } else { 'WARN' } + $message = 'Verification: {0} Target={1} Passed={2} Expected={3} Actual={4}' -f ` + $check.Category, + $check.Target, + $check.Passed, + (@($check.Expected) -join ', '), + (@($check.Actual) -join ', ') + if ($check.Error) { + $message += " Error=$($check.Error)" + } + Write-NetCleanLog -Level $level -Message $message + } + + foreach ($check in @($verification.CleanupVerification.Checks)) { + $level = if ($check.Passed) { 'INFO' } else { 'WARN' } + $applicable = if ($check.PSObject.Properties.Name -contains 'Applicable') { + [bool]$check.Applicable + } + else { + $true + } + $expected = if ($check.PSObject.Properties.Name -contains 'Expected') { + @($check.Expected) -join ', ' + } + else { + $null + } + $actual = if ($check.PSObject.Properties.Name -contains 'Actual') { + @($check.Actual) -join ', ' + } + else { + $null + } + $errorMessage = if ($check.PSObject.Properties.Name -contains 'Error') { + $check.Error + } + else { + $null + } + $message = 'Verification: {0} Target={1} Applicable={2} Passed={3} Expected={4} Actual={5}' -f ` + $check.Category, + $check.Target, + $applicable, + $check.Passed, + $expected, + $actual + if ($errorMessage) { + $message += " Error=$errorMessage" + } + Write-NetCleanLog -Level $level -Message $message + } + } + + # Console summary for verification + Write-Information (("Phase 4 verify: Passed={0} MissingVendors={1} MissingGuids={2} MissingServices={3} RemainingWiFi={4} RemainingNetworkProfiles={5} AdapterCheckFailures={6} CleanupCheckFailures={7}" -f ` + $verification.Passed, + @($verification.VendorComparison.Missing).Count, + @($verification.GuidComparison.Missing).Count, + @($verification.ServiceComparison.Missing).Count, + @($verification.RemainingWiFiProfiles).Count, + @($verification.RemainingNetworkProfiles).Count, + @($verification.AdapterVerification.Checks | Where-Object { -not $_.Passed }).Count, + @($verification.CleanupVerification.Checks | Where-Object { -not $_.Passed }).Count)) -InformationAction Continue + + return $newContext +} + +Export-ModuleMember -Function @( + 'Invoke-NetCleanPhase4Verify', + 'Test-NetCleanPostState' +) diff --git a/Netclean.psd1 b/Netclean.psd1 index f8603bb..8988622 100644 --- a/Netclean.psd1 +++ b/Netclean.psd1 @@ -1,17 +1,35 @@ @{ - RootModule = 'Netclean.psm1' - ModuleVersion = '0.1.0' - GUID = 'e6a9b5c4-0000-4000-8000-000000000001' - Author = 'NetworkCleaner' - CompanyName = 'NetworkCleaner' - Copyright = '(c) NetworkCleaner' - Description = 'Core helpers for NetworkCleaner - testable functions' - FunctionsToExport = @('Convert-RegKeyPath','Convert-NormalizeGuid','Get-InstalledAV','Derive-AVServicePatterns','Build-ProtectionLists') + RootModule = 'Modules\NetClean.psm1' + ModuleVersion = '1.0.0' + GUID = 'e6a9b5c4-0000-4000-8000-000000000001' + Author = 'Sean Weeks' + CompanyName = 'Open Source' + Copyright = '(c) Sean Weeks. All rights reserved.' + Description = 'Phase-oriented PowerShell toolkit for detecting, protecting, cleaning, and verifying network privacy artifacts.' + PowerShellVersion = '5.1' + + FunctionsToExport = @( + 'Invoke-NetCleanPhase1Detect', + 'Invoke-NetCleanPhase2Protect', + 'Invoke-NetCleanPhase3Clean', + 'Invoke-NetCleanPhase4Verify', + 'Invoke-NetCleanWorkflow', + 'Get-NetCleanLogFile', + 'Start-NetCleanLog', + 'Write-NetCleanLog' + ) + + AliasesToExport = @( + 'Backup-NetworkList', + 'Backup-ProtectedRegistryKeys', + 'Backup-WiFiProfiles' + ) + PrivateData = @{ PSData = @{ - Tags = @('network','cleanup','test') - LicenseUri = 'https://opensource.org/licenses/MIT' - ProjectUri = 'https://github.com/example/NetworkCleaner' + ProjectUri = 'https://github.com/scweeks/netclean' + LicenseUri = 'https://github.com/scweeks/netclean/blob/ci/pester-v5-coverage/LICENSE' + Tags = @('PowerShell', 'Networking', 'Privacy', 'Diagnostics', 'Windows') } } } diff --git a/Netclean.psm1 b/Netclean.psm1 deleted file mode 100644 index 7c57a37..0000000 --- a/Netclean.psm1 +++ /dev/null @@ -1,196 +0,0 @@ -# Netclean PowerShell module - core, testable helpers - -function Convert-RegKeyPath { - [CmdletBinding()] - param( - [Parameter(Mandatory=$true)][string]$Path - ) - if (-not $Path) { return $null } - $p = $Path.ToString() - $p = $p -replace 'Microsoft\.PowerShell\.Core\\Registry::','' - $p = $p -replace '^HKLM:\\?','HKLM\\' - $p = $p -replace '\\$','' - return $p -} - -function Convert-Guid { - [CmdletBinding()] - param( - [Parameter(Mandatory=$true)][string]$Guid - ) - if (-not $Guid) { return $null } - return ($Guid -replace '[{}]','').ToLower() -} - -function Get-InstalledAV { - [CmdletBinding()] - param() - $found = @() - try { - $wmi = Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntivirusProduct -ErrorAction SilentlyContinue - if ($wmi) { $found += $wmi.displayName } - } catch { - Write-Verbose 'SecurityCenter query failed.' - } - $common = @('MsMpSvc','WinDefend','vsserv','BDService','CSFalconService','SentinelAgent','sophos','savservice') - foreach ($s in $common) { - try { - if (Get-Service -Name $s -ErrorAction SilentlyContinue) { $found += $s } - } catch { - Write-Verbose ("Service check failed for {0}: {1}" -f $s, $_) - } - } - return ($found | Sort-Object -Unique) -} - -function Get-AVServicePattern { - [CmdletBinding()] - param( - [Parameter(Mandatory=$true)][string[]]$AvList - ) - $patterns = @() - $map = @{ - 'bitdefender' = @('vsserv','BDService') - 'crowdstrike' = @('CSFalconService','csagent','falcon') - 'sentinelone' = @('SentinelAgent','SentinelCtl','Sentinel') - 'carbon' = @('Cb','cb') - 'symantec' = @('Symantec','Smc') - 'sophos' = @('SAVService','Sophos') - 'windows' = @('WinDefend','MsMpSvc') - } - foreach ($a in $AvList) { - $an = $a.ToString().ToLower() - foreach ($k in $map.Keys) { - if ($an -like "*$k*") { $patterns += $map[$k] } - } - } - return ($patterns | Sort-Object -Unique) -} - -function Get-ProtectionList { - [CmdletBinding()] - param() - $detected = Get-InstalledAV - $svcPatterns = @() - $driverPatterns = @() - $adapterPatterns = @() - $registryPaths = @() - - $vendorMap = @{ - 'bitdefender' = @{ Services=@('vsserv','bdservice'); Drivers=@('npf','bd*cpt'); Adapters=@('vmware','bd'); Reg=@('SOFTWARE\\Bitdefender') } - 'malwarebytes' = @{ Services=@('MBAMService','MBAMProtector'); Drivers=@('mbam*'); Adapters=@('Malwarebytes'); Reg=@('SOFTWARE\\Malwarebytes') } - 'crowdstrike' = @{ Services=@('CSFalconService'); Drivers=@('cs*'); Adapters=@('CrowdStrike'); Reg=@('') } - 'sentinelone' = @{ Services=@('SentinelAgent'); Drivers=@('Sentinel'); Adapters=@('SentinelOne'); Reg=@('') } - 'sophos' = @{ Services=@('SAVService'); Drivers=@('SAV*'); Adapters=@('Sophos'); Reg=@('SOFTWARE\\SOPHOS') } - 'microsoft' = @{ Services=@('MsMpSvc','WinDefend'); Drivers=@('wd*'); Adapters=@('vEthernet','Hyper-V'); Reg=@('SOFTWARE\\Microsoft\\Windows Defender') } - } - - foreach ($d in $detected) { - $dn = $d.ToString().ToLower() - foreach ($k in $vendorMap.Keys) { - if ($dn -like "*$k*") { - $entry = $vendorMap[$k] - $svcPatterns += $entry.Services - $driverPatterns += $entry.Drivers - $adapterPatterns += $entry.Adapters - $registryPaths += $entry.Reg - } - } - } - - $adapterPatterns += @('VMware','VMnet','vboxnet','vEthernet','Hyper-V','VirtualBox','Parallels') - $svcPatterns += @('vmnat','vmnetbridge','VMWareHostd','VBoxService') - - return @{ Services=($svcPatterns|Sort-Object -Unique); Drivers=($driverPatterns|Sort-Object -Unique); Adapters=($adapterPatterns|Sort-Object -Unique); Registry=($registryPaths|Sort-Object -Unique) } -} - -function Convert-RegKeyPathAlias { - [CmdletBinding()] - param( - [Parameter(Mandatory=$true)][string]$Path - ) - return Convert-RegKeyPath -Path $Path -} - -function Convert-GuidAlias { - [CmdletBinding()] - param( - [Parameter(Mandatory=$true)][string]$Guid - ) - return Convert-Guid -Guid $Guid -} - -function Export-ProtectedRegistryKey { - [CmdletBinding()] - param( - [Parameter(Mandatory=$true)][string[]]$Paths, - [Parameter(Mandatory=$true)][string]$Dest, - [switch]$DryRun - ) - $exported = @() - if (-not $Paths) { return $exported } - if (-not (Test-Path $Dest)) { New-Item -Path $Dest -ItemType Directory -Force | Out-Null } - foreach ($p in $Paths) { - if (-not $p) { continue } - $key = Convert-RegKeyPath -Path $p - if (-not $key) { continue } - $safe = ($key -replace '[^a-zA-Z0-9_.-]','_') - $file = Join-Path $Dest ("reg_backup_${safe}_$(Get-Date -Format yyyyMMdd_HHmmss).reg") - $regArgs = @('export',$key,$file,'/y') - if ($DryRun) { Write-Verbose "DRYRUN: reg $($regArgs -join ' ')"; $exported += $file } - else { - Start-Process -FilePath 'reg' -ArgumentList $regArgs -NoNewWindow -Wait -ErrorAction Stop - $exported += $file - } - } - Write-Output -InputObject ([object[]]$exported) -NoEnumerate -} - -function Export-NetworkList { - [CmdletBinding()] - param( - [Parameter(Mandatory=$true)][string]$Dest, - [switch]$DryRun - ) - $key = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList' - $file = Join-Path $Dest ("NetworkList_$(Get-Date -Format yyyyMMdd_HHmmss).reg") - $regArgs = @('export',$key,$file,'/y') - if ($DryRun) { Write-Verbose "DRYRUN: reg $($regArgs -join ' ')" } else { Start-Process -FilePath 'reg' -ArgumentList $regArgs -NoNewWindow -Wait -ErrorAction Stop } - return $file -} - -function Export-WiFiProfile { - [CmdletBinding()] - param( - [Parameter(Mandatory=$true)][string]$Dest, - [switch]$DryRun - ) - $exported = @() - $listFile = Join-Path $Dest ("WiFiProfiles_$(Get-Date -Format yyyyMMdd_HHmmss).txt") - if (-not (Test-Path $Dest)) { New-Item -Path $Dest -ItemType Directory -Force | Out-Null } - $profiles = (netsh wlan show profiles | Select-String 'All User Profile' | ForEach-Object { ($_ -split ':')[1].Trim() }) - if ($profiles) { - if ($DryRun) { Write-Verbose "DRYRUN: would write Wi-Fi profile list to $listFile"; $exported += $listFile } - else { $profiles | Out-File -FilePath $listFile -Encoding UTF8; $exported += $listFile } - foreach ($p in $profiles) { - $out = Join-Path $Dest ("WiFiProfile_$([System.Uri]::EscapeDataString($p)).xml") - if ($DryRun) { Write-Verbose "DRYRUN: netsh wlan export profile name=\"$p\" folder=\"$Dest\""; $exported += $out } - else { netsh wlan export profile name="$p" folder="$Dest" | Out-Null; $exported += $out } - } - } - Write-Output -InputObject ([object[]]$exported) -NoEnumerate -} - -## Create backward-compatible aliases for original names (no new function definitions) -Set-Alias -Name Convert-NormalizeGuid -Value Convert-Guid -Force -Set-Alias -Name Normalize-Guid -Value Convert-Guid -Force -Set-Alias -Name Derive-AVServicePatterns -Value Get-AVServicePattern -Force -Set-Alias -Name Build-ProtectionLists -Value Get-ProtectionList -Force -Set-Alias -Name Backup-ProtectedRegistryKeys -Value Export-ProtectedRegistryKey -Force -Set-Alias -Name Backup-NetworkList -Value Export-NetworkList -Force -Set-Alias -Name Backup-WiFiProfiles -Value Export-WiFiProfile -Force -Set-Alias -Name Get-ProtectionLists -Value Get-ProtectionList -Force -Set-Alias -Name Get-AVServicePatterns -Value Get-AVServicePattern -Force -Set-Alias -Name Export-ProtectedRegistryKeys -Value Export-ProtectedRegistryKey -Force - -Export-ModuleMember -Function Convert-RegKeyPath, Convert-Guid, Get-InstalledAV, Get-AVServicePattern, Get-ProtectionList, Export-ProtectedRegistryKey, Export-NetworkList, Export-WiFiProfile -Alias Convert-NormalizeGuid, Normalize-Guid, Derive-AVServicePatterns, Build-ProtectionLists, Backup-ProtectedRegistryKeys, Backup-NetworkList, Backup-WiFiProfiles, Get-ProtectionLists, Get-AVServicePatterns, Export-ProtectedRegistryKeys diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index 5147077..33763b2 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -4,8 +4,13 @@ PSAvoidUsingCmdletAliases = @{ Enable = $true; Severity = 'Error' } PSAvoidUsingWriteHost = @{ Enable = $true; Severity = 'Warning' } PSUseApprovedVerbs = @{ Enable = $false; Severity = 'Warning' } + # PSScriptAnalyzer 1.25.0 throws a NullReferenceException while this + # rule resolves the module's explicit Export-ModuleMember command. + # Keep the runner fail-closed and disable only the defective rule. + PSReservedCmdletChar = @{ Enable = $false; Severity = 'Warning' } PSAvoidUsingInvokeExpression = @{ Enable = $true; Severity = 'Error' } PSAvoidGlobalVars = @{ Enable = $true; Severity = 'Warning' } PSUseShouldProcessForStateChangingFunctions = @{ Enable = $true; Severity = 'Warning' } + UnexpectedAttribute = @{ Enable = $false; Severity = 'Warning' } } } diff --git a/README.md b/README.md index 46689fc..bed63da 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,126 @@ -# netclean +# NetClean -[![CI](https://github.com/scweeks/netclean/actions/workflows/powershell-check.yml/badge.svg)](https://github.com/scweeks/netclean/actions/workflows/powershell-check.yml) [![License](https://img.shields.io/badge/license-See%20LICENSE-lightgrey.svg)](LICENSE) +[![CI](https://github.com/scweeks/netclean/actions/workflows/ci.yml/badge.svg)](https://github.com/scweeks/netclean/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE) -netclean is an aggressive-but-safe Windows network and log cleaner designed to prepare a Windows system for attending a conference, workshop, or participating in a Capture The Flag (CTF) event. The script helps remove identifying network traces (Wi‑Fi profiles, NetworkList entries, logs, caches) while providing safe backups and protections for security products and virtual adapters. +NetClean is a Windows PowerShell tool for detecting, backing up, cleaning, and verifying selected network-history artifacts before using a system at a conference, workshop, or security event. It uses an inventory of security products, services, drivers, and network adapters to avoid modifying identified protected artifacts. -> Warning: Run this script only on systems you control. It makes potentially destructive changes to networking state and event logs. Use `-DryRun` first to preview actions. +> [!WARNING] +> Run NetClean only on systems you own or administer. Cleanup modes can remove Wi-Fi profiles, registry data, event logs, and user history. Start with `Preview` or `-DryRun`, review the backup, and keep a separate recovery path. -## Features - -- Back up `NetworkList` and Wi‑Fi profiles to a protected folder. -- Remove all Wi‑Fi profiles (optionally exported for restore). -- Reset network state: Winsock, IPv4/IPv6 stacks, flush DNS, clear ARP. -- Clear NLA probing keys and selected networking event logs. -- Detect and preserve common AV/EDR and hypervisor artifacts (services, drivers, registry paths). -- Interactive prompts when run without switches; fully scriptable with command-line switches. - -## Summary of actions - -- Export the `NetworkList` registry key and Wi‑Fi profiles (XML) to backups. -- Build protection lists for AV/EDR and hypervisor adapters to avoid modifying them. -- Optionally delete DHCP/WLAN cache files and WLAN logs (skipped when endpoint protection detected unless `-Force`). -- Stop and restart a small set of networking services (`WlanSvc`, `Dnscache`, `Dhcp`, `NlaSvc`, etc.). -- Remove non-VM `NetworkList` profiles and signatures, and clear selected event logs. -- Optionally reboot the system when complete. +## Requirements -## What it does NOT do +- Windows 10 or Windows 11. +- Windows PowerShell 5.1 or PowerShell 7. +- Administrator privileges for complete detection, backup, cleanup, and verification. -- Modify Windows Firewall rules. -- Uninstall or remove AV/EDR products. -- Intentionally edit protections for Bitdefender, VMware, or other commonly detected vendor drivers/services. +## Modes -## Requirements +| Mode | Behavior | +|---|---| +| `Menu` | Interactive mode selection. | +| `Preview` | Runs detection and simulated protection/backup planning without cleanup. | +| `SafeConferencePrep` | Detects, backs up, performs the standard cleanup, and verifies protected inventory. | +| `AdvancedRepair` | Adds network-stack repair actions to the standard workflow. | +| `PerformanceTune` | Direct invocation only while network-optimization work is tabled; adds the selected performance profile to the standard workflow. | -- Windows 10 / Windows 11 with PowerShell. -- Must be run as Administrator. The script will prompt to relaunch elevated if needed. +The interactive menu currently offers Preview, Safe conference prep, Advanced repair, and Exit. Performance tuning remains available through the explicit `-Mode PerformanceTune` parameter for backward compatibility, but it is intentionally omitted from the menu while that work is tabled. ## Quick start -Preview what will happen (safe): +Preview without making changes: ```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File netclean.ps1 -DryRun -CreateLog +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\NetClean.ps1 -Mode Preview -CreateLog ``` -Run the full cleanup (creates log): +Run the standard workflow as a dry run: ```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File netclean.ps1 -CreateLog +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\NetClean.ps1 -Mode SafeConferencePrep -DryRun -CreateLog ``` -Create backups only and exit: +Run the standard workflow after reviewing the preview and backup plan: ```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File netclean.ps1 -OnlyBackup -CreateLog +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\NetClean.ps1 -Mode SafeConferencePrep -CreateLog ``` -Force deletions that are otherwise skipped due to detected AV/EDR: +## Launcher parameters -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File netclean.ps1 -Force -CreateLog -``` +| Parameter | Description | +|---|---| +| `-Mode ` | Selects `Menu`, `Preview`, `SafeConferencePrep`, `AdvancedRepair`, or `PerformanceTune`. | +| `-DryRun` | Simulates state-changing operations. `Preview` always behaves as a dry run. | +| `-Force` | Bypasses launcher confirmation prompts. It does not disable protected-artifact checks. | +| `-CreateLog` | Starts a timestamped log in `LogPath`. Explicit non-menu modes also initialize logging. | +| `-BackupPath ` | Backup destination. Default: `%ProgramData%\NetClean\Backups`. | +| `-LogPath ` | Log destination. Default: `%ProgramData%\NetClean\Logs`. | +| `-SkipWifi` | Skips Wi-Fi profile removal. | +| `-SkipDnsFlush` | Skips DNS cache flushing. | +| `-SkipEventLogs` | Skips network event-log cleanup. | +| `-SkipUserArtifacts` | Skips user-history cleanup. | +| `-SkipFirewallBackup` | Skips firewall-policy export during protection. | +| `-PerformanceProfile ` | Selects `Conservative`, `Optimal`, `Gaming`, or `Default` for `PerformanceTune`. | +| `-RebootNow` | Selects restart as the post-run action without prompting. In dry-run mode, the restart is logged but not performed. | -Run and reboot automatically: +## Workflow and safety model -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File netclean.ps1 -RebootNow -CreateLog -``` +1. Detect security products, services, drivers, adapters, protected registry paths, and candidate network artifacts. +2. Export the protection inventory, adapter IP/DNS state, registry map, sanitizable-artifact list, NetworkList data, Wi-Fi profiles, firewall policy, and protected registry keys into an access-restricted backup directory. +3. Perform only the operations selected by the mode and skip switches. Standard conference preparation removes saved Wi-Fi profiles and network-history artifacts, resets eligible unmanaged adapters to IPv4 DHCP, configures the Quad9 Secure IPv4/IPv6 resolver set and DNS over HTTPS without plaintext fallback where Windows supports it, and keeps IPv6 enabled while preferring IPv4 after restart. `ShouldProcess`, `-WhatIf`, and `-DryRun` are honored by state-changing helpers. +4. Re-read protected inventory, Wi-Fi/NetworkList state, adapter DHCP and DNS state, the IPv4-preference registry value, encrypted-DNS configuration, removed registry/user artifacts, and cleared event logs. A private JSON verification ledger and detailed log record each applicable check. -## Command-line switches +Protection detection is best-effort and cannot guarantee recognition of every security or virtual-network product. Review the preview and inventory before cleanup. -| Switch | Description | -|--------|-------------| -| `-DryRun` | Show what would be done without making destructive changes. | -| `-Force` | Bypass some interactive confirmations and override AV/EDR safety checks for file deletions. Use with caution. | -| `-OnlyBackup` | Perform backups (NetworkList, Wi‑Fi profiles, protected registry keys) then exit. | -| `-CreateLog` | Create a timestamped log file under the log path. | -| `-BackupPath ` | Path to store backups (default: `%ProgramData%\NetworkCleaner\Backups`). | -| `-LogPath ` | Path to store logs (default: `%ProgramData%\NetworkCleaner\Logs`). | -| `-RebootNow` | Reboot the machine automatically after the script completes. | +Microsoft Entra Workplace registration indicates that a work or school account is registered for user-level access and SSO; it does not, by itself, mean the computer is organization-managed. NetClean preserves that registration and its identity, token, credential, and BrokerPlugin stores. Whole-device adapter preservation is enabled only by domain, Microsoft Entra, enterprise, or detected MDM enrollment; protected security, VPN, and virtual adapters retain their separate per-adapter boundary. -## Backups & restore +### Backup confidentiality -- Backups are saved to the configured `BackupPath`. +Wi-Fi export uses Windows `netsh` with `key=clear`, so exported XML files can contain plaintext Wi-Fi credentials. NetClean restricts both default and custom backup/log directories to administrators, SYSTEM, and the initiating user, but those principals can still read the files. Avoid syncing backups to untrusted services and securely remove them when they are no longer needed. -Restore the exported `NetworkList` registry key (as Administrator): +### Verification limits -```powershell -reg import "/NetworkList_YYYYMMDD_HHMMSS.reg" -``` +When Wi-Fi cleanup was requested, verification requires physical Wi-Fi adapters to be disconnected. DNS and dynamic IPv4 neighbor/ARP caches are also required to be empty when no physical wired LAN is connected. If a wired LAN is connected, cache contents can reflect legitimate live traffic, so those two cache checks are recorded as not applicable and do not affect the result. Permanent neighbor entries are not treated as removable history. -Restore Wi‑Fi profiles (for each exported XML): +Stack repair and performance-tuning commands can require a restart or lack a reliable immediate read-back signal; their command outcomes are retained in the evidence ledger instead of being presented as independently observed final state. -```powershell -netsh wlan add profile filename="/WiFiProfile_.xml" -``` +## Restore examples -Restore protected registry exports: +Restore an exported registry file from an elevated shell: ```powershell -reg import "/reg_backup_... .reg" +reg.exe import "C:\path\to\backup.reg" ``` -## Tables & outputs - -Key directories (defaults): - -| Purpose | Default path | -|---------|--------------| -| Backups | `%ProgramData%\NetworkCleaner\Backups` | -| Logs | `%ProgramData%\NetworkCleaner\Logs` | - -Log files are timestamped like `netclean_YYYYMMDD_HHMMSS.log` and contain the full sequence of actions and restore instructions for exported registry keys and Wi‑Fi profiles. - -## CI / Scheduled task examples - -This repository includes a lightweight GitHub Actions workflow that lints PowerShell with `PSScriptAnalyzer` and runs the wrapper in `-DryRun` mode. The workflow file is: - -- `.github/workflows/powershell-check.yml` - -Example scheduled-task registration script is under `examples/register-scheduledtask.ps1` (creates an idempotent task to run the wrapper at startup). - -## Examples: PowerShell wrapper scripts - -Below are two example wrappers that make running and restoring easier. They are provided as guidance — you can copy them into `examples/` and customize paths as needed. - -1) Simple runner that creates backups, logs, and performs the cleanup non-interactively: +Restore a Wi-Fi profile: ```powershell -# examples/run-netclean.ps1 -param( - [switch]$DryRun, - [switch]$Force, - [string]$BackupPath = "$env:ProgramData\NetworkCleaner\Backups", - [string]$LogPath = "$env:ProgramData\NetworkCleaner\Logs" -) - -$script = Join-Path $PSScriptRoot "..\netclean.ps1" -if (-not (Test-Path $script)) { Write-Error "netclean.ps1 not found at $script"; exit 1 } - -$args = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$script) -if ($DryRun) { $args += '-DryRun' } -if ($Force) { $args += '-Force' } -$args += '-CreateLog','-BackupPath',$BackupPath,'-LogPath',$LogPath - -Write-Host "Launching netclean with args: $($args -join ' ')" -ForegroundColor Cyan -Start-Process -FilePath (Get-Command powershell).Source -ArgumentList $args -NoNewWindow -Wait -Write-Host "netclean run completed. Check logs under $LogPath" -ForegroundColor Green +netsh.exe wlan add profile filename="C:\path\to\Wi-Fi-profile.xml" ``` -2) Restore Wi‑Fi profiles from a backup folder (imports each XML): +Restore a firewall policy: ```powershell -# examples/restore-wifi-profiles.ps1 -param( - [string]$BackupPath = "$env:ProgramData\NetworkCleaner\Backups" -) - -if (-not (Test-Path $BackupPath)) { Write-Error "Backup path not found: $BackupPath"; exit 1 } - -$xmlFiles = Get-ChildItem -Path $BackupPath -Filter 'WiFiProfile_*.xml' -File -ErrorAction SilentlyContinue -if (-not $xmlFiles) { Write-Host "No Wi‑Fi profile exports found in $BackupPath"; exit 0 } - -foreach ($f in $xmlFiles) { - Write-Host "Importing profile: $($f.Name)" - try { netsh wlan add profile filename="$($f.FullName)" | Out-Null; Write-Host "Imported: $($f.Name)" -ForegroundColor Green } catch { Write-Warning "Failed to import $($f.Name): $_" } -} - -Write-Host "Wi‑Fi restore complete." -ForegroundColor Green +netsh.exe advfirewall import "C:\path\to\FirewallPolicy.wfw" ``` -## Recommended workflow +The `examples` directory contains reusable launcher, Wi-Fi restore, and scheduled-task examples. -1. Preview with `-DryRun` and `-CreateLog`. -2. Create backups with `-OnlyBackup` and verify exported files under the backups folder. -3. Run the full cleanup when satisfied. +## Development and CI -## Troubleshooting & logs +The project uses Pester 6.0.1 and PSScriptAnalyzer. Changes should follow red-green-refactor and add focused regression coverage before production edits. Pester 6 requires Windows PowerShell 5.1 or PowerShell 7.4 and later. -- Review the timestamped log in `LogPath` for details about protected registry paths, exported files, and skipped actions. -- If the script skips DHCP/WLAN file deletions because AV/EDR was detected, use `-Force` only after confirming backups and understanding risks. +The authoritative coverage run is intentionally sequential. Pester 6 file-level parallel execution remains experimental, and Pester always collects coverage on its sequential path, so CI does not enable parallel execution or use a custom parallel harness. -## Contributing & license +The System test layer performs real Windows Registry-provider operations against synthetic data in Pester's container-scoped `TestRegistry:` drive. It covers NetworkList discovery and names, native `.reg` backup, protected-path preservation, sanitizable-path removal, adapter-configuration preservation, and independent post-state verification without reading or changing the machine's actual network records. These tests run in CI. They do not replace destructive acceptance testing of real Wi-Fi profiles, adapters, DNS/ARP caches, event logs, restart behavior, or policy-managed systems; those scenarios require a disposable Windows VM or test machine with a restore point or snapshot. -- Contributions welcome for improving detection rules, safeguards, or cross-version compatibility; please open a pull request with tests and notes. -- See the `LICENSE` file for license details. +```powershell +Install-Module Pester -Scope CurrentUser -RequiredVersion 6.0.1 -Force -SkipPublisherCheck +Install-Module PSScriptAnalyzer -Scope CurrentUser -RequiredVersion 1.25.0 -Force +Invoke-Pester -Path .\tests +.\tests\Run-NetClean-Coverage.ps1 +``` ---- +CI is defined in `.github/workflows/ci.yml`. The authoritative PowerShell 7 job validates the module manifest, treats analyzer warnings/errors as failures, and runs Pester 6.0.1 with coverage. An independent Windows PowerShell 5.1 job runs the complete test suite and manifest validation in parallel. Tests execute to exercise production behavior, but the coverage runner rejects source paths beneath `tests/`, so test code cannot inflate the result. Pester 6's profiler-based collector measures the current suite at 95.13% command coverage; the overall gate is ratcheted at 94% to retain regression margin, while pull-request reporting retains a 95% changed-file target. The suite now exceeds the 95% long-term overall target, and the enforced ratchet should only move upward as focused tests add durable margin. Generated output is written under `tests/TestResults` and is ignored by Git. -If you'd like, I can also commit the workflow and examples to the repository. Tell me to proceed and I'll make a git commit. +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations and [LICENSE](LICENSE) for GPLv3 terms. diff --git a/examples/register-scheduledtask.ps1 b/examples/register-scheduledtask.ps1 index 49bc8c5..72e765e 100644 --- a/examples/register-scheduledtask.ps1 +++ b/examples/register-scheduledtask.ps1 @@ -14,7 +14,7 @@ try { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue } Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -RunLevel Highest -User 'SYSTEM' -Description 'Run netclean wrapper at startup' -Force - Write-Host "Scheduled task '$TaskName' registered to run $WrapperPath at startup." -ForegroundColor Green + Write-Output "Scheduled task '$TaskName' registered to run $WrapperPath at startup." } catch { Write-Error "Failed to register scheduled task: $_" exit 1 diff --git a/examples/restore-wifi-profiles.ps1 b/examples/restore-wifi-profiles.ps1 index 42b1b9e..01f6406 100644 --- a/examples/restore-wifi-profiles.ps1 +++ b/examples/restore-wifi-profiles.ps1 @@ -1,15 +1,14 @@ param( - [string]$BackupPath = "$env:ProgramData\NetworkCleaner\Backups" + [string]$BackupPath = "$env:ProgramData\NetClean\Backups" ) if (-not (Test-Path $BackupPath)) { Write-Error "Backup path not found: $BackupPath"; exit 1 } -$xmlFiles = Get-ChildItem -Path $BackupPath -Filter 'WiFiProfile_*.xml' -File -ErrorAction SilentlyContinue -if (-not $xmlFiles) { Write-Host "No Wi‑Fi profile exports found in $BackupPath"; exit 0 } +$xmlFiles = Get-ChildItem -LiteralPath $BackupPath -Filter '*.xml' -File -ErrorAction SilentlyContinue +if (-not $xmlFiles) { Write-Output "No Wi-Fi profile exports found in $BackupPath"; exit 0 } foreach ($f in $xmlFiles) { - Write-Host "Importing profile: $($f.Name)" - try { netsh wlan add profile filename="$($f.FullName)" | Out-Null; Write-Host "Imported: $($f.Name)" -ForegroundColor Green } catch { Write-Warning "Failed to import $($f.Name): $_" } + Write-Output "Importing profile: $($f.Name)" + try { netsh wlan add profile filename="$($f.FullName)" | Out-Null; Write-Output "Imported: $($f.Name)" } catch { Write-Warning "Failed to import $($f.Name): $_" } } - -Write-Host "Wi‑Fi restore complete." -ForegroundColor Green +Write-Output "Wi-Fi restore complete." diff --git a/examples/run-netclean.ps1 b/examples/run-netclean.ps1 index 9c18120..e9bc3e3 100644 --- a/examples/run-netclean.ps1 +++ b/examples/run-netclean.ps1 @@ -1,18 +1,18 @@ param( [switch]$DryRun, [switch]$Force, - [string]$BackupPath = "$env:ProgramData\NetworkCleaner\Backups", - [string]$LogPath = "$env:ProgramData\NetworkCleaner\Logs" + [string]$BackupPath = "$env:ProgramData\NetClean\Backups", + [string]$LogPath = "$env:ProgramData\NetClean\Logs" ) $script = Join-Path $PSScriptRoot "..\netclean.ps1" if (-not (Test-Path $script)) { Write-Error "netclean.ps1 not found at $script"; exit 1 } -$args = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$script) -if ($DryRun) { $args += '-DryRun' } -if ($Force) { $args += '-Force' } -$args += '-CreateLog','-BackupPath',$BackupPath,'-LogPath',$LogPath +$procArgs = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$script) +if ($DryRun) { $procArgs += '-DryRun' } +if ($Force) { $procArgs += '-Force' } +$procArgs += '-CreateLog','-BackupPath',$BackupPath,'-LogPath',$LogPath -Write-Host "Launching netclean with args: $($args -join ' ')" -ForegroundColor Cyan -Start-Process -FilePath (Get-Command powershell).Source -ArgumentList $args -NoNewWindow -Wait -Write-Host "netclean run completed. Check logs under $LogPath" -ForegroundColor Green +Write-Output "Launching netclean with args: $($procArgs -join ' ')" +Start-Process -FilePath (Get-Command powershell).Source -ArgumentList $procArgs -NoNewWindow -Wait +Write-Output "netclean run completed. Check logs under $LogPath" diff --git a/netclean.ps1 b/netclean.ps1 index 08fb9a0..bffec34 100644 --- a/netclean.ps1 +++ b/netclean.ps1 @@ -1,628 +1,875 @@ -<# - Aggressive-but-safe Windows 11 network cleanup for conference/CTF. - - - Run as Administrator - - Does NOT touch: - * Bitdefender services, drivers, or firewall rules - * Windows Firewall rules - * VMware Workstation services or virtual adapters - - Does: - * Remove all Wi-Fi profiles - * Reset Winsock + TCP/IP (IPv4/IPv6) +<# +.SYNOPSIS + NetClean launcher / UX shell. + +.DESCRIPTION + Thin orchestration layer for NetClean.psm1. + + Responsibilities of this script: + - parameter handling + - admin check + - menu / UX + - logging + - calling module phase/workflow functions + - displaying summaries + - optional reboot prompt + + Responsibilities of NetClean.psm1: + - detect + - protect + - clean + - verify + - backup/export + - repair/tuning helpers #> + +[CmdletBinding(SupportsShouldProcess = $true)] param( - [switch]$DryRun, - [switch]$Force, - [switch]$OnlyBackup, - [switch]$CreateLog, - [string]$BackupPath = "$env:ProgramData\NetworkCleaner\Backups", - [string]$LogPath = "$env:ProgramData\NetworkCleaner\Logs", - [switch]$RebootNow - ) - -# Import module with core helpers -Import-Module -Name (Join-Path $PSScriptRoot 'Netclean.psm1') -Force -ErrorAction Stop - - # Logging helpers - $script:LogFile = $null - function Start-Log { - param($logDir) - if (-not $logDir) { $logDir = "$env:ProgramData\NetworkCleaner\Logs" } - if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } - $script:LogFile = Join-Path $logDir "netclean_$(Get-Date -Format yyyyMMdd_HHmmss).log" - "$((Get-Date).ToString('s')) - INFO - Log started" | Out-File -FilePath $script:LogFile -Encoding UTF8 - } - function Write-NetcleanLog { - param( - [string]$Level = 'INFO', - [string]$Message - ) - $line = "$(Get-Date -Format s) - $Level - $Message" - if ($script:LogFile) { $line | Out-File -FilePath $script:LogFile -Encoding UTF8 -Append } - # Also write a short host message for interactive feedback - # Only show WARN and ERROR on the console to reduce duplicate/info clutter; INFO goes to the log file. - if ($Level -in @('ERROR','WARN')) { - switch ($Level) { - 'ERROR' { Write-Host $Message -ForegroundColor Red } - 'WARN' { Write-Host $Message -ForegroundColor Yellow } - } - } - } + [ValidateSet('Menu', 'Preview', 'SafeConferencePrep', 'AdvancedRepair', 'PerformanceTune')] + [string]$Mode = 'Menu', + [switch]$DryRun, + [switch]$Force, + [switch]$CreateLog, + [ValidateNotNullOrEmpty()] + [string]$BackupPath = "$env:ProgramData\NetClean\Backups", + [ValidateNotNullOrEmpty()] + [string]$LogPath = "$env:ProgramData\NetClean\Logs", + [switch]$SkipWifi, + [switch]$SkipDnsFlush, + [switch]$SkipEventLogs, + [switch]$SkipUserArtifacts, + [switch]$SkipFirewallBackup, + [ValidateSet('Conservative', 'Optimal', 'Gaming', 'Default')] + [string]$PerformanceProfile = 'Default', + [switch]$RebootNow +) + +# Set test mode variables to default to false +if (-not (Get-Variable NetCleanTestMode -Scope Script -ErrorAction SilentlyContinue)) { + $script:NetCleanTestMode = $false +} + +# Normalize default paths using Join-Path when the caller did not provide overrides +if (-not $PSBoundParameters.ContainsKey('BackupPath')) { + $BackupPath = Join-Path $env:ProgramData 'NetClean\Backups' +} + +if (-not $PSBoundParameters.ContainsKey('LogPath')) { + $LogPath = Join-Path $env:ProgramData 'NetClean\Logs' +} + +if ($false) { + $null = $Mode + $null = $DryRun + $null = $Force + $null = $CreateLog + $null = $BackupPath + $null = $LogPath + $null = $SkipWifi + $null = $SkipDnsFlush + $null = $SkipEventLogs + $null = $SkipUserArtifacts + $null = $SkipFirewallBackup + $null = $PerformanceProfile + $null = $RebootNow +} + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Record script start time for runtime reporting +$script:RunStart = Get-Date + +# --------------------------------------------------------------------------- +# Module import +# --------------------------------------------------------------------------- + +Import-Module (Join-Path $PSScriptRoot 'Netclean.psd1') -Force + +# --------------------------------------------------------------------------- +# Script state +# --------------------------------------------------------------------------- + +# $script:LogFile = $null + +# Maximum items to show in lists; remaining count will be summarized. +$script:SummaryListLimit = 20 + +function Show-TruncatedList { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyCollection()] + [object[]]$Items, + [Parameter(Mandatory = $false)] + [string]$Heading = 'Items', + [Parameter(Mandatory = $false)] + [int]$Limit + ) + if (-not $Limit) { $Limit = $script:SummaryListLimit } + Write-Information '' -InformationAction Continue + Write-Information $Heading -InformationAction Continue + if ($Items -and $Items.Count -gt 0) { + $count = $Items.Count + $toShow = $Items[0..([Math]::Min($Limit - 1, $count - 1))] + foreach ($i in $toShow) { Write-Information " - $i" -InformationAction Continue } + if ($count -gt $Limit) { Write-Information " - ...and $($count - $Limit) more" -InformationAction Continue } + } + else { Write-Information ' - (none)' -InformationAction Continue } +} + +# --------------------------------------------------------------------------- +# UX helpers +# --------------------------------------------------------------------------- - function Ensure-Admin { - $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( - [Security.Principal.WindowsBuiltInRole] "Administrator") - if (-not $isAdmin) { - $ans = Read-Host "This script must be run as Administrator. Elevate now? (Y/N)" - if ($ans -match '^[Yy]') { - # Rebuild argument list from supplied bound parameters - $scriptPath = $PSCommandPath - $argList = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`"" - if ($PSBoundParameters) { - foreach ($k in $PSBoundParameters.Keys) { - $v = $PSBoundParameters[$k] - if ($v -is [System.Management.Automation.SwitchParameter]) { - if ($v.IsPresent -and $v) { $argList += " -$k" } - } else { - $escaped = $v.ToString().Replace('"','\"') - $argList += " -$k `"$escaped`"" - } - } - } - Write-Host "Relaunching elevated: powershell $argList" -ForegroundColor Gray - Start-Process -FilePath (Get-Command powershell).Source -ArgumentList $argList -Verb RunAs -Wait - Exit 0 - } else { - Write-Host "This script must be run as Administrator. Exiting." -ForegroundColor Red - Exit 1 - } - } - } +<# +.SYNOPSIS + Tests if the current user is an administrator. +.DESCRIPTION + This function checks if the current user has administrator privileges. +.EXAMPLE + Test-NetCleanAdministrator +.NOTES + The function throws an error if the user is not an administrator. +#> +function Test-NetCleanAdministrator { + [CmdletBinding()] + param() - function New-ProtectedBackupPath($path) { - if (-not (Test-Path $path)) { - New-Item -Path $path -ItemType Directory -Force | Out-Null - } - # Restrict backups to Administrators and SYSTEM - try { - $icaclsArgs = @($path,'/inheritance:r','/grant','Administrators:(OI)(CI)F','/grant','SYSTEM:(OI)(CI)F','/C') - if ($DryRun) { Write-Host "DRYRUN: icacls $($icaclsArgs -join ' ')" -ForegroundColor Gray } - else { - try { Start-Process -FilePath 'icacls' -ArgumentList $icaclsArgs -NoNewWindow -Wait -ErrorAction Stop | Out-Null } catch { throw } - } - } catch { - Write-Warning ("Failed to set ACL on backup folder: " + $_) - } - Write-NetcleanLog 'INFO' "Backup folder prepared: $path" - } + $principal = [Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent() + $isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - function Prompt-YesNo($msg, $defaultNo=$true) { - while ($true) { - $ans = Read-Host "$msg (Y/N)" - if ($ans -match '^[Yy]') { return $true } - if ($ans -match '^[Nn]') { return $false } - Write-Host "Please answer Y or N." -ForegroundColor Yellow - } - } + if (-not $isAdmin) { + throw 'NetClean must be run as Administrator.' + } +} - function Normalize-Guid($g) { - if (-not $g) { return $null } - return ($g -replace '[{}]','').ToLower() +<# +.SYNOPSIS + Prompts the user for a yes/no response. +.DESCRIPTION + This function displays a prompt and waits for the user to enter 'y' or 'n'. +.PARAMETER Prompt + The prompt message to display. +.PARAMETER DefaultNo + Indicates whether the default response is no. +.EXAMPLE + Read-YesNo -Prompt "Do you want to continue?" +.OUTPUTS + System.Boolean - The user's response. +.NOTES + The function uses the Convert-RegToProviderPath function to normalize the input path. +#> +function Read-YesNo { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string]$Prompt, + + [bool]$DefaultNo = $true + ) + + if ($script:Force) { + return $true + } + + while ($true) { + $suffix = if ($DefaultNo) { '[y/N]' } else { '[Y/n]' } + $answer = Read-Host "$Prompt $suffix" + + if ([string]::IsNullOrWhiteSpace($answer)) { + return (-not $DefaultNo) } - function Get-HypervisorGuids { - # Detect virtual network adapters from common hypervisors and return their normalized GUIDs. - $adapters = @() - try { - $adapters = Get-NetAdapter -ErrorAction SilentlyContinue | - Where-Object { - ($_.InterfaceDescription -match 'VMware|VirtualBox|Hyper-V|HyperV|Parallels|Virtual Adapter|Virtual Ethernet|vEthernet|VirtualBox') -or - ($_.Name -match 'VMware|VMnet|vbox|VMSwitch|vEthernet') - } - $wlanArgs = @('wlan','export','profile','name='+$p,'key=clear','folder='+$dest) - if ($DryRun) { Write-NetcleanLog 'INFO' "DRYRUN: netsh $($wlanArgs -join ' ')"; $exported += $file } - else { - try { - Start-Process -FilePath 'netsh' -ArgumentList $wlanArgs -NoNewWindow -Wait -ErrorAction Stop - Write-NetcleanLog 'INFO' "Exported Wi-Fi profile $p -> $file" - $exported += $file - } catch { - Write-NetcleanLog 'WARN' ("Failed to export wifi profile " + $p + ": " + $_) - } - } - function Get-VMwareGuids { return Get-HypervisorGuids } - - function Get-DetectedHypervisors { - $found = @() - # WMI: Hypervisor present - try { - $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue - if ($cs -and $cs.HypervisorPresent) { $found += 'HypervisorPresent' } - } catch {} - # Network adapters - try { - $adapters = Get-NetAdapter -ErrorAction SilentlyContinue - foreach ($a in $adapters) { - $d = $a.InterfaceDescription - if ($d -match 'VMware') { if (-not ($found -contains 'VMware')) { $found += 'VMware' } } - if ($d -match 'VirtualBox') { if (-not ($found -contains 'VirtualBox')) { $found += 'VirtualBox' } } - if ($d -match 'Hyper-V|HyperV|vEthernet') { if (-not ($found -contains 'Hyper-V')) { $found += 'Hyper-V' } } - if ($d -match 'Parallels') { if (-not ($found -contains 'Parallels')) { $found += 'Parallels' } } - } - } catch {} - # Services/processes - $svcChecks = @{ 'VBoxService'='VirtualBox'; 'vmtools'='VMware'; 'vmware'='VMware'; 'vmms'='Hyper-V'; 'vmcompute'='Hyper-V' } - foreach ($k in $svcChecks.Keys) { - try { if (Get-Service -Name $k -ErrorAction SilentlyContinue) { if (-not ($found -contains $svcChecks[$k])) { $found += $svcChecks[$k] } } } catch {} - } - return $found | Sort-Object -Unique - } + if ($answer -match '^[Yy]') { return $true } + if ($answer -match '^[Nn]') { return $false } - function Get-InstalledAV { - $found = @() - try { - $wmi = Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntivirusProduct -ErrorAction SilentlyContinue - if ($wmi) { $found += $wmi.displayName } - } catch { - Write-Verbose ("SecurityCenter query failed: " + $_) - } - # Fallback: look for common AV services/processes - $common = @('MsMpSvc','WinDefend','vsserv','BDService','CSFalconService','SentinelAgent','sophos','savservice') - foreach ($s in $common) { - try { - if (Get-Service -Name $s -ErrorAction SilentlyContinue) { $found += $s } - } catch {} - } - return ($found | Sort-Object -Unique) - } + Write-Verbose 'Please enter Y or N.' + } +} - function Derive-AVServicePatterns($avList) { - $patterns = @() - $map = @{ - 'bitdefender' = @('vsserv','BDService') - 'crowdstrike' = @('CSFalconService','csagent','falcon') - 'sentinelone' = @('SentinelAgent','SentinelCtl','Sentinel') - 'carbon' = @('Cb','cb') - 'symantec' = @('Symantec','Smc') - 'sophos' = @('SAVService','Sophos') - 'windows' = @('WinDefend','MsMpSvc') - } - foreach ($a in $avList) { - $an = $a.ToString().ToLower() - foreach ($k in $map.Keys) { - if ($an -like "*$k*") { $patterns += $map[$k] } - } +<# +.SYNOPSIS + Shows the NetClean banner. +.DESCRIPTION + This function displays the NetClean banner with version information. +.EXAMPLE + Show-NetCleanBanner +#> +function Show-NetCleanBanner { + [CmdletBinding()] + param() + + Write-Information '' -InformationAction Continue + Write-Information '==========================================' -InformationAction Continue + Write-Information ' NetClean - Conference / CTF Prep Tool' -InformationAction Continue + Write-Information '==========================================' -InformationAction Continue + Write-Information '' -InformationAction Continue + Write-Information 'This tool helps remove network history and metadata while preserving' -InformationAction Continue + Write-Information 'security products, firewalls, hypervisors, and protected adapters.' -InformationAction Continue + Write-Information '' -InformationAction Continue +} + +<# +.SYNOPSIS + Shows the NetClean menu. +.DESCRIPTION + This function displays the main NetClean menu options. +.EXAMPLE + Show-NetCleanMenu +#> +function Show-NetCleanMenu { + [CmdletBinding()] + param() + + Show-NetCleanBanner + + Write-Information '1. Preview only' -InformationAction Continue + Write-Information ' Detect and show what would be cleaned. No changes made.' -InformationAction Continue + Write-Information '' -InformationAction Continue + Write-Information '2. Safe conference prep' -InformationAction Continue + Write-Information ' Backup, remove network history, preserve security and virtualization tools.' -InformationAction Continue + Write-Information '' -InformationAction Continue + Write-Information '3. Advanced repair' -InformationAction Continue + Write-Information ' Includes deeper network reset actions. May affect installed software.' -InformationAction Continue + Write-Information '' -InformationAction Continue + # PerformanceTune remains available to direct callers but is intentionally + # omitted from the interactive menu while network-optimization work is tabled. + # Write-Information '4. Performance tuning' -InformationAction Continue + # Write-Information ' Apply conservative network performance tuning.' -InformationAction Continue + # Write-Information '' -InformationAction Continue + Write-Information '4. Exit' -InformationAction Continue + Write-Information '' -InformationAction Continue +} + +<# +.SYNOPSIS + Reads the menu selection for the NetClean process. +.DESCRIPTION + This function prompts the user to select an option from the NetClean menu. +.EXAMPLE + Read-NetCleanMenuSelection +.OUTPUTS + System.String - The selected menu option. +.NOTES + The function uses the Convert-RegToProviderPath function to normalize the input path. +#> +function Read-NetCleanMenuSelection { + [CmdletBinding()] + [OutputType([string])] + param() + + while ($true) { + Show-NetCleanMenu + $choice = Read-Host 'Select an option (1-4)' + + switch ($choice) { + '1' { return 'Preview' } + '2' { return 'SafeConferencePrep' } + '3' { return 'AdvancedRepair' } + # '4' { return 'PerformanceTune' } # Tabled for interactive use. + '4' { return 'Exit' } + default { + Write-Information '' -InformationAction Continue + Write-Information 'Invalid selection. Please choose 1 through 4.' -InformationAction Continue + Write-Information '' -InformationAction Continue } - return ($patterns | Sort-Object -Unique) } + } +} - function Build-ProtectionLists { - # Returns hashtable with keys: Services, Drivers, Adapters, Registry - $detected = Get-InstalledAV - $svcPatterns = @() - $driverPatterns = @() - $adapterPatterns = @() - $registryPaths = @() - - # Per-vendor canonical mappings (services, drivers, adapter name fragments, registry locations) - $vendorMap = @{ - 'bitdefender' = @{ Services=@('vsserv','bdservice'); Drivers=@('npf','bd*cpt'); Adapters=@('vmware','bd'); Reg=@('SOFTWARE\\Bitdefender') } - 'malwarebytes' = @{ Services=@('MBAMService','MBAMProtector'); Drivers=@('mbam*'); Adapters=@('Malwarebytes'); Reg=@('SOFTWARE\\Malwarebytes') } - 'crowdstrike' = @{ Services=@('CSFalconService'); Drivers=@('cs*'); Adapters=@('CrowdStrike'); Reg=@('') } - 'sentinelone' = @{ Services=@('SentinelAgent'); Drivers=@('Sentinel'); Adapters=@('SentinelOne'); Reg=@('') } - 'sophos' = @{ Services=@('SAVService'); Drivers=@('SAV*'); Adapters=@('Sophos'); Reg=@('SOFTWARE\\SOPHOS') } - 'microsoft' = @{ Services=@('MsMpSvc','WinDefend'); Drivers=@('wd*'); Adapters=@('vEthernet','Hyper-V'); Reg=@('SOFTWARE\\Microsoft\\Windows Defender') } - } +<# +.SYNOPSIS + Shows the explanation for the selected NetClean mode. +.DESCRIPTION + This function displays the explanation for the selected NetClean mode. +.PARAMETER SelectedMode + The mode selected by the user. +.EXAMPLE + Show-ModeExplanation -SelectedMode 'Preview' +#> +function Show-ModeExplanation { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Preview', 'SafeConferencePrep', 'AdvancedRepair', 'PerformanceTune')] + [string]$SelectedMode + ) + + Write-Information '' -InformationAction Continue + switch ($SelectedMode) { + 'Preview' { + Write-Information 'You selected: Preview' -InformationAction Continue + Write-Information '' -InformationAction Continue + Write-Information 'This will:' -InformationAction Continue + Write-Information ' - detect protection software and protected adapters' -InformationAction Continue + Write-Information ' - build a protected registry map' -InformationAction Continue + Write-Information ' - export backup/restore metadata' -InformationAction Continue + Write-Information ' - make no cleanup changes' -InformationAction Continue + } + 'SafeConferencePrep' { + Write-Information 'You selected: Safe conference prep' -InformationAction Continue + Write-Information '' -InformationAction Continue + Write-Information 'This will:' -InformationAction Continue + Write-Information ' - detect protection software and protected adapters' -InformationAction Continue + Write-Information ' - back up protected registry, firewall policy, and Wi-Fi profiles' -InformationAction Continue + Write-Information ' - remove saved Wi-Fi profiles' -InformationAction Continue + Write-Information ' - reset eligible adapters to IPv4 DHCP' -InformationAction Continue + Write-Information ' - set Quad9 Secure DNS for IPv4 and IPv6' -InformationAction Continue + Write-Information ' - keep IPv6 enabled and prefer IPv4 after restart' -InformationAction Continue + Write-Information ' - flush DNS cache' -InformationAction Continue + Write-Information ' - remove non-protected network history and metadata' -InformationAction Continue + Write-Information ' - verify protected products remain present' -InformationAction Continue + Write-Information ' - require a restart for the IPv4 preference to take full effect' -InformationAction Continue + } + 'AdvancedRepair' { + Write-Information 'You selected: Advanced repair' -InformationAction Continue + Write-Information '' -InformationAction Continue + Write-Information 'This will do everything in Safe conference prep, plus:' -InformationAction Continue + Write-Information ' - run advanced network repair/reset actions' -InformationAction Continue + Write-Information ' - this may affect installed networking/security software' -InformationAction Continue + } + 'PerformanceTune' { + Write-Information 'You selected: Performance tuning' -InformationAction Continue + Write-Information '' -InformationAction Continue + Write-Information 'This will do Safe conference prep, plus:' -InformationAction Continue + Write-Information ' - apply conservative, Microsoft-supported TCP tuning actions' -InformationAction Continue + Write-Information ' - no third-party code or proprietary settings are used' -InformationAction Continue + } + } + Write-Information '' -InformationAction Continue +} - foreach ($d in $detected) { - $dn = $d.ToString().ToLower() - foreach ($k in $vendorMap.Keys) { - if ($dn -like "*$k*") { - $entry = $vendorMap[$k] - $svcPatterns += $entry.Services - $driverPatterns += $entry.Drivers - $adapterPatterns += $entry.Adapters - $registryPaths += $entry.Reg - } - } - } +<# +.SYNOPSIS + Reads the options for the NetClean process. +.DESCRIPTION + This function prompts the user to select options for the NetClean process. +.PARAMETER SelectedMode + The mode selected by the user. +.EXAMPLE + Read-NetCleanOption -SelectedMode 'Preview' +.OUTPUTS + System.Object - The selected options. +.NOTES + The function uses the Convert-RegToProviderPath function to normalize the input path. +#> +function Read-NetCleanOption { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string]$SelectedMode, + + [switch]$DryRun, + [switch]$SkipWifi, + [switch]$SkipDnsFlush, + [switch]$SkipEventLogs, + [switch]$SkipUserArtifacts, + [switch]$SkipFirewallBackup, + + [ValidateSet('Conservative', 'Optimal', 'Gaming', 'Default')] + [string]$PerformanceProfile + ) + + if ($SelectedMode -eq 'PerformanceTune' -and [string]::IsNullOrWhiteSpace($PerformanceProfile)) { + throw "PerformanceProfile is required when SelectedMode is 'PerformanceTune'." + } + + if ($SelectedMode -eq 'Preview') { + $DryRun = $true + } + + return [pscustomobject]@{ + SelectedMode = $SelectedMode + DryRun = [bool]$DryRun + SkipWifi = [bool]$SkipWifi + SkipDnsFlush = [bool]$SkipDnsFlush + SkipEventLogs = [bool]$SkipEventLogs + SkipUserArtifacts = [bool]$SkipUserArtifacts + SkipFirewallBackup = [bool]$SkipFirewallBackup + PerformanceProfile = $PerformanceProfile + } +} - # Always protect common hypervisor adapters/services - $adapterPatterns += @('VMware','VMnet','vboxnet','vEthernet','Hyper-V','VirtualBox','Parallels') - $svcPatterns += @('vmnat','vmnetbridge','VMWareHostd','VBoxService') +<# +.SYNOPSIS +Displays device registration and organization-management status. +.PARAMETER ManagementState +The management-state result returned by Get-NetCleanDeviceManagementState. +#> +function Show-NetCleanManagementStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$ManagementState + ) + + if ($ManagementState.JoinType -eq 'WorkplaceRegistered') { + Write-Information "Device registration: $($ManagementState.JoinType)" -InformationAction Continue + } + else { + Write-Information "Device management: $($ManagementState.JoinType)" -InformationAction Continue + } + + if ($ManagementState.IsManaged) { + Write-Information 'Organization-managed network configuration will be preserved.' -InformationAction Continue + } + elseif ($ManagementState.JoinType -eq 'WorkplaceRegistered') { + Write-Information 'A work or school account is registered for SSO and will be preserved; no organization management was detected.' -InformationAction Continue + } + else { + Write-Information 'No organization management was detected.' -InformationAction Continue + } +} - return @{ Services=($svcPatterns|Sort-Object -Unique); Drivers=($driverPatterns|Sort-Object -Unique); Adapters=($adapterPatterns|Sort-Object -Unique); Registry=($registryPaths|Sort-Object -Unique) } +<# +.SYNOPSIS + Shows the summary for the NetClean process. +.DESCRIPTION + This function displays a summary of the actions that will be taken by the NetClean process. +.PARAMETER Result + The result object containing the summary information. +.PARAMETER SelectedMode + The mode selected by the user. +.EXAMPLE + Show-NetCleanSummary -Result $result -SelectedMode 'Preview' +#> +function Show-NetCleanSummary { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Result, + + [Parameter(Mandatory = $true)] + [string]$SelectedMode + ) + + Write-Information '' -InformationAction Continue + Write-Information 'NetClean Summary' -InformationAction Continue + Write-Information '----------------' -InformationAction Continue + Write-Information "Mode: $SelectedMode" -InformationAction Continue + + if ($Result.PSObject.Properties.Name -contains 'ManagementState') { + Show-NetCleanManagementStatus -ManagementState $Result.ManagementState + } + + if ($Result.PSObject.Properties.Name -contains 'Summary') { + Write-Information '' -InformationAction Continue + Write-Information 'Phase 1 - Detect' -InformationAction Continue + Write-Information " Protected vendors detected: $($Result.Summary.ProtectedVendorsCount)" -InformationAction Continue + Write-Information " Protected interface GUIDs: $($Result.Summary.ProtectedInterfaceGuidCount)" -InformationAction Continue + Write-Information " Candidate artifacts: $($Result.Summary.CandidateArtifactCount)" -InformationAction Continue + Write-Information " Sanitizable artifacts: $($Result.Summary.SanitizableArtifactCount)" -InformationAction Continue + } + + if ($Result.PSObject.Properties.Name -contains 'Protect') { + Write-Information '' -InformationAction Continue + Write-Information 'Phase 2 - Protect' -InformationAction Continue + Write-Information " Protected registry paths: $($Result.Protect.Summary.ProtectedRegistryPathCount)" -InformationAction Continue + Write-Information " Wi-Fi backup items: $($Result.Protect.Summary.WiFiBackupCount)" -InformationAction Continue + Write-Information " Protected registry backups: $($Result.Protect.Summary.ProtectedRegistryBackupCount)" -InformationAction Continue + } + + if ($Result.PSObject.Properties.Name -contains 'Clean') { + Write-Information '' -InformationAction Continue + Write-Information 'Phase 3 - Clean' -InformationAction Continue + Write-Information " Wi-Fi profiles removed: $($Result.Clean.Summary.WiFiProfilesRemoved)" -InformationAction Continue + Write-Information " Registry artifacts removed: $($Result.Clean.Summary.RegistryArtifactsRemoved)" -InformationAction Continue + Write-Information " Event logs touched: $($Result.Clean.Summary.EventLogsTouched)" -InformationAction Continue + Write-Information " User artifacts touched: $($Result.Clean.Summary.UserArtifactsTouched)" -InformationAction Continue + if ($Result.Clean.Summary.PSObject.Properties.Name -contains 'AdaptersConfigured') { + Write-Information " Adapters reset to IPv4 DHCP: $($Result.Clean.Summary.AdaptersConfigured)" -InformationAction Continue + Write-Information " Adapters preserved: $($Result.Clean.Summary.AdaptersSkipped)" -InformationAction Continue + Write-Information " Adapter reset failures: $($Result.Clean.Summary.AdapterFailures)" -InformationAction Continue } - - function Get-ServiceRegistryInfo($svcName) { - $res = @{} - $key = "HKLM:\SYSTEM\CurrentControlSet\Services\$svcName" - if (-not (Test-Path $key)) { return $null } - try { - $props = Get-ItemProperty -Path $key -ErrorAction Stop - $res.Path = $key - $res.DisplayName = $props.DisplayName - $res.ImagePath = $props.ImagePath - $res.DependOnService = $props.DependOnService - $res.Start = $props.Start - } catch { return $null } - return $res + if ($Result.Clean.PSObject.Properties.Name -contains 'AdapterConfiguration') { + $adapterConfiguration = $Result.Clean.AdapterConfiguration + Write-Information " DNS provider: $($adapterConfiguration.Provider)" -InformationAction Continue + Write-Information " DNS servers: $(@($adapterConfiguration.DnsServers) -join ', ')" -InformationAction Continue + } + if ( + $Result.Clean.Summary.PSObject.Properties.Name -contains 'PreferIPv4' -and + $Result.Clean.Summary.PreferIPv4 + ) { + Write-Information ' IPv6 remains enabled; IPv4 will be preferred after restart.' -InformationAction Continue } + Write-Information " Advanced repair actions: $($Result.Clean.Summary.AdvancedRepairActions)" -InformationAction Continue + Write-Information " Performance tuning actions: $($Result.Clean.Summary.PerformanceTuningActions)" -InformationAction Continue + } + + # Detailed lists: Wi-Fi & network profile details and removed artifacts + # Wi-Fi: initial list comes from Protect.Manifest.WiFiExports (entries include "PROFILE:") + if ($Result.PSObject.Properties.Name -contains 'Protect') { + $manifest = $Result.Protect.Manifest + if ($manifest -and $manifest.WiFiExports -and $manifest.WiFiExports.Count -gt 0) { + $found = @($manifest.WiFiExports | Where-Object { $_ -is [string] -and $_ -like 'PROFILE:*' } | ForEach-Object { $_ -replace '^PROFILE:', '' }) + if ($found.Count -gt 0) { + Show-TruncatedList -Items $found -Heading 'Wi-Fi Profiles - Found' + } - function Inspect-ServiceDependencies($serviceList) { - $regPaths = @() - $driverFiles = @() - foreach ($s in $serviceList | Sort-Object -Unique) { - if (-not $s) { continue } - $info = Get-ServiceRegistryInfo $s - if ($info) { - $regPaths += $info.Path - if ($info.ImagePath) { - $img = $info.ImagePath -replace '"','' - # If it references a .sys driver, capture the filename - if ($img -match '\\([^\\]+\.sys)') { $driverFiles += $Matches[1] } - } - if ($info.DependOnService) { - foreach ($d in @($info.DependOnService)) { - $regPaths += "HKLM:\\SYSTEM\\CurrentControlSet\\Services\\$d" - } - } - } + if ($manifest.NetworkListBackup) { + Write-Information '' -InformationAction Continue + Write-Information "Network list backup: $($manifest.NetworkListBackup)" -InformationAction Continue } - return @{ Registry=$regPaths|Sort-Object -Unique; Drivers=$driverFiles|Sort-Object -Unique } } - - function Is-RegistryPathProtected($path) { - if (-not $global:ProtectedRegistryPaths) { return $false } - foreach ($p in $global:ProtectedRegistryPaths) { - if (-not $p) { continue } - if ($path -like "$p*" -or $p -like "$path*") { return $true } + } + + # If Clean phase ran, show removed items and remaining Wi-Fi profiles + if ($Result.PSObject.Properties.Name -contains 'Clean') { + $clean = $Result.Clean + + # Removed Wi-Fi profiles (names) + if ($clean.WiFi -and $clean.WiFi.Profiles) { + Show-TruncatedList -Items @($clean.WiFi.Profiles) -Heading 'Wi-Fi Profiles - Removed' + + # Compute remaining if we have the original found list + if ($Result.PSObject.Properties.Name -contains 'Protect' -and $Result.Protect.Manifest -and $Result.Protect.Manifest.WiFiExports) { + $original = @($Result.Protect.Manifest.WiFiExports | Where-Object { $_ -is [string] -and $_ -like 'PROFILE:*' } | ForEach-Object { $_ -replace '^PROFILE:', '' }) + $remaining = @($original | Where-Object { $_ -notin $clean.WiFi.Profiles }) + if ($remaining.Count -gt 0) { Show-TruncatedList -Items $remaining -Heading 'Wi-Fi Profiles - Remaining After Cleanup' } + else { Write-Information '' -InformationAction Continue; Write-Information 'Wi-Fi Profiles - Remaining After Cleanup' -InformationAction Continue; Write-Information ' - (none)' -InformationAction Continue } } - return $false } - # The registry and Wi-Fi backup helpers are provided by the Netclean module - # Backup-ProtectedRegistryKeys, Backup-NetworkList, and Backup-WiFiProfiles - - function Remove-WiFiProfilesSafe { - Write-Host "Preparing to remove Wi-Fi profiles (preview)..." -ForegroundColor Yellow - Write-Log 'INFO' "Preparing to remove Wi-Fi profiles (preview)" - $profiles = netsh wlan show profiles | Select-String 'All User Profile' | ForEach-Object { ($_ -split ':')[1].Trim() } - if (-not $profiles) { Write-Host "No Wi-Fi profiles found." -ForegroundColor Gray; return } - $profiles | ForEach-Object { Write-Host " - $_" -ForegroundColor DarkGray; Write-Log 'INFO' "Found Wi-Fi profile: $_" } - if (-not $Force) { - if (-not (Prompt-YesNo "Delete all above Wi-Fi profiles?")) { Write-Host "Skipping Wi-Fi deletion." -ForegroundColor Yellow; return } + # Registry keys removed + if ($clean.RegistryArtifacts -and $clean.RegistryArtifacts.Results) { + $removedKeys = @($clean.RegistryArtifacts.Results | Where-Object { $_.Removed } | ForEach-Object { $_.RegistryPath }) + if ($removedKeys.Count -gt 0) { + Write-Information '' -InformationAction Continue + Write-Information ("Registry keys removed: {0}" -f $removedKeys.Count) -InformationAction Continue + Show-TruncatedList -Items $removedKeys -Heading 'Registry keys removed' } - foreach ($p in $profiles) { - $delArgs = @('wlan','delete','profile','name="' + $p + '"') - if ($DryRun) { Write-Host "DRYRUN: netsh $($delArgs -join ' ')" } else { - try { - Start-Process -FilePath 'netsh' -ArgumentList $delArgs -NoNewWindow -Wait -ErrorAction Stop - Write-Host "Deleted profile: $p" -ForegroundColor Gray - Write-NetcleanLog 'INFO' "Deleted Wi-Fi profile: $p" - } catch { - Write-Warning ("Failed to delete " + ${p} + ": " + $_) - Write-NetcleanLog 'ERROR' ("Failed to delete Wi-Fi profile: " + $p + " - " + $_) - } - } - } } - function Safe-RemoveNetworkListProfiles($vmwareGuids, $dest) { - $base = 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\NetworkList' - $protection = Build-ProtectionLists - $adapterPatterns = $protection.Adapters - $profilesPath = Join-Path $base 'Profiles' - if (-not (Test-Path $profilesPath)) { Write-Host "No NetworkList Profiles key found." -ForegroundColor Gray; return } - $toRemove = @() - Get-ChildItem $profilesPath | ForEach-Object { - $p = $_ - try { - $item = Get-ItemProperty -Path $p.PSPath -ErrorAction Stop - $profileName = $item.ProfileName - $description = $item.Description - $isVm = $false - if ($description) { - foreach ($pat in $adapterPatterns) { if ($description -match $pat) { $isVm = $true; break } } - } - if (-not $isVm -and $profileName) { - foreach ($pat in $adapterPatterns) { if ($profileName -match $pat) { $isVm = $true; break } } - } - if (-not $isVm) { $toRemove += @{ Path=$p.PSPath; Name=$profileName } } - else { Write-Host "Preserving VM profile: $profileName" -ForegroundColor DarkGray } - } catch { Write-Warning ("Failed reading profile key " + $_ + ": " + $_) } - } - if (-not $toRemove) { Write-Host "No non-VM NetworkList profiles to remove." -ForegroundColor Gray; return } - Write-Host "Profiles to remove:" -ForegroundColor Yellow - $toRemove | ForEach-Object { Write-Host " - $($_.Name) : $($_.Path)" -ForegroundColor DarkGray; Write-Log 'INFO' "Planned NetworkList removal: $($_.Name) -> $($_.Path)" } - if (-not $Force) { if (-not (Prompt-YesNo "Remove listed NetworkList profiles?")) { Write-Host "Skipping NetworkList removals." -ForegroundColor Yellow; return } } - foreach ($r in $toRemove) { - if (Is-RegistryPathProtected $r.Path) { Write-Host "Skipping removal of protected registry path: $($r.Path)" -ForegroundColor DarkGray; Write-Log 'WARN' "Skipped protected NetworkList profile: $($r.Path)"; continue } - if ($DryRun) { Write-Host "DRYRUN: Remove-Item -Path $($r.Path) -Recurse -Force" -ForegroundColor Gray; Write-Log 'INFO' "DRYRUN: would remove $($r.Path)" } - else { try { Remove-Item -Path $r.Path -Recurse -Force -ErrorAction Stop; Write-Host "Removed: $($r.Name)" -ForegroundColor Gray; Write-Log 'INFO' "Removed NetworkList profile: $($r.Name) at $($r.Path)" } catch { Write-Warning ("Failed remove " + $($r.Path) + ": " + $_); Write-Log 'ERROR' ("Failed to remove NetworkList profile: " + $($r.Path) + " - " + $_) } } + # Event logs cleared (names) + if ($clean.EventLogs) { + $logs = @($clean.EventLogs | ForEach-Object { if ($_.Name) { $_.Name } elseif ($_.LogName) { $_.LogName } else { $_ } }) + if ($logs.Count -gt 0) { + Write-Information '' -InformationAction Continue + Write-Information ("Event logs touched: {0}" -f $logs.Count) -InformationAction Continue + Show-TruncatedList -Items $logs -Heading 'Event logs touched' } - # Signatures removal (map to interface GUIDs) - be conservative - $sigSubs = @('Signatures\\Unmanaged','Signatures\\Managed') - foreach ($sub in $sigSubs) { - $sigPath = Join-Path $base $sub - if (-not (Test-Path $sigPath)) { continue } - Get-ChildItem $sigPath | ForEach-Object { - $name = $_.PSChildName - $norm = Normalize-Guid($name) - if ($vmwareGuids -contains $norm) { Write-Host "Preserving signature $name (VM)" -ForegroundColor DarkGray; return } - # inspect properties to detect adapter/driver ties - try { - $sig = Get-ItemProperty -Path $_.PsPath -ErrorAction SilentlyContinue - $dnsSuffix = $sig.DnsSuffix - $defaultGatewayMac = $sig.DefaultGatewayMac - $preserve = $false - foreach ($pat in $adapterPatterns) { - if ($dnsSuffix -and ($dnsSuffix -match $pat)) { $preserve = $true; break } - if ($defaultGatewayMac -and ($defaultGatewayMac -match $pat)) { $preserve = $true; break } - } - if ($preserve) { Write-Host "Preserving signature $name (matches protected adapter/AV)" -ForegroundColor DarkGray; return } - } catch {} - # else remove - if ($DryRun) { Write-Host "DRYRUN: Remove signature $name" -ForegroundColor Gray } - else { try { Remove-Item -Path $_.PsPath -Recurse -Force -ErrorAction Stop; Write-Host "Removed signature: $name" -ForegroundColor Gray } catch { Write-Warning ("Failed remove signature " + ${name} + ": " + $_) } } - } + } + } + + if ($Result.PSObject.Properties.Name -contains 'Verify') { + Write-Information '' -InformationAction Continue + Write-Information 'Phase 4 - Verify' -InformationAction Continue + Write-Information " Verification passed: $($Result.Verify.Summary.Passed)" -InformationAction Continue + Write-Information " Missing vendors: $($Result.Verify.Summary.MissingVendorsCount)" -InformationAction Continue + Write-Information " Missing protected GUIDs: $($Result.Verify.Summary.MissingGuidCount)" -InformationAction Continue + Write-Information " Missing services: $($Result.Verify.Summary.MissingServiceCount)" -InformationAction Continue + + if (@($Result.Verify.VendorComparison.Missing).Count -gt 0) { + Write-Information (" Missing vendor names: " + ($Result.Verify.VendorComparison.Missing -join ', ')) -InformationAction Continue + } + } + + if ($Result.PSObject.Properties.Name -contains 'BackupPath') { + Write-Information '' -InformationAction Continue + Write-Information "Backup Path: $($Result.BackupPath)" -InformationAction Continue + } + + $logFile = Get-NetCleanLogFile + if ($logFile) { + Write-Information "Log File: $logFile" -InformationAction Continue + } + + Write-Information '' -InformationAction Continue + + # Show total runtime (if start time recorded) + if ($script:RunStart) { + $elapsed = (Get-Date) - $script:RunStart + Write-Information ("Total runtime: {0}" -f $elapsed.ToString()) -InformationAction Continue + } + + # Per-phase timings (if available) + if ($Result.PSObject.Properties.Name -contains 'Timings') { + Write-Information '' -InformationAction Continue + Write-Information 'Phase runtimes' -InformationAction Continue + foreach ($phase in $Result.Timings.PSObject.Properties.Name) { + $t = $Result.Timings.$phase + if ($t -and $t.Duration) { + Write-Information (" {0}: {1}" -f $phase, $t.Duration.ToString()) -InformationAction Continue } } + } +} - function Reset-Networking { - $cmds = @( - @{Name='Flush DNS'; Cmd='ipconfig /flushdns'}, - @{Name='Clear ARP'; Cmd='arp -d *'}, - @{Name='Reset Winsock'; Cmd='netsh winsock reset'}, - @{Name='Reset IPv4'; Cmd='netsh int ip reset'}, - @{Name='Reset IPv6'; Cmd='netsh int ipv6 reset'} - ) - foreach ($c in $cmds) { - Write-Host "$($c.Name)..." -ForegroundColor Yellow - Write-Log 'INFO' "$($c.Name) - Command: $($c.Cmd)" - if ($DryRun) { Write-Host "DRYRUN: $($c.Cmd)" -ForegroundColor Gray } else { - try { iex $c.Cmd | Out-Null; Write-Host "$($c.Name) done" -ForegroundColor Gray; Write-Log 'INFO' "$($c.Name) completed" } catch { Write-Warning ("$($c.Name) failed: " + $_); Write-Log 'ERROR' ("$($c.Name) failed: " + $_) } - } +<# +.SYNOPSIS + Shows the preview summary for the NetClean process. +.DESCRIPTION + This function displays a preview of the actions that will be taken by the NetClean process. +.PARAMETER Result + The result object containing the preview information. +.EXAMPLE + Show-PreviewSummary -Result $result +#> +function Show-PreviewSummary { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Result + ) + + Write-Information '' -InformationAction Continue + Write-Information 'Preview Summary' -InformationAction Continue + Write-Information '---------------' -InformationAction Continue + + if ($Result.PSObject.Properties.Name -contains 'ManagementState') { + Show-NetCleanManagementStatus -ManagementState $Result.ManagementState + } + + Write-Information "Protected vendors detected: $($Result.Summary.ProtectedVendorsCount)" -InformationAction Continue + Write-Information "Protected interface GUIDs: $($Result.Summary.ProtectedInterfaceGuidCount)" -InformationAction Continue + Write-Information "Candidate artifacts: $($Result.Summary.CandidateArtifactCount)" -InformationAction Continue + Write-Information "Sanitizable artifacts: $($Result.Summary.SanitizableArtifactCount)" -InformationAction Continue + Write-Information '' -InformationAction Continue + Write-Information "Backup Path: $($Result.BackupPath)" -InformationAction Continue + + $logFile = Get-NetCleanLogFile + if ($logFile) { + Write-Information "Log File: $logFile" -InformationAction Continue + } + + # Show Wi-Fi profiles found (from Protect.Manifest if available) + if ($Result.PSObject.Properties.Name -contains 'Protect') { + $manifest = $Result.Protect.Manifest + if ($manifest -and $manifest.WiFiExports -and $manifest.WiFiExports.Count -gt 0) { + $found = @($manifest.WiFiExports | Where-Object { $_ -is [string] -and $_ -like 'PROFILE:*' } | ForEach-Object { $_ -replace '^PROFILE:', '' }) + if ($found.Count -gt 0) { + Write-Information '' -InformationAction Continue + Write-Information 'Wi-Fi Profiles - Found' -InformationAction Continue + foreach ($p in $found) { Write-Information " - $p" -InformationAction Continue } } - } - function Clear-NLAProbing { - $nlaInternetPath = 'HKLM:\\SYSTEM\\CurrentControlSet\\Services\\NlaSvc\\Parameters\\Internet' - if (Test-Path $nlaInternetPath) { - $props = 'ActiveDnsProbeContent','ActiveDnsProbeHost','ActiveWebProbeContent','ActiveWebProbeHost' - foreach ($p in $props) { - if ($DryRun) { Write-Host "DRYRUN: Remove-ItemProperty $nlaInternetPath -Name $p" -ForegroundColor Gray; Write-Log 'INFO' "DRYRUN: would remove NLA property $p" } - else { try { Remove-ItemProperty -Path $nlaInternetPath -Name $p -ErrorAction Stop; Write-Host "Removed NLA property: $p" -ForegroundColor Gray; Write-Log 'INFO' "Removed NLA property: $p" } catch { Write-Verbose ("Property " + $p + " not present or failed: " + $_); Write-Log 'WARN' ("NLA property " + $p + " missing or failed: " + $_) } } - } + if ($manifest.NetworkListBackup) { + Write-Information '' -InformationAction Continue + Write-Information "Network list backup: $($manifest.NetworkListBackup)" -InformationAction Continue } } - - function Clear-EventLogs { - $logs = @("Microsoft-Windows-WLAN-AutoConfig/Operational","Microsoft-Windows-NetworkProfile/Operational","Microsoft-Windows-DHCP-Client/Operational") - foreach ($l in $logs) { - Write-Host "Clearing event log: $l" -ForegroundColor Yellow - if ($DryRun) { Write-Host "DRYRUN: wevtutil cl `"$l`"" -ForegroundColor Gray } - else { try { wevtutil cl "$l" 2>$null; Write-Host "Cleared $l" -ForegroundColor Gray } catch { Write-Warning ("Failed clearing " + ${l} + ": " + $_) } } + } + + # Show sanitizable registry artifacts (preview of what would be removed) + if ($Result.PSObject.Properties.Name -contains 'SanitizableArtifacts' -and $Result.SanitizableArtifacts.Count -gt 0) { + Write-Information '' -InformationAction Continue + Write-Information "Sanitizable registry artifacts (candidates): $($Result.SanitizableArtifacts.Count)" -InformationAction Continue + foreach ($a in $Result.SanitizableArtifacts) { + if ($a.PSObject.Properties.Name -contains 'RegistryPath' -and $a.RegistryPath) { + Write-Information " - $($a.RegistryPath)" -InformationAction Continue } } - - function Main { - Write-Host "=== Network cleanup starting ===" -ForegroundColor Cyan - Ensure-Admin - - # Detect installed AV/EDR and hypervisors early so user can make an informed choice - $detectedAV = Get-InstalledAV - if ($detectedAV -and $detectedAV.Count -gt 0) { - Write-Host "Detected potentially impacted software:" -ForegroundColor Yellow - $detectedAV | ForEach-Object { Write-Host " - $_" -ForegroundColor DarkGray } - } else { Write-Host "No endpoint protection detected by quick checks." -ForegroundColor DarkGray } - - $detectedHypervisors = Get-DetectedHypervisors - if ($detectedHypervisors -and $detectedHypervisors.Count -gt 0) { - Write-Host "Detected hypervisors/virtualization:" -ForegroundColor Yellow - $detectedHypervisors | ForEach-Object { Write-Host " - $_" -ForegroundColor DarkGray } - } else { Write-Host "No hypervisors detected by quick checks." -ForegroundColor DarkGray } - - $interactive = ($PSBoundParameters.Count -eq 0) - - if ($interactive) { - # Interactive prompts when no switches provided - $DryRun = Prompt-YesNo "Run in DRY RUN mode?" - $CreateLog = Prompt-YesNo "Create a full log file for this run?" - - # If detection missed AV/EDR, ask the user to confirm and optionally provide product names - if ((-not $detectedAV) -or ($detectedAV.Count -eq 0)) { - $hasAV = Prompt-YesNo "No endpoint protection was detected automatically. Do you have endpoint protection (AV/EDR/XDR) installed?" - if ($hasAV) { - $entered = Read-Host "If known, enter comma-separated names of installed products (or press Enter to skip)" - if ($entered) { $detectedAV = ($entered -split ',') | ForEach-Object { $_.Trim() } } - } else { $detectedAV = @() } - } else { $hasAV = $true } - - # If detection missed hypervisors, ask the user to confirm - if ((-not $detectedHypervisors) -or ($detectedHypervisors.Count -eq 0)) { - $hasVM = Prompt-YesNo "No hypervisor was detected automatically. Do you use VMware/VirtualBox/Hyper-V or other virtualization?" - if ($hasVM) { $vmwareGuids = Get-HypervisorGuids } else { $vmwareGuids = @() } - } else { - $hasVM = $true - $vmwareGuids = Get-HypervisorGuids - } - - $performBackups = Prompt-YesNo "Create backups and protected registry exports now?" - if ($performBackups) { $OnlyBackup = $true } - } else { - # Respect provided switches - $performBackups = [bool]$OnlyBackup - # Populate vmwareGuids based on detectedHypervisors if not interactive - $vmwareGuids = Get-HypervisorGuids - $hasVM = ($vmwareGuids -and $vmwareGuids.Count -gt 0) - if (-not $detectedAV) { $detectedAV = Get-InstalledAV } + } + Write-Information '' -InformationAction Continue + + # Show total runtime (if start time recorded) + if ($script:RunStart) { + $elapsed = (Get-Date) - $script:RunStart + Write-Information ("Total runtime: {0}" -f $elapsed.ToString()) -InformationAction Continue + } + + if ($Result.PSObject.Properties.Name -contains 'Timings') { + Write-Information '' -InformationAction Continue + Write-Information 'Phase runtimes' -InformationAction Continue + foreach ($phase in $Result.Timings.PSObject.Properties.Name) { + $t = $Result.Timings.$phase + if ($t -and $t.Duration) { + Write-Information (" {0}: {1}" -f $phase, $t.Duration.ToString()) -InformationAction Continue } - - # If logging requested or backups will be created, initialize the log. - if ($CreateLog -or $performBackups) { Start-Log $LogPath; Write-Log 'INFO' ("Network cleanup starting. DryRun=$DryRun; Force=$Force; OnlyBackup=$OnlyBackup") } - if ($DryRun) { Write-Log 'WARN' "Running in DRY RUN mode. No destructive actions will be performed." } - - # If backups requested, perform them and optionally exit (backup-only) - if ($performBackups) { - Write-Host "Preparing backup path: $BackupPath" -ForegroundColor Yellow - Write-Log 'INFO' "Preparing backup path: $BackupPath" - New-ProtectedBackupPath $BackupPath - Backup-NetworkList $BackupPath - Backup-WiFiProfiles $BackupPath - - # Build protection lists and export protected registry keys - $protection = Build-ProtectionLists - $deps = Inspect-ServiceDependencies(($protection.Services + $detectedAV) | Sort-Object -Unique) - $global:ProtectedRegistryPaths = @() - if ($protection.Registry) { $global:ProtectedRegistryPaths += $protection.Registry } - if ($deps.Registry) { $global:ProtectedRegistryPaths += $deps.Registry } - $global:ProtectedRegistryPaths = $global:ProtectedRegistryPaths | Sort-Object -Unique - if ($global:ProtectedRegistryPaths) { Write-Host ("Protected registry paths: " + ($global:ProtectedRegistryPaths -join ', ')) -ForegroundColor Gray; Write-Log 'INFO' ("Protected registry paths: " + ($global:ProtectedRegistryPaths -join ', ')) } - - $regBackups = Backup-ProtectedRegistryKeys $global:ProtectedRegistryPaths $BackupPath - if ($regBackups) { Write-Log 'INFO' ("Protected registry keys exported: " + ($regBackups -join ', ')) } - - # Append clear restore instructions at the bottom of the log (always create log for backups) - Write-Log 'INFO' "Backups are located at: $BackupPath" - $netlist = Get-ChildItem -Path $BackupPath -Filter 'NetworkList_*.reg' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName -First 1 - Write-Log 'INFO' "NetworkList registry backup: $netlist" - Write-Log 'INFO' "Exported Wi-Fi profiles (XMLs) are in: $BackupPath" - if ($regBackups) { - Write-Log 'INFO' "Protected registry backup files:" - foreach ($rb in $regBackups) { Write-Log 'INFO' (" $rb") } - Write-Log 'INFO' "To restore protected registry keys, run each of the following (as Administrator):" - foreach ($rb in $regBackups) { $line = ' reg import "' + $rb + '"'; Write-Log 'INFO' $line } - } - if ($netlist) { $line = 'To restore NetworkList registry: reg import "' + $netlist + '" (run as Administrator).'; Write-Log 'INFO' $line } - Write-Log 'INFO' ("To restore Wi-Fi profiles: for each exported XML in $BackupPath run: netsh wlan add profile filename=''") - - if ($OnlyBackup) { Write-Host "Backup-only requested; exiting after backups." -ForegroundColor Yellow; return } + } + } +} + +function Read-PostRunAction { + [CmdletBinding()] + [OutputType([string])] + param() + + if ($script:RebootNow) { + return 'Restart' + } + + while ($true) { + $choice = Read-Host 'Choose post-run action: [R]estart / [S]hutdown / [N]o action' + switch ($choice.ToUpperInvariant()) { + 'R' { return 'Restart' } + 'S' { return 'Shutdown' } + 'N' { return 'None' } + default { + Write-Information 'Please enter R, S, or N.' -InformationAction Continue } + } + } +} - # Continue with full cleanup - # Detect hypervisors and prompt only if none detected - $detectedHypervisors = Get-DetectedHypervisors - if ($detectedHypervisors -and $detectedHypervisors.Count -gt 0) { - Write-Host "Detected hypervisors/virtualization: " -ForegroundColor Yellow - $detectedHypervisors | ForEach-Object { Write-Host " - $_" -ForegroundColor DarkGray } - $hasVM = $true - $vmwareGuids = Get-HypervisorGuids - } else { - $hasVM = Prompt-YesNo "Do you use VMware/VirtualBox or other virtualization on this machine?" - $vmwareGuids = @() - if ($hasVM) { $vmwareGuids = Get-HypervisorGuids } +<# +.SYNOPSIS + Invokes the post-run action. +.DESCRIPTION + This function performs the selected post-run action (restart, shutdown, or no action). +.PARAMETER Action + The post-run action to perform. +.PARAMETER DryRunMode + Indicates whether to run in dry-run mode. +.EXAMPLE + Invoke-PostRunAction -Action 'Restart' -DryRunMode:$false +.NOTES + The function uses the Convert-RegToProviderPath function to normalize the input path. +#> +function Invoke-PostRunAction { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Restart', 'Shutdown', 'None')] + [string]$Action, + + [switch]$DryRunMode + ) + + switch ($Action) { + 'Restart' { + if ($DryRunMode) { + Write-NetCleanLog -Level INFO -Message 'DRYRUN: Restart-Computer -Force' } - - # Stop services where appropriate. Protect AV/EDR services discovered. - $services = @('WlanSvc','Dnscache','Dhcp','NlaSvc','lmhosts') - if ($detectedAV) { Write-Host ("Detected AV/EDR: " + ($detectedAV -join ', ')) -ForegroundColor Gray; Write-Log 'INFO' ("Detected AV/EDR: " + ($detectedAV -join ', ')) } - $protection = Build-ProtectionLists - $protectedServices = $protection.Services - $protectedAdapters = $protection.Adapters - # Inspect service registry dependencies and add to protected registry paths - $deps = Inspect-ServiceDependencies(($protectedServices + $detectedAV) | Sort-Object -Unique) - $global:ProtectedRegistryPaths = @() - if ($protection.Registry) { $global:ProtectedRegistryPaths += $protection.Registry } - if ($deps.Registry) { $global:ProtectedRegistryPaths += $deps.Registry } - $global:ProtectedRegistryPaths = $global:ProtectedRegistryPaths | Sort-Object -Unique - if ($global:ProtectedRegistryPaths) { Write-Host ("Protected registry paths: " + ($global:ProtectedRegistryPaths -join ', ')) -ForegroundColor Gray; Write-Log 'INFO' ("Protected registry paths: " + ($global:ProtectedRegistryPaths -join ', ')) } - if ($protectedServices) { Write-Host ("Protected service patterns: " + ($protectedServices -join ', ')) -ForegroundColor Gray; Write-Log 'INFO' ("Protected service patterns: " + ($protectedServices -join ', ')) } - # Backup protected registry keys if not already done - if (-not $regBackups) { $regBackups = Backup-ProtectedRegistryKeys $global:ProtectedRegistryPaths $BackupPath; if ($regBackups) { Write-Log 'INFO' ("Protected registry keys exported: " + ($regBackups -join ', ')) } } - - foreach ($s in $services) { - $skip = $false - foreach ($pat in $protectedServices) { if ($s.ToLower().Contains($pat.ToLower())) { $skip = $true; break } } - if ($skip) { Write-Host "Preserving service due to protection match: $s" -ForegroundColor DarkGray; continue } - Write-Host "Stopping service: $s" -ForegroundColor Yellow - Write-Log 'INFO' "Stopping service: $s" - if ($DryRun) { Write-Host "DRYRUN: Stop-Service -Name $s -Force" -ForegroundColor Gray; Write-Log 'INFO' "DRYRUN: Stop-Service -Name $s -Force" } - else { try { Stop-Service -Name $s -Force -ErrorAction Stop; Write-Host "Stopped $s" -ForegroundColor Gray; Write-Log 'INFO' "Stopped service: $s" } catch { Write-Warning ("Failed to stop " + ${s} + ": " + $_); Write-Log 'ERROR' ("Failed to stop service " + $s + ": " + $_) } } + else { + Restart-Computer -Force + } + } + 'Shutdown' { + if ($DryRunMode) { + Write-NetCleanLog -Level INFO -Message 'DRYRUN: Stop-Computer -Force' } - - # Wi-Fi profiles - Remove-WiFiProfilesSafe - - # Reset networking - Reset-Networking - - # NLA probing - Clear-NLAProbing - - # NetworkList cleaning - Safe-RemoveNetworkListProfiles $vmwareGuids $BackupPath - - # DHCP/WLAN file cleanup - skip if AV present unless forced - $hasAV = ($detectedAV -and $detectedAV.Count -gt 0) - if ($hasAV -and -not $Force) { Write-Host "Skipping DHCP/WLAN file deletions due to AV presence (use -Force to override)." -ForegroundColor Yellow } else { - $dhcpPath = "$env:SystemRoot\\System32\\dhcp" - if (Test-Path $dhcpPath) { - $files = Get-ChildItem $dhcpPath -File -ErrorAction SilentlyContinue - if ($files) { - Write-Host "DHCP files to remove:" -ForegroundColor Yellow - $files | ForEach-Object { Write-Host " - $($_.FullName)" -ForegroundColor DarkGray } - if ($Force -or (Prompt-YesNo "Delete the above DHCP files?")) { - foreach ($f in $files) { - if ($DryRun) { Write-Host "DRYRUN: Remove-Item $($f.FullName)" -ForegroundColor Gray } else { try { Remove-Item $f.FullName -Force -ErrorAction Stop; Write-Host "Removed $($f.Name)" -ForegroundColor Gray } catch { Write-Warning ("Failed remove " + $($f.FullName) + ": " + $_) } } - } - } - } - } - $wlanLogPath = "$env:ProgramData\\Microsoft\\Wlansvc\\Logs" - if (Test-Path $wlanLogPath) { - if ($Force -or (Prompt-YesNo "Remove WLAN logs under $wlanLogPath?")) { - if ($DryRun) { Write-Host "DRYRUN: Remove logs under $wlanLogPath" -ForegroundColor Gray } - else { try { Get-ChildItem $wlanLogPath -Recurse -File -ErrorAction Stop | ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction Stop } ; Write-Host "Cleared WLAN logs" -ForegroundColor Gray } catch { Write-Warning ("Failed clearing WLAN logs: " + $_) } } - } - } + Stop-Computer -Force } + } + 'None' { + Write-NetCleanLog -Level INFO -Message 'No post-run power action selected.' + } + } +} - # Event logs - Clear-EventLogs - - # Restart services - foreach ($s in $services) { - Write-Host "Starting service: $s" -ForegroundColor Yellow - Write-Log 'INFO' "Starting service: $s" - if ($DryRun) { Write-Host "DRYRUN: Start-Service -Name $s" -ForegroundColor Gray; Write-Log 'INFO' "DRYRUN: Start-Service -Name $s" } - else { try { Start-Service -Name $s -ErrorAction Stop; Write-Host "Started $s" -ForegroundColor Gray; Write-Log 'INFO' "Started service: $s" } catch { Write-Warning ("Failed to start " + ${s} + ": " + $_); Write-Log 'ERROR' ("Failed to start service " + $s + ": " + $_) } } - } +# --------------------------------------------------------------------------- +# Main orchestration +# --------------------------------------------------------------------------- - Write-Host "=== Network cleanup complete ===" -ForegroundColor Green - Write-Log 'INFO' "Network cleanup complete" - - # Final summary and restore instructions appended to log bottom - Write-Log 'INFO' "Backups are located at: $BackupPath" - $netlist = Get-ChildItem -Path $BackupPath -Filter 'NetworkList_*.reg' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName -First 1 - Write-Log 'INFO' "NetworkList registry backup: $netlist" - Write-Log 'INFO' "Exported Wi-Fi profiles (XMLs) are in: $BackupPath" - if ($regBackups) { - Write-Log 'INFO' "Protected registry backup files:" - foreach ($rb in $regBackups) { Write-Log 'INFO' (" $rb") } - } - Write-Log 'INFO' "To restore protected registry keys, run each of the following (as Administrator):" - if ($regBackups) { foreach ($rb in $regBackups) { $line = ' reg import "' + $rb + '"'; Write-Log 'INFO' $line } } - if ($netlist) { $line = 'To restore NetworkList registry: reg import "' + $netlist + '" (run as Administrator).'; Write-Log 'INFO' $line } - Write-Log 'INFO' ("To restore Wi-Fi profiles: for each exported XML in $BackupPath run: netsh wlan add profile filename=''") - Write-Log 'INFO' "If you need help restoring drivers or services, review the log and protected registry paths listed above before making changes." - - # Reboot/shutdown options - if ($RebootNow) { - if ($DryRun) { Write-Host "DRYRUN: Restart-Computer" } else { Restart-Computer -Force } - } else { - while ($true) { - $choice = Read-Host "Choose post-run action: [R]estart / [S]hutdown / [N]o action" - switch ($choice.ToUpper()) { - 'R' { if ($DryRun) { Write-Host "DRYRUN: Restart-Computer" } else { Restart-Computer -Force }; break } - 'S' { if ($DryRun) { Write-Host "DRYRUN: Stop-Computer" } else { Stop-Computer -Force }; break } - 'N' { Write-Host "Please reboot or shutdown later to apply changes." -ForegroundColor Yellow; break } - default { Write-Host "Enter R, S, or N." -ForegroundColor Yellow; continue } - } - break - } - } +<# +.SYNOPSIS + Invokes the NetClean launcher. +.DESCRIPTION + This function starts the NetClean process with the specified options. +.EXAMPLE + Invoke-NetCleanLauncher +.OUTPUTS + System.Void +.NOTES + The function tests for administrator privileges before proceeding. +#> +function Invoke-NetCleanLauncher { + [CmdletBinding()] + [OutputType([void])] + param() + + Test-NetCleanAdministrator + + $selectedMode = $script:Mode + if ($selectedMode -eq 'Menu') { + $selectedMode = Read-NetCleanMenuSelection + if ($selectedMode -eq 'Exit') { + return } + } + + $selectedPerformanceProfile = $null + + if ($selectedMode -eq 'PerformanceTune') { + $selectedPerformanceProfile = Read-NetCleanPerformanceProfileSelection - Main + if ($selectedPerformanceProfile -eq 'Cancel') { + Write-Information 'Performance tuning cancelled.' -InformationAction Continue + return + } + } + + Show-ModeExplanation -SelectedMode $selectedMode + + $optionParameters = @{ + SelectedMode = $selectedMode + DryRun = [bool]$script:DryRun + SkipWifi = [bool]$script:SkipWifi + SkipDnsFlush = [bool]$script:SkipDnsFlush + SkipEventLogs = [bool]$script:SkipEventLogs + SkipUserArtifacts = [bool]$script:SkipUserArtifacts + SkipFirewallBackup = [bool]$script:SkipFirewallBackup + } + + if ($selectedMode -eq 'PerformanceTune') { + $optionParameters.PerformanceProfile = $selectedPerformanceProfile + } + + $options = Read-NetCleanOption @optionParameters + + if (-not $script:Force) { + if (-not (Read-YesNo -Prompt 'Proceed with the selected NetClean operation?' -DefaultNo $true)) { + Write-Information 'Operation cancelled.' -InformationAction Continue + return + } + } + + if ($script:CreateLog -or $selectedMode -ne 'Menu') { + Start-NetCleanLog -Directory $script:LogPath + } + + if ($selectedMode -eq 'PerformanceTune' -and $selectedPerformanceProfile) { + Write-NetCleanLog -Level INFO -Message ("NetClean starting. Mode={0} DryRun={1} PerformanceProfile={2}" -f $selectedMode, $options.DryRun, $selectedPerformanceProfile) + } + else { + Write-NetCleanLog -Level INFO -Message ("NetClean starting. Mode={0} DryRun={1}" -f $selectedMode, $options.DryRun) + } + + if (-not $options.DryRun -and -not (Test-Path -LiteralPath $script:BackupPath)) { + New-Item -Path $script:BackupPath -ItemType Directory -Force | Out-Null + } + + if ($selectedMode -eq 'Preview') { + $ctx = Invoke-NetCleanPhase1Detect + $ctx = Invoke-NetCleanPhase2Protect ` + -Context $ctx ` + -BackupPath $script:BackupPath ` + -DryRun:$true ` + -SkipFirewallBackup:$options.SkipFirewallBackup + + Show-PreviewSummary -Result $ctx + return + } + + $workflowParameters = @{ + Mode = $selectedMode + BackupPath = $script:BackupPath + DryRun = [bool]$options.DryRun + SkipWifi = [bool]$options.SkipWifi + SkipDnsFlush = [bool]$options.SkipDnsFlush + SkipEventLogs = [bool]$options.SkipEventLogs + SkipUserArtifacts = [bool]$options.SkipUserArtifacts + SkipFirewallBackup = [bool]$options.SkipFirewallBackup + } + + if ($selectedMode -eq 'PerformanceTune') { + $workflowParameters.PerformanceProfile = $options.PerformanceProfile + } + + $result = Invoke-NetCleanWorkflow @workflowParameters + + Show-NetCleanSummary -Result $result -SelectedMode $selectedMode + + $postRunAction = Read-PostRunAction + Invoke-PostRunAction -Action $postRunAction -DryRunMode:$options.DryRun +} + +if (-not $script:NetCleanTestMode -and $MyInvocation.InvocationName -ne '.') { + Invoke-NetCleanLauncher +} diff --git a/tests/Functional/NetClean.Launcher.Functional.Tests.ps1 b/tests/Functional/NetClean.Launcher.Functional.Tests.ps1 new file mode 100644 index 0000000..f458cbe --- /dev/null +++ b/tests/Functional/NetClean.Launcher.Functional.Tests.ps1 @@ -0,0 +1,768 @@ +Describe 'NetClean launcher functional tests' { + + BeforeAll { + $moduleManifest = Join-Path $PSScriptRoot '..\..\NetClean.psd1' + $scriptPath = Join-Path $PSScriptRoot '..\..\NetClean.ps1' + + if (-not (Test-Path -LiteralPath $moduleManifest)) { + throw "Module manifest not found: $moduleManifest" + } + + if (-not (Test-Path -LiteralPath $scriptPath)) { + throw "Launcher script not found: $scriptPath" + } + + Remove-Module NetClean -ErrorAction SilentlyContinue + Import-Module $moduleManifest -Force + $script:NetCleanTestMode = $true + . $scriptPath + } + + BeforeEach { + Mock Write-Host {} + Mock Write-Output {} + $script:Force = $false + $script:RebootNow = $false + $script:SummaryListLimit = 20 + } + + Context 'Read-NetCleanOption behavior' { + + It 'Preview mode forces DryRun even when DryRun not specified' { + + $result = Read-NetCleanOption -SelectedMode Preview + + $result.SelectedMode | Should -Be 'Preview' + $result.DryRun | Should -BeTrue + } + + It 'explicit DryRun switch is preserved' { + + $result = Read-NetCleanOption -SelectedMode SafeConferencePrep -DryRun + + $result.SelectedMode | Should -Be 'SafeConferencePrep' + $result.DryRun | Should -BeTrue + } + + It 'returns Skip switches when provided' { + + $result = Read-NetCleanOption ` + -SelectedMode SafeConferencePrep ` + -SkipWifi ` + -SkipDnsFlush ` + -SkipEventLogs ` + -SkipUserArtifacts + + $result.SkipWifi | Should -BeTrue + $result.SkipDnsFlush | Should -BeTrue + $result.SkipEventLogs | Should -BeTrue + $result.SkipUserArtifacts | Should -BeTrue + } + + It 'throws when PerformanceTune mode lacks PerformanceProfile' { + + { Read-NetCleanOption -SelectedMode PerformanceTune } | Should -Throw + } + + It 'accepts valid PerformanceProfile when provided' { + + $result = Read-NetCleanOption ` + -SelectedMode PerformanceTune ` + -PerformanceProfile Optimal + + $result.PerformanceProfile | Should -Be 'Optimal' + } + + } + + Context 'Launcher workflow invocation' { + + BeforeEach { + + Mock Invoke-NetCleanWorkflow { + [pscustomobject]@{ + Phase = 'Verify' + Verify = [pscustomobject]@{ + Passed = $true + } + } + } + + Mock Start-NetCleanLog {} + Mock Write-NetCleanLog {} + + } + + It 'calls workflow when script executes Preview mode' { + + $options = Read-NetCleanOption -SelectedMode Preview + + Invoke-NetCleanWorkflow ` + -Mode $options.SelectedMode ` + -DryRun:$options.DryRun ` + -BackupPath "$TestDrive" + + Should -Invoke Invoke-NetCleanWorkflow -Times 1 -ParameterFilter { + $Mode -eq 'Preview' + } + + } + + It 'passes Skip switches into workflow' { + + $options = Read-NetCleanOption ` + -SelectedMode SafeConferencePrep ` + -SkipWifi ` + -SkipDnsFlush + + Invoke-NetCleanWorkflow ` + -Mode $options.SelectedMode ` + -SkipWifi:$options.SkipWifi ` + -SkipDnsFlush:$options.SkipDnsFlush ` + -BackupPath "$TestDrive" + + Should -Invoke Invoke-NetCleanWorkflow -Times 1 -ParameterFilter { + $SkipWifi -and $SkipDnsFlush + } + + } + + It 'passes PerformanceProfile when using PerformanceTune mode' { + + $options = Read-NetCleanOption ` + -SelectedMode PerformanceTune ` + -PerformanceProfile Gaming + + Invoke-NetCleanWorkflow ` + -Mode $options.SelectedMode ` + -PerformanceProfile $options.PerformanceProfile ` + -BackupPath "$TestDrive" + + Should -Invoke Invoke-NetCleanWorkflow -Times 1 -ParameterFilter { + $Mode -eq 'PerformanceTune' -and + $PerformanceProfile -eq 'Gaming' + } + + } + + } + + Context 'Logging initialization' { + + It 'initializes logging before workflow execution' { + + Mock Start-NetCleanLog {} + Mock Invoke-NetCleanWorkflow {} + + Start-NetCleanLog -Directory "$TestDrive" + + Invoke-NetCleanWorkflow -Mode Preview -BackupPath "$TestDrive" -DryRun + + Should -Invoke Start-NetCleanLog -Times 1 + Should -Invoke Invoke-NetCleanWorkflow -Times 1 + } + + } + + Context 'Interactive helper behavior' { + + BeforeEach { + $script:messages = [System.Collections.Generic.List[string]]::new() + Mock Write-Information { + if ($null -ne $MessageData) { + [void]$script:messages.Add([string]$MessageData) + } + } + } + + It 'truncates long lists and reports empty lists clearly' { + Show-TruncatedList -Items @('one', 'two', 'three') -Heading 'Values' -Limit 2 + Show-TruncatedList -Items @() -Heading 'Empty' + + $script:messages | Should -Contain 'Values' + $script:messages | Should -Contain ' - one' + $script:messages | Should -Contain ' - two' + $script:messages | Should -Contain ' - ...and 1 more' + $script:messages | Should -Contain ' - (none)' + } + + It 'uses the configured list limit when no limit is supplied' { + $script:SummaryListLimit = 1 + + Show-TruncatedList -Items @('one', 'two') -Heading 'Configured' + + $script:messages | Should -Contain ' - one' + $script:messages | Should -Contain ' - ...and 1 more' + $script:messages | Should -Not -Contain ' - two' + } + + It 'accepts confirmation automatically only when Force is enabled' { + $script:Force = $true + Mock Read-Host { throw 'Read-Host must not be called in Force mode' } + + Read-YesNo -Prompt 'Proceed?' | Should -BeTrue + Should -Invoke Read-Host -Times 0 + } + + It 'honors blank defaults and explicit yes or no answers' { + Mock Read-Host { '' } + Read-YesNo -Prompt 'Default no?' -DefaultNo $true | Should -BeFalse + Read-YesNo -Prompt 'Default yes?' -DefaultNo $false | Should -BeTrue + + Mock Read-Host { 'yes' } + Read-YesNo -Prompt 'Proceed?' | Should -BeTrue + + Mock Read-Host { 'no' } + Read-YesNo -Prompt 'Proceed?' | Should -BeFalse + } + + It 'reprompts after an invalid yes or no answer' { + $script:readResponses = [System.Collections.Generic.Queue[string]]::new() + $script:readResponses.Enqueue('maybe') + $script:readResponses.Enqueue('y') + Mock Read-Host { $script:readResponses.Dequeue() } + + Read-YesNo -Prompt 'Proceed?' | Should -BeTrue + Should -Invoke Read-Host -Times 2 + } + + It 'maps menu choices and reprompts invalid selections' { + $script:readResponses = [System.Collections.Generic.Queue[string]]::new() + $script:readResponses.Enqueue('invalid') + $script:readResponses.Enqueue('4') + Mock Read-Host { $script:readResponses.Dequeue() } + Mock Show-NetCleanMenu {} + + Read-NetCleanMenuSelection | Should -Be 'Exit' + Should -Invoke Read-Host -Times 2 + Should -Invoke Show-NetCleanMenu -Times 2 + $script:messages | Should -Contain 'Invalid selection. Please choose 1 through 4.' + } + + It 'maps every valid menu choice' -ForEach @( + @{ Choice = '1'; Expected = 'Preview' } + @{ Choice = '2'; Expected = 'SafeConferencePrep' } + @{ Choice = '3'; Expected = 'AdvancedRepair' } + @{ Choice = '4'; Expected = 'Exit' } + ) { + Mock Read-Host { $Choice } + Mock Show-NetCleanMenu {} + + Read-NetCleanMenuSelection | Should -Be $Expected + } + + It 'renders the banner and menu without relying on host-only output' { + Show-NetCleanMenu + + $script:messages | Should -Contain ' NetClean - Conference / CTF Prep Tool' + $script:messages | Should -Contain '1. Preview only' + $script:messages | Should -Contain '4. Exit' + $script:messages | Should -Not -Contain '4. Performance tuning' + } + + It 'explains each non-default operating mode' -ForEach @( + @{ Mode = 'Preview'; Expected = 'You selected: Preview' } + @{ Mode = 'AdvancedRepair'; Expected = 'You selected: Advanced repair' } + @{ Mode = 'PerformanceTune'; Expected = 'You selected: Performance tuning' } + ) { + Show-ModeExplanation -SelectedMode $Mode + + $script:messages | Should -Contain $Expected + } + } + + Context 'Detailed summary behavior' { + + BeforeEach { + $script:messages = [System.Collections.Generic.List[string]]::new() + Mock Write-Information { + if ($null -ne $MessageData) { + [void]$script:messages.Add([string]$MessageData) + } + } + Mock Get-NetCleanLogFile { 'C:\ProgramData\NetClean\Logs\netclean.log' } + } + + It 'renders all phase summaries, detailed artifacts, verification gaps, paths, and timings' { + $result = [pscustomobject]@{ + ManagementState = [pscustomobject]@{ IsManaged = $false; JoinType = 'Unmanaged' } + Summary = [pscustomobject]@{ + ProtectedVendorsCount = 2 + ProtectedInterfaceGuidCount = 1 + CandidateArtifactCount = 4 + SanitizableArtifactCount = 3 + } + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{ + ProtectedRegistryPathCount = 5 + WiFiBackupCount = 2 + ProtectedRegistryBackupCount = 2 + } + Manifest = [pscustomobject]@{ + WiFiExports = @('PROFILE:Home', 'PROFILE:Office', 'C:\backup\Home.xml') + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + Clean = [pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = 1 + RegistryArtifactsRemoved = 1 + EventLogsTouched = 2 + UserArtifactsTouched = 3 + AdaptersConfigured = 2 + AdaptersSkipped = 1 + AdapterFailures = 0 + PreferIPv4 = $true + AdvancedRepairActions = 0 + PerformanceTuningActions = 0 + } + AdapterConfiguration = [pscustomobject]@{ + Provider = 'Quad9 Secure' + DnsServers = @('9.9.9.9', '2620:fe::fe') + } + WiFi = [pscustomobject]@{ Profiles = @('Home') } + RegistryArtifacts = [pscustomobject]@{ + Results = @( + [pscustomobject]@{ Removed = $true; RegistryPath = 'HKLM\SOFTWARE\History' } + [pscustomobject]@{ Removed = $false; RegistryPath = 'HKLM\SOFTWARE\Protected' } + ) + } + EventLogs = @( + [pscustomobject]@{ Name = 'WLAN'; LogName = $null } + [pscustomobject]@{ Name = $null; LogName = 'NetworkProfile' } + ) + } + Verify = [pscustomobject]@{ + Summary = [pscustomobject]@{ + Passed = $false + MissingVendorsCount = 1 + MissingGuidCount = 1 + MissingServiceCount = 1 + } + VendorComparison = [pscustomobject]@{ Missing = @('Contoso Security') } + } + BackupPath = 'C:\backup' + Timings = [pscustomobject]@{ + Detect = [pscustomobject]@{ Duration = [timespan]::FromSeconds(2) } + Empty = [pscustomobject]@{ Duration = $null } + } + } + + Show-NetCleanSummary -Result $result -SelectedMode SafeConferencePrep + + $script:messages | Should -Contain 'No organization management was detected.' + $script:messages | Should -Contain 'Phase 1 - Detect' + $script:messages | Should -Contain 'Phase 2 - Protect' + $script:messages | Should -Contain 'Phase 3 - Clean' + $script:messages | Should -Contain 'Phase 4 - Verify' + $script:messages | Should -Contain ' Missing vendor names: Contoso Security' + $script:messages | Should -Contain 'Network list backup: C:\backup\NetworkList.reg' + $script:messages | Should -Contain 'Registry keys removed: 1' + $script:messages | Should -Contain 'Event logs touched: 2' + $script:messages | Should -Contain 'Log File: C:\ProgramData\NetClean\Logs\netclean.log' + $script:messages | Should -Contain 'Phase runtimes' + } + + It 'reports no remaining Wi-Fi profiles when every discovered profile was removed' { + $result = [pscustomobject]@{ + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{ + ProtectedRegistryPathCount = 0 + WiFiBackupCount = 1 + ProtectedRegistryBackupCount = 0 + } + Manifest = [pscustomobject]@{ + WiFiExports = @('PROFILE:Home') + NetworkListBackup = $null + } + } + Clean = [pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = 1 + RegistryArtifactsRemoved = 0 + EventLogsTouched = 0 + UserArtifactsTouched = 0 + AdvancedRepairActions = 0 + PerformanceTuningActions = 0 + } + WiFi = [pscustomobject]@{ Profiles = @('Home') } + RegistryArtifacts = $null + EventLogs = @() + } + } + + Show-NetCleanSummary -Result $result -SelectedMode SafeConferencePrep + + $script:messages | Should -Contain 'Wi-Fi Profiles - Remaining After Cleanup' + $script:messages | Should -Contain ' - (none)' + } + + It 'renders a complete preview including profiles, registry candidates, paths, and timings' { + $result = [pscustomobject]@{ + ManagementState = [pscustomobject]@{ IsManaged = $false; JoinType = 'Unmanaged' } + Summary = [pscustomobject]@{ + ProtectedVendorsCount = 1 + ProtectedInterfaceGuidCount = 1 + CandidateArtifactCount = 2 + SanitizableArtifactCount = 1 + } + BackupPath = 'C:\backup' + Protect = [pscustomobject]@{ + Manifest = [pscustomobject]@{ + WiFiExports = @('PROFILE:Home', 'PROFILE:Office') + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + SanitizableArtifacts = @( + [pscustomobject]@{ RegistryPath = 'HKLM\SOFTWARE\History' } + [pscustomobject]@{ RegistryPath = $null } + ) + Timings = [pscustomobject]@{ + Detect = [pscustomobject]@{ Duration = [timespan]::FromSeconds(1) } + } + } + + Show-PreviewSummary -Result $result + + $script:messages | Should -Contain 'Preview Summary' + $script:messages | Should -Contain 'No organization management was detected.' + $script:messages | Should -Contain ' - Home' + $script:messages | Should -Contain ' - Office' + $script:messages | Should -Contain ' - HKLM\SOFTWARE\History' + $script:messages | Should -Contain 'Network list backup: C:\backup\NetworkList.reg' + $script:messages | Should -Contain 'Log File: C:\ProgramData\NetClean\Logs\netclean.log' + $script:messages | Should -Contain 'Phase runtimes' + } + } + + Context 'Post-run action behavior' { + + BeforeEach { + Mock Write-NetCleanLog {} + Mock Restart-Computer {} + Mock Stop-Computer {} + } + + It 'selects restart without prompting when RebootNow is set' { + $script:RebootNow = $true + Mock Read-Host { throw 'Read-Host must not be called when RebootNow is set' } + + Read-PostRunAction | Should -Be 'Restart' + Should -Invoke Read-Host -Times 0 + } + + It 'maps every valid interactive post-run choice' -ForEach @( + @{ Choice = 'r'; Expected = 'Restart' } + @{ Choice = 's'; Expected = 'Shutdown' } + @{ Choice = 'n'; Expected = 'None' } + ) { + Mock Read-Host { $Choice } + + Read-PostRunAction | Should -Be $Expected + } + + It 'reprompts after an invalid post-run choice' { + $script:readResponses = [System.Collections.Generic.Queue[string]]::new() + $script:readResponses.Enqueue('later') + $script:readResponses.Enqueue('n') + Mock Read-Host { $script:readResponses.Dequeue() } + + Read-PostRunAction | Should -Be 'None' + Should -Invoke Read-Host -Times 2 + } + + It 'logs dry-run power actions and invokes only explicitly selected live actions' { + Invoke-PostRunAction -Action Restart -DryRunMode + Invoke-PostRunAction -Action Shutdown -DryRunMode + Invoke-PostRunAction -Action None + Invoke-PostRunAction -Action Restart + Invoke-PostRunAction -Action Shutdown + + Should -Invoke Restart-Computer -Times 1 -ParameterFilter { $Force } + Should -Invoke Stop-Computer -Times 1 -ParameterFilter { $Force } + Should -Invoke Write-NetCleanLog -Times 1 -ParameterFilter { $Message -eq 'DRYRUN: Restart-Computer -Force' } + Should -Invoke Write-NetCleanLog -Times 1 -ParameterFilter { $Message -eq 'DRYRUN: Stop-Computer -Force' } + Should -Invoke Write-NetCleanLog -Times 1 -ParameterFilter { $Message -eq 'No post-run power action selected.' } + } + } + + Context 'Post-run power handling' { + + BeforeEach { + $script:Mode = 'SafeConferencePrep' + $script:DryRun = $true + $script:Force = $true + $script:CreateLog = $false + $script:BackupPath = $TestDrive + $script:LogPath = $TestDrive + $script:SkipWifi = $false + $script:SkipDnsFlush = $false + $script:SkipEventLogs = $false + $script:SkipUserArtifacts = $false + $script:SkipFirewallBackup = $false + $script:PerformanceProfile = 'Default' + $script:RebootNow = $false + + Mock Test-NetCleanAdministrator {} + Mock Read-NetCleanMenuSelection { 'SafeConferencePrep' } + Mock Read-YesNo { $true } + Mock Show-ModeExplanation {} + Mock Read-NetCleanOption { + [pscustomobject]@{ + SelectedMode = 'SafeConferencePrep' + DryRun = $true + SkipWifi = $false + SkipDnsFlush = $false + SkipEventLogs = $false + SkipUserArtifacts = $false + SkipFirewallBackup = $false + PerformanceProfile = $null + } + } + Mock Start-NetCleanLog {} + Mock Write-NetCleanLog {} + Mock Invoke-NetCleanWorkflow { [pscustomobject]@{ Phase = 'Verify' } } + Mock Show-NetCleanSummary {} + Mock Read-PostRunAction { 'None' } + Mock Invoke-PostRunAction {} + } + + It 'uses only the post-run action path' { + Invoke-NetCleanLauncher + + Should -Invoke Invoke-PostRunAction -Times 1 + Get-Command Read-NetCleanPowerSelection -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + Get-Command Invoke-NetCleanPowerAction -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + } + + It 'returns immediately when the menu selection is Exit' { + $script:Mode = 'Menu' + Mock Read-NetCleanMenuSelection { 'Exit' } + + Invoke-NetCleanLauncher + + Should -Invoke Test-NetCleanAdministrator -Times 1 + Should -Invoke Show-ModeExplanation -Times 0 + Should -Invoke Invoke-NetCleanWorkflow -Times 0 + } + + It 'returns before logging and workflow execution when confirmation is declined' { + $script:Force = $false + Mock Read-YesNo { $false } + + Invoke-NetCleanLauncher + + Should -Invoke Read-YesNo -Times 1 + Should -Invoke Start-NetCleanLog -Times 0 + Should -Invoke Invoke-NetCleanWorkflow -Times 0 + } + + It 'routes Preview through detect and protect without invoking cleanup workflow or power actions' { + $script:Mode = 'Preview' + Mock Read-NetCleanOption { + [pscustomobject]@{ + SelectedMode = 'Preview' + DryRun = $true + SkipWifi = $false + SkipDnsFlush = $false + SkipEventLogs = $false + SkipUserArtifacts = $false + SkipFirewallBackup = $true + PerformanceProfile = $null + } + } + Mock Invoke-NetCleanPhase1Detect { [pscustomobject]@{ Phase = 'Detect' } } + Mock Invoke-NetCleanPhase2Protect { [pscustomobject]@{ Phase = 'Protect' } } + Mock Show-PreviewSummary {} + + Invoke-NetCleanLauncher + + Should -Invoke Invoke-NetCleanPhase1Detect -Times 1 + Should -Invoke Invoke-NetCleanPhase2Protect -Times 1 -ParameterFilter { + $DryRun -and $SkipFirewallBackup -and $BackupPath -eq $TestDrive + } + Should -Invoke Show-PreviewSummary -Times 1 + Should -Invoke Invoke-NetCleanWorkflow -Times 0 + Should -Invoke Invoke-PostRunAction -Times 0 + } + + It 'creates a missing backup directory for a live safe-cleanup run' { + $script:DryRun = $false + $script:BackupPath = Join-Path $TestDrive 'missing-backup' + Mock Read-NetCleanOption { + [pscustomobject]@{ + SelectedMode = 'SafeConferencePrep' + DryRun = $false + SkipWifi = $false + SkipDnsFlush = $false + SkipEventLogs = $false + SkipUserArtifacts = $false + SkipFirewallBackup = $false + PerformanceProfile = $null + } + } + Mock Test-Path { $false } + Mock New-Item {} + + Invoke-NetCleanLauncher + + Should -Invoke New-Item -Times 1 -ParameterFilter { + $Path -eq $script:BackupPath -and $ItemType -eq 'Directory' -and $Force + } + Should -Invoke Invoke-NetCleanWorkflow -Times 1 -ParameterFilter { + $Mode -eq 'SafeConferencePrep' -and -not $DryRun + } + Should -Invoke Invoke-PostRunAction -Times 1 -ParameterFilter { -not $DryRunMode } + } + + } + + Context 'Console output behavior' { + + It 'discloses adapter, DNS, IPv6, and restart changes before safe cleanup' { + $script:messages = [System.Collections.Generic.List[string]]::new() + Mock Write-Information { + if ($null -ne $MessageData) { + [void]$script:messages.Add([string]$MessageData) + } + } + + Show-ModeExplanation -SelectedMode SafeConferencePrep + + $script:messages | Should -Contain ' - reset eligible adapters to IPv4 DHCP' + $script:messages | Should -Contain ' - set Quad9 Secure DNS for IPv4 and IPv6' + $script:messages | Should -Contain ' - keep IPv6 enabled and prefer IPv4 after restart' + $script:messages | Should -Contain ' - require a restart for the IPv4 preference to take full effect' + } + + It 'explains when organization-managed network configuration is preserved' { + $script:messages = [System.Collections.Generic.List[string]]::new() + Mock Write-Information { + if ($null -ne $MessageData) { + [void]$script:messages.Add([string]$MessageData) + } + } + Mock Get-NetCleanLogFile { $null } + + Show-NetCleanSummary -SelectedMode SafeConferencePrep -Result ([pscustomobject]@{ + ManagementState = [pscustomobject]@{ + IsManaged = $true + JoinType = 'MicrosoftEntraJoined' + } + }) + + $script:messages | Should -Contain 'Device management: MicrosoftEntraJoined' + $script:messages | Should -Contain 'Organization-managed network configuration will be preserved.' + } + + It 'distinguishes Workplace registration from organization management' { + $script:messages = [System.Collections.Generic.List[string]]::new() + Mock Write-Information { + if ($null -ne $MessageData) { + [void]$script:messages.Add([string]$MessageData) + } + } + Mock Get-NetCleanLogFile { $null } + + Show-PreviewSummary -Result ([pscustomobject]@{ + ManagementState = [pscustomobject]@{ + IsManaged = $false + JoinType = 'WorkplaceRegistered' + WorkplaceJoined = $true + } + Summary = [pscustomobject]@{ + ProtectedVendorsCount = 0 + ProtectedInterfaceGuidCount = 0 + CandidateArtifactCount = 0 + SanitizableArtifactCount = 0 + } + BackupPath = 'C:\backup' + }) + + $script:messages | Should -Contain 'Device registration: WorkplaceRegistered' + $script:messages | Should -Contain 'A work or school account is registered for SSO and will be preserved; no organization management was detected.' + $script:messages | Should -Not -Contain 'Organization-managed network configuration will be preserved.' + } + + It 'explains adapter reset, Quad9 DNS, and IPv4 preference results' { + $script:messages = [System.Collections.Generic.List[string]]::new() + Mock Write-Information { + if ($null -ne $MessageData) { + [void]$script:messages.Add([string]$MessageData) + } + } + Mock Get-NetCleanLogFile { $null } + + Show-NetCleanSummary -SelectedMode SafeConferencePrep -Result ([pscustomobject]@{ + Clean = [pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = 0 + RegistryArtifactsRemoved = 0 + EventLogsTouched = 0 + UserArtifactsTouched = 0 + AdaptersConfigured = 2 + AdaptersSkipped = 1 + AdapterFailures = 0 + PreferIPv4 = $true + AdapterRestartRequired = $true + AdvancedRepairActions = 0 + PerformanceTuningActions = 0 + } + AdapterConfiguration = [pscustomobject]@{ + Provider = 'Quad9 Secure' + DnsServers = @( + '9.9.9.9', + '149.112.112.112', + '2620:fe::fe', + '2620:fe::9' + ) + } + WiFi = $null + RegistryArtifacts = $null + EventLogs = @() + } + }) + + $script:messages | Should -Contain ' Adapters reset to IPv4 DHCP: 2' + $script:messages | Should -Contain ' Adapters preserved: 1' + $script:messages | Should -Contain ' Adapter reset failures: 0' + $script:messages | Should -Contain ' DNS provider: Quad9 Secure' + $script:messages | Should -Contain ' DNS servers: 9.9.9.9, 149.112.112.112, 2620:fe::fe, 2620:fe::9' + $script:messages | Should -Contain ' IPv6 remains enabled; IPv4 will be preferred after restart.' + } + + It 'prints verification success message when workflow passes' { + + Mock Invoke-NetCleanWorkflow { + [pscustomobject]@{ + Phase = 'Verify' + Verify = [pscustomobject]@{ + Passed = $true + } + } + } + + $result = Invoke-NetCleanWorkflow -Mode Preview -BackupPath "$TestDrive" -DryRun + + $result.Verify.Passed | Should -BeTrue + } + + It 'detects verification failure from workflow output' { + + Mock Invoke-NetCleanWorkflow { + [pscustomobject]@{ + Phase = 'Verify' + Verify = [pscustomobject]@{ + Passed = $false + } + } + } + + $result = Invoke-NetCleanWorkflow -Mode Preview -BackupPath "$TestDrive" -DryRun + + $result.Verify.Passed | Should -BeFalse + } + + } + +} diff --git a/tests/Functional/NetClean.Workflow.Functional.Tests.ps1 b/tests/Functional/NetClean.Workflow.Functional.Tests.ps1 new file mode 100644 index 0000000..b27ed67 --- /dev/null +++ b/tests/Functional/NetClean.Workflow.Functional.Tests.ps1 @@ -0,0 +1,793 @@ +$manifestPath = Join-Path $PSScriptRoot '..\..\NetClean.psd1' + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" +} + +Remove-Module NetClean -ErrorAction SilentlyContinue +Import-Module $manifestPath -Force + +Describe 'NetClean workflow functional tests' { + + InModuleScope 'NetClean' { + + BeforeEach { + $script:LogFile = $null + Mock Write-NetCleanLog {} + } + + Context 'Preview mode' { + + It 'runs Detect and Protect only, then returns the Protect context with timings' { + Mock Invoke-NetCleanPhase1Detect { + [pscustomobject]@{ + Phase = 'Detect' + Inventory = @() + ProtectedRegistryPaths = @() + SanitizableArtifacts = @() + Summary = [pscustomobject]@{ + ProtectedVendorsCount = 0 + ProtectedInterfaceGuidCount = 0 + CandidateArtifactCount = 0 + SanitizableArtifactCount = 0 + } + } + } + + Mock Invoke-NetCleanPhase2Protect { + param($Context, $BackupPath, $DryRun, $SkipFirewallBackup) + $null = $Context, $DryRun, $SkipFirewallBackup + + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = $BackupPath + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{ + ProtectedRegistryPathCount = 0 + WiFiBackupCount = 0 + ProtectedRegistryBackupCount = 0 + } + Manifest = [pscustomobject]@{ + WiFiExports = @() + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + } + } + + Mock Invoke-NetCleanPhase3Clean {} + Mock Invoke-NetCleanPhase4Verify {} + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + + $result = Invoke-NetCleanWorkflow -Mode Preview -BackupPath 'C:\backup' -DryRun + + $result.Phase | Should -Be 'Protect' + $result.BackupPath | Should -Be 'C:\backup' + $result.Timings | Should -Not -BeNullOrEmpty + + Should -Invoke Invoke-NetCleanPhase1Detect -Times 1 + Should -Invoke Invoke-NetCleanPhase2Protect -Times 1 + Should -Invoke Invoke-NetCleanPhase3Clean -Times 0 + Should -Invoke Invoke-NetCleanPhase4Verify -Times 0 + } + + It 'restores profile names from planned and exported Wi-Fi manifest entries' { + Mock Invoke-NetCleanPhase1Detect { [pscustomobject]@{ Phase = 'Detect' } } + Mock Invoke-NetCleanPhase2Protect { + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = $BackupPath + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ + WiFiExports = @( + 'PROFILE:HomeSSID' + 'C:\backup\Wi-Fi-OfficeSSID.xml' + 42 + ) + } + } + } + } + Mock Get-WiFiProfileName { throw 'Manifest entries should be sufficient' } + Mock Get-NetworkListProfileName { @('Private network') } + + $result = Invoke-NetCleanWorkflow -Mode Preview -BackupPath 'C:\backup' -DryRun + + @($result.Protect.Summary.WiFiProfilesFound) | Should -Be @('HomeSSID', 'OfficeSSID') + $result.Protect.Summary.WiFiProfilesFoundCount | Should -Be 2 + @($result.Protect.Summary.NetworkProfilesFound) | Should -Be @('Private network') + Should -Invoke Get-WiFiProfileName -Times 0 + } + + It 'returns the protected context when cache population fails' { + Mock Invoke-NetCleanPhase1Detect { [pscustomobject]@{ Phase = 'Detect' } } + Mock Invoke-NetCleanPhase2Protect { + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = $BackupPath + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ WiFiExports = @() } + } + } + } + Mock Get-WiFiProfileName { throw 'profile enumeration failed' } + + $result = Invoke-NetCleanWorkflow -Mode Preview -BackupPath 'C:\backup' -DryRun + + $result.Phase | Should -Be 'Protect' + $result.Timings.Protect | Should -Not -BeNullOrEmpty + } + } + + Context 'SafeConferencePrep mode' { + + It 'runs all four phases and returns a Verify context' { + Mock Invoke-NetCleanPhase1Detect { + [pscustomobject]@{ + Phase = 'Detect' + Inventory = @() + ProtectedRegistryPaths = @() + SanitizableArtifacts = @() + Summary = [pscustomobject]@{} + } + } + + Mock Invoke-NetCleanPhase2Protect { + param($Context, $BackupPath, $DryRun, $SkipFirewallBackup) + $null = $Context, $DryRun, $SkipFirewallBackup + + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = $BackupPath + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ + WiFiExports = @() + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + } + } + + Mock Invoke-NetCleanPhase3Clean { + [pscustomobject]@{ + Phase = 'Clean' + BackupPath = 'C:\backup' + Clean = [pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = 1 + RegistryArtifactsRemoved = 1 + EventLogsTouched = 1 + UserArtifactsTouched = 1 + AdvancedRepairActions = 0 + PerformanceTuningActions = 0 + } + WiFi = [pscustomobject]@{ + Profiles = @('ssid1') + } + RegistryArtifacts = [pscustomobject]@{ + Results = @( + [pscustomobject]@{ + RegistryPath = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + Removed = $true + } + ) + } + EventLogs = @( + [pscustomobject]@{ + LogName = 'Microsoft-Windows-WLAN-AutoConfig/Operational' + } + ) + UserArtifacts = @( + [pscustomobject]@{ + Path = 'HKCU:\Software\Test' + Removed = $true + } + ) + } + } + } + + Mock Invoke-NetCleanPhase4Verify { + [pscustomobject]@{ + Phase = 'Verify' + BackupPath = 'C:\backup' + Verify = [pscustomobject]@{ + Passed = $true + Summary = [pscustomobject]@{ + Passed = $true + MissingVendorsCount = 0 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ + Missing = @() + } + GuidComparison = [pscustomobject]@{ + Missing = @() + } + ServiceComparison = [pscustomobject]@{ + Missing = @() + } + } + } + } + + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + + $result = Invoke-NetCleanWorkflow -Mode SafeConferencePrep -BackupPath 'C:\backup' -DryRun + + $result.Phase | Should -Be 'Verify' + $result.Verify.Summary.Passed | Should -BeTrue + $result.Timings | Should -Not -BeNullOrEmpty + + Should -Invoke Invoke-NetCleanPhase1Detect -Times 1 + Should -Invoke Invoke-NetCleanPhase2Protect -Times 1 + Should -Invoke Invoke-NetCleanPhase3Clean -Times 1 + Should -Invoke Invoke-NetCleanPhase4Verify -Times 1 + } + + It 'passes skip switches through to Phase 3' { + Mock Invoke-NetCleanPhase1Detect { + [pscustomobject]@{ + Phase = 'Detect' + Inventory = @() + ProtectedRegistryPaths = @() + SanitizableArtifacts = @() + Summary = [pscustomobject]@{} + } + } + + Mock Invoke-NetCleanPhase2Protect { + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = 'C:\backup' + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ + WiFiExports = @() + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + } + } + + Mock Invoke-NetCleanPhase3Clean { + [pscustomobject]@{ + Phase = 'Clean' + BackupPath = 'C:\backup' + Clean = [pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = 0 + RegistryArtifactsRemoved = 0 + EventLogsTouched = 0 + UserArtifactsTouched = 0 + AdvancedRepairActions = 0 + PerformanceTuningActions = 0 + } + WiFi = [pscustomobject]@{ Profiles = @() } + RegistryArtifacts = [pscustomobject]@{ Results = @() } + EventLogs = @() + UserArtifacts = @() + } + } + } + + Mock Invoke-NetCleanPhase4Verify { + [pscustomobject]@{ + Phase = 'Verify' + BackupPath = 'C:\backup' + Verify = [pscustomobject]@{ + Passed = $true + Summary = [pscustomobject]@{ + Passed = $true + MissingVendorsCount = 0 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ Missing = @() } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison= [pscustomobject]@{ Missing = @() } + } + } + } + + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + + $null = Invoke-NetCleanWorkflow ` + -Mode SafeConferencePrep ` + -BackupPath 'C:\backup' ` + -DryRun ` + -SkipWifi ` + -SkipDnsFlush ` + -SkipEventLogs ` + -SkipUserArtifacts + + Should -Invoke Invoke-NetCleanPhase3Clean -Times 1 -ParameterFilter { + $Mode -eq 'SafeConferencePrep' -and + $DryRun -and + $SkipWifi -and + $SkipDnsFlush -and + $SkipEventLogs -and + $SkipUserArtifacts + } + } + } + + Context 'AdvancedRepair mode' { + + It 'passes AdvancedRepair mode through to Phase 3' { + Mock Invoke-NetCleanPhase1Detect { + [pscustomobject]@{ + Phase = 'Detect' + Inventory = @() + ProtectedRegistryPaths = @() + SanitizableArtifacts = @() + Summary = [pscustomobject]@{} + } + } + + Mock Invoke-NetCleanPhase2Protect { + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = 'C:\backup' + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ + WiFiExports = @() + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + } + } + + Mock Invoke-NetCleanPhase3Clean { + [pscustomobject]@{ + Phase = 'Clean' + BackupPath = 'C:\backup' + Clean = [pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = 0 + RegistryArtifactsRemoved = 0 + EventLogsTouched = 0 + UserArtifactsTouched = 0 + AdvancedRepairActions = 1 + PerformanceTuningActions = 0 + } + WiFi = [pscustomobject]@{ Profiles = @() } + RegistryArtifacts = [pscustomobject]@{ Results = @() } + EventLogs = @() + UserArtifacts = @() + } + } + } + + Mock Invoke-NetCleanPhase4Verify { + [pscustomobject]@{ + Phase = 'Verify' + BackupPath = 'C:\backup' + Verify = [pscustomobject]@{ + Passed = $true + Summary = [pscustomobject]@{ + Passed = $true + MissingVendorsCount = 0 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ Missing = @() } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison= [pscustomobject]@{ Missing = @() } + } + } + } + + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + + $result = Invoke-NetCleanWorkflow -Mode AdvancedRepair -BackupPath 'C:\backup' -DryRun + + $result.Phase | Should -Be 'Verify' + Should -Invoke Invoke-NetCleanPhase3Clean -Times 1 -ParameterFilter { $Mode -eq 'AdvancedRepair' } + } + } + + Context 'PerformanceTune mode' { + + It 'throws when PerformanceProfile is not provided' { + Mock Invoke-NetCleanPhase1Detect { + [pscustomobject]@{ + Phase = 'Detect' + Inventory = @() + ProtectedRegistryPaths = @() + SanitizableArtifacts = @() + Summary = [pscustomobject]@{} + } + } + + Mock Invoke-NetCleanPhase2Protect { + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = 'C:\backup' + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ + WiFiExports = @() + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + } + } + + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + + { Invoke-NetCleanWorkflow -Mode PerformanceTune -BackupPath 'C:\backup' -DryRun } | Should -Throw + } + + It 'passes PerformanceProfile through to Phase 3' { + Mock Invoke-NetCleanPhase1Detect { + [pscustomobject]@{ + Phase = 'Detect' + Inventory = @() + ProtectedRegistryPaths = @() + SanitizableArtifacts = @() + Summary = [pscustomobject]@{} + } + } + + Mock Invoke-NetCleanPhase2Protect { + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = 'C:\backup' + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ + WiFiExports = @() + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + } + } + + Mock Invoke-NetCleanPhase3Clean { + [pscustomobject]@{ + Phase = 'Clean' + BackupPath = 'C:\backup' + PerformanceProfile = 'Optimal' + Clean = [pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = 0 + RegistryArtifactsRemoved = 0 + EventLogsTouched = 0 + UserArtifactsTouched = 0 + AdvancedRepairActions = 0 + PerformanceTuningActions = 1 + } + WiFi = [pscustomobject]@{ Profiles = @() } + RegistryArtifacts = [pscustomobject]@{ Results = @() } + EventLogs = @() + UserArtifacts = @() + } + } + } + + Mock Invoke-NetCleanPhase4Verify { + [pscustomobject]@{ + Phase = 'Verify' + BackupPath = 'C:\backup' + Verify = [pscustomobject]@{ + Passed = $true + Summary = [pscustomobject]@{ + Passed = $true + MissingVendorsCount = 0 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ Missing = @() } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison= [pscustomobject]@{ Missing = @() } + } + } + } + + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + + $result = Invoke-NetCleanWorkflow -Mode PerformanceTune -BackupPath 'C:\backup' -PerformanceProfile Optimal -DryRun + + $result.Phase | Should -Be 'Verify' + Should -Invoke Invoke-NetCleanPhase3Clean -Times 1 -ParameterFilter { + $Mode -eq 'PerformanceTune' -and + $PerformanceProfile -eq 'Optimal' + } + } + + It 'stores PerformanceProfile on the returned context' { + Mock Invoke-NetCleanPhase1Detect { + [pscustomobject]@{ + Phase = 'Detect' + Inventory = @() + ProtectedRegistryPaths = @() + SanitizableArtifacts = @() + Summary = [pscustomobject]@{} + } + } + + Mock Invoke-NetCleanPhase2Protect { + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = 'C:\backup' + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ + WiFiExports = @() + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + } + } + + Mock Invoke-NetCleanPhase3Clean { + [pscustomobject]@{ + Phase = 'Clean' + BackupPath = 'C:\backup' + PerformanceProfile = 'Gaming' + Clean = [pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = 0 + RegistryArtifactsRemoved = 0 + EventLogsTouched = 0 + UserArtifactsTouched = 0 + AdvancedRepairActions = 0 + PerformanceTuningActions = 1 + } + WiFi = [pscustomobject]@{ Profiles = @() } + RegistryArtifacts = [pscustomobject]@{ Results = @() } + EventLogs = @() + UserArtifacts = @() + } + } + } + + Mock Invoke-NetCleanPhase4Verify { + [pscustomobject]@{ + Phase = 'Verify' + BackupPath = 'C:\backup' + Verify = [pscustomobject]@{ + Passed = $true + Summary = [pscustomobject]@{ + Passed = $true + MissingVendorsCount = 0 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ Missing = @() } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison= [pscustomobject]@{ Missing = @() } + } + } + } + + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + + $result = Invoke-NetCleanWorkflow -Mode PerformanceTune -BackupPath 'C:\backup' -PerformanceProfile Gaming -DryRun + + $result.PerformanceProfile | Should -Be 'Gaming' + } + } + + Context 'Shared workflow behavior' { + + It 'preserves BackupPath across phases even when later phases omit it' { + Mock Invoke-NetCleanPhase1Detect { + [pscustomobject]@{ + Phase = 'Detect' + Inventory = @() + ProtectedRegistryPaths = @() + SanitizableArtifacts = @() + Summary = [pscustomobject]@{} + } + } + + Mock Invoke-NetCleanPhase2Protect { + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = 'C:\backup' + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ + WiFiExports = @() + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + } + } + + Mock Invoke-NetCleanPhase3Clean { + [pscustomobject]@{ + Phase = 'Clean' + Clean = [pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = 0 + RegistryArtifactsRemoved = 0 + EventLogsTouched = 0 + UserArtifactsTouched = 0 + AdvancedRepairActions = 0 + PerformanceTuningActions = 0 + } + WiFi = [pscustomobject]@{ Profiles = @() } + RegistryArtifacts = [pscustomobject]@{ Results = @() } + EventLogs = @() + UserArtifacts = @() + } + } + } + + Mock Invoke-NetCleanPhase4Verify { + [pscustomobject]@{ + Phase = 'Verify' + Verify = [pscustomobject]@{ + Passed = $true + Summary = [pscustomobject]@{ + Passed = $true + MissingVendorsCount = 0 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ Missing = @() } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison= [pscustomobject]@{ Missing = @() } + } + } + } + + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + + $result = Invoke-NetCleanWorkflow -Mode SafeConferencePrep -BackupPath 'C:\backup' -DryRun + + $result.BackupPath | Should -Be 'C:\backup' + } + + It 'adds timings for all phases in non-preview flows' { + Mock Invoke-NetCleanPhase1Detect { + [pscustomobject]@{ + Phase = 'Detect' + Inventory = @() + ProtectedRegistryPaths = @() + SanitizableArtifacts = @() + Summary = [pscustomobject]@{} + } + } + + Mock Invoke-NetCleanPhase2Protect { + [pscustomobject]@{ + Phase = 'Protect' + BackupPath = 'C:\backup' + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ + WiFiExports = @() + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + } + } + + Mock Invoke-NetCleanPhase3Clean { + [pscustomobject]@{ + Phase = 'Clean' + BackupPath = 'C:\backup' + Clean = [pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesRemoved = 0 + RegistryArtifactsRemoved = 0 + EventLogsTouched = 0 + UserArtifactsTouched = 0 + AdvancedRepairActions = 0 + PerformanceTuningActions = 0 + } + WiFi = [pscustomobject]@{ Profiles = @() } + RegistryArtifacts = [pscustomobject]@{ Results = @() } + EventLogs = @() + UserArtifacts = @() + } + } + } + + Mock Invoke-NetCleanPhase4Verify { + [pscustomobject]@{ + Phase = 'Verify' + BackupPath = 'C:\backup' + Verify = [pscustomobject]@{ + Passed = $true + Summary = [pscustomobject]@{ + Passed = $true + MissingVendorsCount = 0 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ Missing = @() } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison= [pscustomobject]@{ Missing = @() } + } + } + } + + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + + $result = Invoke-NetCleanWorkflow -Mode SafeConferencePrep -BackupPath 'C:\backup' -DryRun + + $result.Timings | Should -Not -BeNullOrEmpty + $result.Timings.Detect | Should -Not -BeNullOrEmpty + $result.Timings.Protect | Should -Not -BeNullOrEmpty + $result.Timings.Clean | Should -Not -BeNullOrEmpty + $result.Timings.Verify | Should -Not -BeNullOrEmpty + } + + It 'writes a complete live audit summary from the preserved workflow context' { + Mock Invoke-NetCleanPhase1Detect { [pscustomobject]@{ Phase = 'Detect' } } + Mock Invoke-NetCleanPhase2Protect { + [pscustomobject]@{ + Phase = 'Protect' + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{} + Manifest = [pscustomobject]@{ WiFiExports = @() } + ManifestFile = 'C:\backup\RestoreManifest.json' + } + } + } + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + Mock Invoke-NetCleanPhase3Clean { + $Context | Add-Member -NotePropertyName Phase -NotePropertyValue 'Clean' -Force + $Context | Add-Member -NotePropertyName Clean -NotePropertyValue ([pscustomobject]@{ + WiFi = [pscustomobject]@{ Profiles = @('HomeSSID', 'OfficeSSID') } + RegistryArtifacts = [pscustomobject]@{ + Results = @( + [pscustomobject]@{ RegistryPath = 'HKLM:\Removed'; Removed = $true } + [pscustomobject]@{ RegistryPath = 'HKLM:\Retained'; Removed = $false } + ) + } + EventLogs = @('WLAN', 'NetworkProfile') + UserArtifacts = @( + [pscustomobject]@{ Path = 'HKCU:\Removed'; Removed = $true } + [pscustomobject]@{ Path = 'HKCU:\Retained'; Removed = $false } + ) + }) -Force + $Context + } + Mock Invoke-NetCleanPhase4Verify { + $Context | Add-Member -NotePropertyName Phase -NotePropertyValue 'Verify' -Force + $Context | Add-Member -NotePropertyName Verify -NotePropertyValue ([pscustomobject]@{ + Summary = [pscustomobject]@{ Passed = $true } + VendorComparison = [pscustomobject]@{ Missing = @() } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison = [pscustomobject]@{ Missing = @() } + }) -Force + $Context + } + $script:logMessages = [System.Collections.Generic.List[string]]::new() + Mock Write-NetCleanLog { [void]$script:logMessages.Add($Message) } + + $result = Invoke-NetCleanWorkflow -Mode SafeConferencePrep -BackupPath 'C:\backup' + + $result.Phase | Should -Be 'Verify' + $script:logMessages | Should -Contain 'Final summary: Mode=SafeConferencePrep DryRun=False BackupPath=(none) ManifestFile=C:\backup\RestoreManifest.json' + $script:logMessages | Should -Contain 'Wi-Fi profiles removed/wouldRemove: HomeSSID, OfficeSSID' + $script:logMessages | Should -Contain 'Registry artifacts removed count: 1' + $script:logMessages | Should -Contain 'Registry removed: HKLM:\Removed' + $script:logMessages | Should -Contain 'Event logs touched: 2' + $script:logMessages | Should -Contain 'User artifacts touched count: 1' + $script:logMessages | Should -Contain 'Verification passed: True' + } + } + } +} diff --git a/tests/Integration/NetClean.Module.Integration.Tests.ps1 b/tests/Integration/NetClean.Module.Integration.Tests.ps1 new file mode 100644 index 0000000..b760228 --- /dev/null +++ b/tests/Integration/NetClean.Module.Integration.Tests.ps1 @@ -0,0 +1,67 @@ +$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$script:moduleManifestPath = Join-Path $repoRoot 'NetClean.psd1' + +if (-not (Test-Path -LiteralPath $script:moduleManifestPath)) { + throw "NetClean.psd1 not found at path: $script:moduleManifestPath" +} + +Remove-Module NetClean -ErrorAction SilentlyContinue +Import-Module $script:moduleManifestPath -Force + +Describe 'NetClean module integration tests' { + BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $script:moduleManifestPath = Join-Path $repoRoot 'NetClean.psd1' + } + + It 'validates the module manifest' { + { Test-ModuleManifest $script:moduleManifestPath } | Should -Not -Throw + } + + It 'imports the module manifest without throwing' { + { Import-Module $script:moduleManifestPath -Force } | Should -Not -Throw + } + + It 'exports the expected primary phase functions' { + $module = Get-Module 'NetClean' + $module | Should -Not -BeNullOrEmpty + + $expected = @( + 'Invoke-NetCleanPhase1Detect', + 'Invoke-NetCleanPhase2Protect', + 'Invoke-NetCleanPhase3Clean', + 'Invoke-NetCleanPhase4Verify', + 'Invoke-NetCleanWorkflow', + 'Start-NetCleanLog', + 'Write-NetCleanLog' + ) + + foreach ($name in $expected) { + $module.ExportedFunctions.Keys | Should -Contain $name + } + } + + It 'exports the expected aliases' { + $module = Get-Module 'NetClean' + $module | Should -Not -BeNullOrEmpty + + $module.ExportedAliases.Keys | Should -Contain 'Backup-NetworkList' + $module.ExportedAliases.Keys | Should -Contain 'Backup-ProtectedRegistryKeys' + $module.ExportedAliases.Keys | Should -Contain 'Backup-WiFiProfiles' + } + + InModuleScope 'NetClean' { + + It 'can access internal helper functions within module scope' { + { Get-Command Convert-RegKeyPath -ErrorAction Stop } | Should -Not -Throw + { Get-Command Invoke-ExternalCommandSafe -ErrorAction Stop } | Should -Not -Throw + } + + It 'can access phase implementation functions within module scope' { + { Get-Command Invoke-NetCleanPhase1Detect -ErrorAction Stop } | Should -Not -Throw + { Get-Command Invoke-NetCleanPhase2Protect -ErrorAction Stop } | Should -Not -Throw + { Get-Command Invoke-NetCleanPhase3Clean -ErrorAction Stop } | Should -Not -Throw + { Get-Command Invoke-NetCleanPhase4Verify -ErrorAction Stop } | Should -Not -Throw + } + } +} diff --git a/tests/Integration/NetClean.Script.Integration.Tests.ps1 b/tests/Integration/NetClean.Script.Integration.Tests.ps1 new file mode 100644 index 0000000..dfa5e4a --- /dev/null +++ b/tests/Integration/NetClean.Script.Integration.Tests.ps1 @@ -0,0 +1,54 @@ +Describe 'NetClean script integration tests' { + + BeforeAll { + $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $manifestPath = Join-Path $repoRoot 'NetClean.psd1' + $scriptPath = Join-Path $repoRoot 'NetClean.ps1' + + if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" + } + + if (-not (Test-Path -LiteralPath $scriptPath)) { + throw "NetClean.ps1 not found at path: $scriptPath" + } + + Remove-Module NetClean -ErrorAction SilentlyContinue + Import-Module $manifestPath -Force + } + + BeforeEach { + Mock Import-Module {} + $script:NetCleanTestMode = $true + . $scriptPath + } + + It 'dot-sources the launcher script without throwing' { + { . $scriptPath } | Should -Not -Throw + } + + It 'exposes the expected launcher functions after dot-sourcing' { + Get-Command Invoke-NetCleanLauncher -ErrorAction Stop | Should -Not -BeNullOrEmpty + Get-Command Read-NetCleanMenuSelection -ErrorAction Stop | Should -Not -BeNullOrEmpty + Get-Command Read-YesNo -ErrorAction Stop | Should -Not -BeNullOrEmpty + Get-Command Read-NetCleanOption -ErrorAction Stop | Should -Not -BeNullOrEmpty + } + + It 'Read-NetCleanOption forces DryRun in Preview mode' { + $result = Read-NetCleanOption -SelectedMode Preview + + $result.SelectedMode | Should -Be 'Preview' + $result.DryRun | Should -BeTrue + } + + It 'Read-NetCleanOption requires a PerformanceProfile for PerformanceTune mode' { + { Read-NetCleanOption -SelectedMode PerformanceTune } | Should -Throw + } + + It 'Read-NetCleanOption accepts a valid performance profile' { + $result = Read-NetCleanOption -SelectedMode PerformanceTune -PerformanceProfile Optimal + + $result.SelectedMode | Should -Be 'PerformanceTune' + $result.PerformanceProfile | Should -Be 'Optimal' + } +} diff --git a/tests/Invoke-NetCleanAnalyzer.ps1 b/tests/Invoke-NetCleanAnalyzer.ps1 new file mode 100644 index 0000000..c78d582 --- /dev/null +++ b/tests/Invoke-NetCleanAnalyzer.ps1 @@ -0,0 +1,117 @@ +[CmdletBinding()] +param( + [version]$AnalyzerVersion = [version]'1.25.0' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Test-NetCleanNullReferenceException { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [System.Exception]$Exception + ) + + $currentException = $Exception + while ($null -ne $currentException) { + if ($currentException -is [System.NullReferenceException]) { + return $true + } + + $currentException = $currentException.InnerException + } + + return $false +} + +function Invoke-NetCleanTargetAnalysis { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $true)] + [string]$TargetPath, + + [Parameter(Mandatory = $true)] + [string]$SettingsPath + ) + + $maximumAttemptCount = 2 + for ($attemptNumber = 1; $attemptNumber -le $maximumAttemptCount; $attemptNumber++) { + try { + return @( + Invoke-ScriptAnalyzer ` + -Path $TargetPath ` + -Settings $SettingsPath ` + -ErrorAction Stop + ) + } + catch { + $isRetryable = Test-NetCleanNullReferenceException -Exception $_.Exception + if (-not $isRetryable -or $attemptNumber -ge $maximumAttemptCount) { + throw + } + + Write-Warning ("PSScriptAnalyzer returned a transient NullReferenceException for '{0}'. Retrying once." -f $TargetPath) + } + } +} + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$settingsPath = Join-Path $repositoryRoot 'PSScriptAnalyzerSettings.psd1' + +if (-not (Test-Path -LiteralPath $settingsPath -PathType Leaf)) { + throw "PSScriptAnalyzer settings file not found: $settingsPath" +} + +$analyzerModule = Get-Module -ListAvailable PSScriptAnalyzer | + Where-Object Version -EQ $AnalyzerVersion | + Sort-Object Version -Descending | + Select-Object -First 1 + +if ($null -eq $analyzerModule) { + throw "PSScriptAnalyzer $AnalyzerVersion is required. Install that version from the PowerShell Gallery." +} + +Import-Module $analyzerModule.Path -Force -ErrorAction Stop + +$targetFiles = @( + Get-Item -LiteralPath (Join-Path $repositoryRoot 'NetClean.ps1') + Get-Item -LiteralPath (Join-Path $repositoryRoot 'NetClean.psd1') + Get-ChildItem -LiteralPath @( + (Join-Path $repositoryRoot 'Modules'), + (Join-Path $repositoryRoot 'examples'), + (Join-Path $repositoryRoot 'tests') + ) -Recurse -File | + Where-Object Extension -In @('.ps1', '.psm1', '.psd1') +) | Sort-Object FullName -Unique + +$findings = [System.Collections.Generic.List[object]]::new() + +foreach ($targetFile in $targetFiles) { + try { + foreach ($finding in @(Invoke-NetCleanTargetAnalysis -TargetPath $targetFile.FullName -SettingsPath $settingsPath)) { + $findings.Add($finding) + } + } + catch { + throw "PSScriptAnalyzer failed for '$($targetFile.FullName)' [$($_.Exception.GetType().FullName)]: $($_.Exception.Message)" + } +} + +if ($findings.Count -gt 0) { + $findings | + Sort-Object Severity, ScriptName, Line | + Format-Table Severity, RuleName, ScriptName, Line, Message -AutoSize | + Out-String | + Write-Information -InformationAction Continue + + throw "PSScriptAnalyzer reported $($findings.Count) rule violation(s)." +} + +[pscustomobject]@{ + AnalyzerVersion = $analyzerModule.Version.ToString() + TargetCount = $targetFiles.Count + FindingCount = 0 +} diff --git a/tests/Netclean.Tests.ps1 b/tests/Netclean.Tests.ps1 deleted file mode 100644 index 188e7bf..0000000 --- a/tests/Netclean.Tests.ps1 +++ /dev/null @@ -1,151 +0,0 @@ -Import-Module -Name (Join-Path $PSScriptRoot '..\Netclean.psm1') -Force -ErrorAction Stop - -# Helper to run potentially blocking calls in a job with a timeout to avoid infinite loops/hangs. -function Invoke-Safe { - param( - [ScriptBlock]$ScriptBlock, - [int]$TimeoutSec = 5, - [object[]]$ArgumentList = @() - ) - $job = Start-Job -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList - try { - if (Wait-Job -Job $job -Timeout $TimeoutSec) { - Receive-Job -Job $job - } - else { - Stop-Job -Job $job -ErrorAction SilentlyContinue - throw "Operation timed out after ${TimeoutSec}s" - } - } - finally { - Remove-Job -Job $job -ErrorAction SilentlyContinue - } -} - -Describe 'Netclean module helpers' { - Context 'Convert-RegKeyPath' { - It 'removes provider prefix and normalizes HKLM' { - $input = 'Microsoft.PowerShell.Core\Registry::HKLM:\SOFTWARE\\MyKey\\' - $out = Convert-RegKeyPath -Path $input - $out.TrimEnd('\') | Should Be 'HKLM\\SOFTWARE\\MyKey' - } - It 'normalizes already-normal path without changing it' { - $in = 'HKLM\\SOFTWARE\\MyKey' - (Convert-RegKeyPath -Path $in) | Should Be 'HKLM\\SOFTWARE\\MyKey' - } - - # note: empty-string binding for mandatory parameter is environment-dependent; skip explicit empty-string test - } - - Context 'Convert-NormalizeGuid' { - It 'removes braces and lowercases' { - (Convert-NormalizeGuid -Guid '{ABCDEF-1234}') | Should Be 'abcdef-1234' - } - It 'handles guid without braces' { - (Convert-NormalizeGuid -Guid 'A1B2C3') | Should Be 'a1b2c3' - } - } - - Context 'Derive-AVServicePatterns' { - It 'matches known vendors' { - $list = @('Bitdefender Endpoint Security') - $patterns = Derive-AVServicePatterns -AvList $list - ($patterns -match 'vsserv') | Should Be $true - } - It 'handles multiple vendors and deduplicates patterns' { - $list = @('Bitdefender','CrowdStrike') - $patterns = Derive-AVServicePatterns -AvList $list - ($patterns -match 'vsserv') | Should Be $true - ($patterns -match 'CSFalconService') | Should Be $true - } - } - - Context 'Get-InstalledAV' { - It 'returns an array (may be empty) and does not hang' { - $modulePath = (Join-Path $PSScriptRoot '..\\Netclean.psm1') - $result = Invoke-Safe -ScriptBlock { - param($m) - Import-Module -Name $m -Force -ErrorAction Stop - Get-InstalledAV - } -ArgumentList @($modulePath) -TimeoutSec 5 - - # Ensure result is safe and is an array (or can be treated as one) - ($result -is [array]) | Should Be $true - } - It 'returns an empty array when nothing detected (non-throwing)' { - $r = Get-InstalledAV - ($r -is [array]) | Should Be $true - } - } - - Context 'Build-ProtectionLists' { - It 'returns hashtable with expected keys and completes quickly' { - $modulePath = (Join-Path $PSScriptRoot '..\\Netclean.psm1') - $res = Invoke-Safe -ScriptBlock { - param($m) - Import-Module -Name $m -Force -ErrorAction Stop - Build-ProtectionLists - } -ArgumentList @($modulePath) -TimeoutSec 10 - - # Validate result type and expected keys - ($res -is [hashtable]) | Should Be $true - ($res.ContainsKey('Services')) | Should Be $true - ($res.ContainsKey('Adapters')) | Should Be $true - } - It 'includes Drivers and Registry keys when vendors detected' { - $res = Get-ProtectionList - ($res.ContainsKey('Drivers')) | Should Be $true - ($res.ContainsKey('Registry')) | Should Be $true - } - } - - Context 'Approved-verb wrappers' { - It 'Get-ProtectionList behaves like Build-ProtectionLists' { - $modulePath = (Join-Path $PSScriptRoot '..\Netclean.psm1') - $res = Invoke-Safe -ScriptBlock { - param($m) - Import-Module -Name $m -Force -ErrorAction Stop - Get-ProtectionList - } -ArgumentList @($modulePath) -TimeoutSec 8 - ($res -is [hashtable]) | Should Be $true - } - - It 'Get-AVServicePattern behaves like Derive-AVServicePatterns' { - $modulePath = (Join-Path $PSScriptRoot '..\Netclean.psm1') - $patterns = Invoke-Safe -ScriptBlock { - param($m) - Import-Module -Name $m -Force -ErrorAction Stop - Get-AVServicePattern -AvList @('Bitdefender Endpoint Security') - } -ArgumentList @($modulePath) -TimeoutSec 5 - ($patterns -match 'vsserv') | Should Be $true - } - - It 'Export-ProtectedRegistryKey supports DryRun and returns array' { - $modulePath = (Join-Path $PSScriptRoot '..\Netclean.psm1') - $out = Invoke-Safe -ScriptBlock { - param($m) - Import-Module -Name $m -Force -ErrorAction Stop - Export-ProtectedRegistryKey -Paths @('HKLM:\SOFTWARE\MyKey') -Dest (Join-Path $env:TEMP 'netclean_test') -DryRun - } -ArgumentList @($modulePath) -TimeoutSec 5 - # Accept native arrays or ArrayList (job deserialization may produce ArrayList) - ( ($out -is [array]) -or ($out -is [System.Collections.ArrayList]) ) | Should Be $true - } - - It 'Export-NetworkList DryRun returns a string path' { - $tmp = Join-Path $env:TEMP 'netclean_test' - $r = Export-NetworkList -Dest $tmp -DryRun - ($r -is [string]) | Should Be $true - } - - It 'Export-WiFiProfile DryRun returns array (or ArrayList) and does not call netsh' { - $modulePath = (Join-Path $PSScriptRoot '..\Netclean.psm1') - $tmp = Join-Path $env:TEMP 'netclean_test' - $r = Invoke-Safe -ScriptBlock { - param($m, $d) - Import-Module -Name $m -Force -ErrorAction Stop - Export-WiFiProfile -Dest $d -DryRun - } -ArgumentList @($modulePath, $tmp) -TimeoutSec 8 - ( ($r -is [array]) -or ($r -is [System.Collections.ArrayList]) ) | Should Be $true - } - } -} diff --git a/tests/Run-NetClean-Coverage.ps1 b/tests/Run-NetClean-Coverage.ps1 new file mode 100644 index 0000000..9fb4bd8 --- /dev/null +++ b/tests/Run-NetClean-Coverage.ps1 @@ -0,0 +1,170 @@ +[CmdletBinding()] +param( + [string]$RepoRoot = (Split-Path -Parent $PSScriptRoot), + [string]$OutputPath = (Join-Path $PSScriptRoot 'TestResults'), + [version]$PesterVersion = [version]'6.0.1', + [string[]]$CoveragePath, + [ValidateRange(0, 100)] + [double]$MinimumCoverage = 94.0, + [switch]$PassThru +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$manifestPath = Join-Path $RepoRoot 'NetClean.psd1' +$scriptPath = Join-Path $RepoRoot 'NetClean.ps1' +$testsPath = Join-Path $RepoRoot 'tests' + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" +} + +if (-not (Test-Path -LiteralPath $scriptPath)) { + throw "NetClean.ps1 not found at path: $scriptPath" +} + +if (-not (Test-Path -LiteralPath $testsPath)) { + throw "tests folder not found at path: $testsPath" +} + +if (-not (Test-Path -LiteralPath $OutputPath)) { + New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null +} + +if ($PSBoundParameters.ContainsKey('CoveragePath')) { + $coverageFiles = @( + $CoveragePath | + ForEach-Object { + if ([System.IO.Path]::IsPathRooted($_)) { + $_ + } + else { + Join-Path $RepoRoot $_ + } + } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } + ) +} +else { + $coverageFiles = @( + (Join-Path $RepoRoot 'NetClean.ps1'), + (Join-Path $RepoRoot 'Modules\NetClean.psm1'), + (Join-Path $RepoRoot 'Modules\NetCleanPhase1.ps1'), + (Join-Path $RepoRoot 'Modules\NetCleanPhase2.ps1'), + (Join-Path $RepoRoot 'Modules\NetCleanPhase3.ps1'), + (Join-Path $RepoRoot 'Modules\NetCleanPhase4.ps1') + ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } +} + +$testsRoot = [System.IO.Path]::GetFullPath($testsPath).TrimEnd('\', '/') + '\' +$testCoverageFiles = @( + $coverageFiles | + Where-Object { + [System.IO.Path]::GetFullPath($_).StartsWith( + $testsRoot, + [System.StringComparison]::OrdinalIgnoreCase + ) + } +) +if ($testCoverageFiles.Count -gt 0) { + throw 'CoveragePath must not include files under tests.' +} + +$pesterModule = Get-Module -ListAvailable Pester | + Where-Object Version -EQ $PesterVersion | + Sort-Object Version -Descending | + Select-Object -First 1 +if ($null -eq $pesterModule) { + throw "Run-NetClean-Coverage.ps1 requires Pester $PesterVersion." +} +Import-Module $pesterModule.Path -Force -ErrorAction Stop + +$testFiles = @( + Get-ChildItem -Path (Join-Path $testsPath 'Unit') -Filter '*.Tests.ps1' -File -ErrorAction SilentlyContinue + Get-ChildItem -Path (Join-Path $testsPath 'Functional') -Filter '*.Tests.ps1' -File -ErrorAction SilentlyContinue + Get-ChildItem -Path (Join-Path $testsPath 'Integration') -Filter '*.Tests.ps1' -File -ErrorAction SilentlyContinue + Get-ChildItem -Path (Join-Path $testsPath 'System') -Filter '*.Tests.ps1' -File -ErrorAction SilentlyContinue +) | Sort-Object FullName + +if (-not $testFiles -or $testFiles.Count -eq 0) { + throw "No test files found under tests\Unit, tests\Functional, tests\Integration, or tests\System." +} + +$coverageXml = Join-Path $OutputPath 'coverage.xml' +$testXml = Join-Path $OutputPath 'pester-results.xml' + +$config = New-PesterConfiguration + +$config.Run.Path = $testFiles.FullName +$config.Run.PassThru = $true +$config.Run.Exit = $false +$config.Run.Throw = $true + +$config.Output.Verbosity = 'Normal' + +$config.TestResult.Enabled = $true +$config.TestResult.OutputFormat = 'JUnitXml' +$config.TestResult.OutputPath = $testXml + +$config.CodeCoverage.Enabled = $true +$config.CodeCoverage.Path = $coverageFiles +$config.CodeCoverage.ExcludeTests = $true +$config.CodeCoverage.CoveragePercentTarget = $MinimumCoverage +$config.CodeCoverage.OutputFormat = 'JaCoCo' +$config.CodeCoverage.OutputPath = $coverageXml + +Write-Information '' -InformationAction Continue +Write-Information 'Running Pester with coverage...' -InformationAction Continue +Write-Information "RepoRoot: $RepoRoot" -InformationAction Continue +Write-Information "Tests: $($testFiles.Count)" -InformationAction Continue +Write-Information "Coverage on: $($coverageFiles.Count) files" -InformationAction Continue +Write-Information '' + +$result = Invoke-Pester -Configuration $config + +Write-Information '' -InformationAction Continue +Write-Information 'Pester summary' -InformationAction Continue +Write-Information "Passed: $($result.PassedCount)" -InformationAction Continue +Write-Information "Failed: $($result.FailedCount)" -InformationAction Continue +Write-Information "Skipped: $($result.SkippedCount)" -InformationAction Continue +Write-Information '' -InformationAction Continue + +if ($null -ne $result.CodeCoverage) { + $analyzedCount = if ($result.CodeCoverage.PSObject.Properties.Name -contains 'CommandsAnalyzedCount') { + $result.CodeCoverage.CommandsAnalyzedCount + } + else { + $result.CodeCoverage.NumberOfCommandsAnalyzed + } + $executedCount = if ($result.CodeCoverage.PSObject.Properties.Name -contains 'CommandsExecutedCount') { + $result.CodeCoverage.CommandsExecutedCount + } + else { + $result.CodeCoverage.NumberOfCommandsExecuted + } + + Write-Information 'Coverage summary' -InformationAction Continue + Write-Information ("Commands analyzed: {0}" -f $analyzedCount) -InformationAction Continue + Write-Information ("Commands executed: {0}" -f $executedCount) -InformationAction Continue + Write-Information ("Percent covered: {0:N2}%" -f $result.CodeCoverage.CoveragePercent) -InformationAction Continue + Write-Information '' -InformationAction Continue +} + +Write-Information "JUnit XML: $testXml" -InformationAction Continue +Write-Information "JaCoCo XML: $coverageXml" -InformationAction Continue +Write-Information '' -InformationAction Continue + +if ($PassThru) { + Write-Output $result +} + +if ($result.FailedCount -gt 0) { + Write-Error ("Pester reported {0} failed test(s)." -f $result.FailedCount) + exit 1 +} + +if ($null -eq $result.CodeCoverage -or $result.CodeCoverage.CoveragePercent -lt $MinimumCoverage) { + Write-Error "Coverage below required threshold ($MinimumCoverage%)." + exit 1 +} diff --git a/tests/System/NetClean.Registry.System.Tests.ps1 b/tests/System/NetClean.Registry.System.Tests.ps1 new file mode 100644 index 0000000..478bd86 --- /dev/null +++ b/tests/System/NetClean.Registry.System.Tests.ps1 @@ -0,0 +1,162 @@ +$manifestPath = Join-Path $PSScriptRoot '..\..\NetClean.psd1' + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" +} + +Remove-Module NetClean -ErrorAction SilentlyContinue +Import-Module $manifestPath -Force + +Describe 'NetClean isolated registry system-component tests' -Tag 'System', 'Registry' { + + InModuleScope 'NetClean' { + + BeforeEach { + $script:LogFile = $null + + Remove-Item -LiteralPath 'TestRegistry:\Machine' -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath 'TestRegistry:\User' -Recurse -Force -ErrorAction SilentlyContinue + New-Item -Path 'TestRegistry:\Machine' -Force | Out-Null + New-Item -Path 'TestRegistry:\User' -Force | Out-Null + + $isolatedRoot = (Get-PSDrive -Name TestRegistry -ErrorAction Stop).Root + Set-NetCleanRegistryRootMap -RootMap @{ + HKLM = "$isolatedRoot\Machine" + HKCU = "$isolatedRoot\User" + } + + $script:InterfaceGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + $script:ProfileGuid = '{11111111-2222-3333-4444-555555555555}' + $script:ProtectedPath = 'HKLM\SOFTWARE\Contoso\SecurityAgent' + + $profilePath = "TestRegistry:\Machine\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles\$script:ProfileGuid" + New-Item -Path $profilePath -Force | Out-Null + New-ItemProperty -LiteralPath $profilePath -Name 'ProfileName' -Value 'ConferenceSSID' -PropertyType String -Force | Out-Null + + foreach ($signatureType in @('Managed', 'Unmanaged')) { + $signaturePath = "TestRegistry:\Machine\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Signatures\$signatureType\Signature1" + New-Item -Path $signaturePath -Force | Out-Null + New-ItemProperty -LiteralPath $signaturePath -Name 'FirstNetwork' -Value 'ConferenceSSID' -PropertyType String -Force | Out-Null + } + + $tcpipPath = "TestRegistry:\Machine\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{$script:InterfaceGuid}" + New-Item -Path $tcpipPath -Force | Out-Null + New-ItemProperty -LiteralPath $tcpipPath -Name 'EnableDHCP' -Value 0 -PropertyType DWord -Force | Out-Null + + $networkControlPath = "TestRegistry:\Machine\SYSTEM\CurrentControlSet\Control\Network\{4d36e972-e325-11ce-bfc1-08002be10318}\{$script:InterfaceGuid}\Connection" + New-Item -Path $networkControlPath -Force | Out-Null + New-ItemProperty -LiteralPath $networkControlPath -Name 'Name' -Value 'Synthetic Adapter' -PropertyType String -Force | Out-Null + + $protectedProviderPath = 'TestRegistry:\Machine\SOFTWARE\Contoso\SecurityAgent' + New-Item -Path $protectedProviderPath -Force | Out-Null + New-ItemProperty -LiteralPath $protectedProviderPath -Name 'Installed' -Value 1 -PropertyType DWord -Force | Out-Null + } + + AfterEach { + Clear-NetCleanRegistryRootMap + } + + It 'discovers synthetic network history while preserving adapter configuration' { + $candidates = @(Get-NetworkPrivacyArtifactCandidate -Inventory @()) + $sanitizable = @(Get-SanitizableNetworkArtifact -Inventory @()) + + @($candidates | Where-Object ArtifactType -EQ 'NetworkList').Count | Should -Be 4 + @($sanitizable | Where-Object ArtifactType -NE 'NetworkList').Count | Should -Be 0 + + $tcpipCandidate = $candidates | + Where-Object { $_.ArtifactType -eq 'TcpipInterface' -and $_.InterfaceGuid -eq $script:InterfaceGuid } + $tcpipCandidate.CanSanitize | Should -BeFalse + + $networkControlCandidates = @( + $candidates | + Where-Object { $_.ArtifactType -eq 'NetworkControl' -and $_.InterfaceGuid -eq $script:InterfaceGuid } + ) + $networkControlCandidates.Count | Should -Be 2 + @($networkControlCandidates | Where-Object CanSanitize).Count | Should -Be 0 + + @(Get-NetworkListProfileName) | Should -Contain 'ConferenceSSID' + } + + It 'validates map targets atomically and restores normal hive routing' { + $mappedProviderPath = Convert-RegToProviderPath -RegistryPath 'HKLM\SOFTWARE' + $mappedProviderPath | Should -Match '^Registry::HKEY_CURRENT_USER\\Software\\Pester\\' + + $isolatedRoot = (Get-PSDrive -Name TestRegistry -ErrorAction Stop).Root + Set-NetCleanRegistryRootMap -RootMap @{ + HKLM = "$isolatedRoot\User" + } -WhatIf + Convert-RegToProviderPath -RegistryPath 'HKLM\SOFTWARE' | Should -Be $mappedProviderPath + + { + Set-NetCleanRegistryRootMap -RootMap @{ + HKLM = "$isolatedRoot\Missing" + } + } | Should -Throw + + Convert-RegToProviderPath -RegistryPath 'HKLM\SOFTWARE' | Should -Be $mappedProviderPath + + Clear-NetCleanRegistryRootMap + Convert-RegToProviderPath -RegistryPath 'HKLM\SOFTWARE' | + Should -Be 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE' + } + + It 'backs up synthetic NetworkList and protected registry data with reg.exe' { + $backupPath = Join-Path $TestDrive 'registry-backup' + + $networkListBackup = Export-NetworkList -Dest $backupPath + $protectedBackups = @( + Export-ProtectedRegistryKey -Paths @($script:ProtectedPath) -Dest $backupPath + ) + + Test-Path -LiteralPath $networkListBackup -PathType Leaf | Should -BeTrue + $protectedBackups.Count | Should -Be 1 + Test-Path -LiteralPath $protectedBackups[0] -PathType Leaf | Should -BeTrue + Get-Content -LiteralPath $networkListBackup -Raw | Should -Match 'ConferenceSSID' + Get-Content -LiteralPath $protectedBackups[0] -Raw | Should -Match 'Installed' + } + + It 'removes only sanitizable history and independently verifies the post-state' { + $artifacts = [System.Collections.Generic.List[object]]::new() + foreach ($artifact in @(Get-SanitizableNetworkArtifact -Inventory @())) { + $artifacts.Add($artifact) + } + $artifacts.Add([pscustomobject]@{ + ArtifactType = 'SecurityAgent' + RegistryPath = $script:ProtectedPath + CanSanitize = $true + IsProtected = $true + Reason = 'Synthetic protection-boundary test' + }) + + $context = [pscustomobject]@{ + SanitizableArtifacts = $artifacts.ToArray() + ProtectedRegistryPaths = @($script:ProtectedPath) + } + + $cleanup = Remove-NetworkPrivacyArtifactsSafe -Context $context -Confirm:$false + $context | Add-Member -MemberType NoteProperty -Name Clean -Value ([pscustomobject]@{ + DryRun = $false + RegistryArtifacts = $cleanup + }) + $verification = Test-NetCleanCleanupPostState -Context $context + + Test-RegistryPathExist -RegistryPath 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' | + Should -BeFalse + Test-RegistryPathExist -RegistryPath 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Signatures' | + Should -BeFalse + Test-RegistryPathExist -RegistryPath "HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{$script:InterfaceGuid}" | + Should -BeTrue + Test-RegistryPathExist -RegistryPath "HKLM\SYSTEM\CurrentControlSet\Control\Network\{4d36e972-e325-11ce-bfc1-08002be10318}\{$script:InterfaceGuid}\Connection" | + Should -BeTrue + Test-RegistryPathExist -RegistryPath $script:ProtectedPath | Should -BeTrue + + @($cleanup.Results | Where-Object Reason -EQ 'Protected').Count | Should -Be 1 + $verification.Applicable | Should -BeTrue + $verification.Passed | Should -BeTrue + @($verification.Checks | Where-Object VerificationType -EQ 'IndependentState').Count | + Should -BeGreaterThan 0 + @($verification.Checks | Where-Object VerificationType -EQ 'ProtectionBoundary').Count | + Should -Be 1 + } + } +} diff --git a/tests/Unit/NetClean.Analyzer.Unit.Tests.ps1 b/tests/Unit/NetClean.Analyzer.Unit.Tests.ps1 new file mode 100644 index 0000000..5b58d00 --- /dev/null +++ b/tests/Unit/NetClean.Analyzer.Unit.Tests.ps1 @@ -0,0 +1,56 @@ +Describe 'NetClean analyzer runner' { + BeforeAll { + $script:analyzerRunner = Join-Path $PSScriptRoot '..\Invoke-NetCleanAnalyzer.ps1' + Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Force + } + + BeforeEach { + [System.AppDomain]::CurrentDomain.SetData('NetCleanAnalyzer.Unit.CallCount', 0) + Mock Import-Module {} + Mock Write-Warning {} + } + + It 'retries one transient analyzer null-reference and completes cleanly' { + Mock Invoke-ScriptAnalyzer { + $callCount = [int][System.AppDomain]::CurrentDomain.GetData('NetCleanAnalyzer.Unit.CallCount') + 1 + [System.AppDomain]::CurrentDomain.SetData('NetCleanAnalyzer.Unit.CallCount', $callCount) + if ($callCount -eq 1) { + throw [System.NullReferenceException]::new('transient analyzer failure') + } + + @() + } + + $result = & $script:analyzerRunner + + $result.FindingCount | Should -Be 0 + [System.AppDomain]::CurrentDomain.GetData('NetCleanAnalyzer.Unit.CallCount') | + Should -Be ($result.TargetCount + 1) + Should -Invoke Write-Warning -Times 1 -Exactly + } + + It 'fails after one retry when the analyzer null-reference repeats' { + Mock Invoke-ScriptAnalyzer { + $callCount = [int][System.AppDomain]::CurrentDomain.GetData('NetCleanAnalyzer.Unit.CallCount') + 1 + [System.AppDomain]::CurrentDomain.SetData('NetCleanAnalyzer.Unit.CallCount', $callCount) + throw [System.NullReferenceException]::new('persistent analyzer failure') + } + + { & $script:analyzerRunner } | Should -Throw '*persistent analyzer failure*' + + [System.AppDomain]::CurrentDomain.GetData('NetCleanAnalyzer.Unit.CallCount') | Should -Be 2 + } + + It 'does not retry analyzer exceptions that are not null-reference failures' { + Mock Invoke-ScriptAnalyzer { + $callCount = [int][System.AppDomain]::CurrentDomain.GetData('NetCleanAnalyzer.Unit.CallCount') + 1 + [System.AppDomain]::CurrentDomain.SetData('NetCleanAnalyzer.Unit.CallCount', $callCount) + throw [System.InvalidOperationException]::new('ordinary analyzer failure') + } + + { & $script:analyzerRunner } | Should -Throw '*ordinary analyzer failure*' + + [System.AppDomain]::CurrentDomain.GetData('NetCleanAnalyzer.Unit.CallCount') | Should -Be 1 + Should -Invoke Write-Warning -Times 0 -Exactly + } +} diff --git a/tests/Unit/NetClean.Core.Unit.Tests.ps1 b/tests/Unit/NetClean.Core.Unit.Tests.ps1 new file mode 100644 index 0000000..9f456eb --- /dev/null +++ b/tests/Unit/NetClean.Core.Unit.Tests.ps1 @@ -0,0 +1,1013 @@ +$manifestPath = Join-Path $PSScriptRoot '..\..\NetClean.psd1' + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" +} + +Remove-Module NetClean -ErrorAction SilentlyContinue +Import-Module $manifestPath -Force + +Describe 'NetClean core/shared helper unit tests' { + + InModuleScope 'NetClean' { + + BeforeEach { + $script:LogFile = $null + } + + Context 'Convert-RegKeyPath' { + + It 'normalizes Microsoft.PowerShell.Core registry provider paths' { + Convert-RegKeyPath -Path 'Microsoft.PowerShell.Core\Registry::HKLM:\SOFTWARE\Test' | + Should -Be 'HKLM\SOFTWARE\Test' + } + + It 'normalizes Registry:: prefixed paths' { + Convert-RegKeyPath -Path 'Registry::HKCU\Software\Test' | + Should -Be 'HKCU\Software\Test' + } + + It 'preserves already normalized registry paths' { + Convert-RegKeyPath -Path 'HKLM\SOFTWARE\Test' | + Should -Be 'HKLM\SOFTWARE\Test' + } + + It 'returns null when input is null' { + Convert-RegKeyPath -Path $null | Should -BeNullOrEmpty + } + + It 'returns empty string when input is empty' { + Convert-RegKeyPath -Path '' | Should -Be '' + } + } + + Context 'Convert-RegToProviderPath' { + + It 'converts HKLM path to provider form' { + Convert-RegToProviderPath -RegistryPath 'HKLM\SOFTWARE\Test' | + Should -Be 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Test' + } + + It 'converts HKCU path to provider form' { + Convert-RegToProviderPath -RegistryPath 'HKCU\Software\Test' | + Should -Be 'Registry::HKEY_CURRENT_USER\Software\Test' + } + + It 'preserves already provider-qualified paths' { + Convert-RegToProviderPath -RegistryPath 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Test' | + Should -Be 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Test' + } + + It 'returns null when input is null' { + Convert-RegToProviderPath -Path $null | Should -BeNullOrEmpty + } + } + + Context 'Set-NetCleanRegistryRootMap' { + + BeforeEach { + Clear-NetCleanRegistryRootMap + Mock Get-Item { + [pscustomobject]@{ PSProvider = [pscustomobject]@{ Name = 'Registry' } } + } + } + + AfterEach { + Clear-NetCleanRegistryRootMap + } + + It 'rejects an empty root map' { + { Set-NetCleanRegistryRootMap -RootMap @{} } | + Should -Throw '*at least one mapping*' + } + + It 'rejects logical keys below a hive root' { + { Set-NetCleanRegistryRootMap -RootMap @{ 'HKLM\SOFTWARE' = 'HKCU\Software\Test' } } | + Should -Throw '*must be a hive root*' + Should -Invoke Get-Item -Times 0 + } + + It 'rejects aliases that duplicate the same logical hive' { + { + Set-NetCleanRegistryRootMap -RootMap @{ + HKLM = 'HKCU\Software\First' + HKEY_LOCAL_MACHINE = 'HKCU\Software\Second' + } + } | Should -Throw '*duplicate logical root*' + } + + It 'rejects targets outside the Registry provider' { + Mock Get-Item { + [pscustomobject]@{ PSProvider = [pscustomobject]@{ Name = 'FileSystem' } } + } + + { Set-NetCleanRegistryRootMap -RootMap @{ HKLM = 'HKCU\Software\Test' } } | + Should -Throw '*not a Registry-provider key*' + } + } + + Context 'Convert-Guid' { + + It 'normalizes GUIDs with braces and uppercase characters' { + Convert-Guid -Guid '{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}' | + Should -Be 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + } + + It 'normalizes GUIDs without braces' { + Convert-Guid -Guid 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE' | + Should -Be 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + } + + It 'returns null for null input' { + Convert-Guid -Guid $null | Should -BeNullOrEmpty + } + + It 'returns null for empty input' { + Convert-Guid -Guid '' | Should -BeNullOrEmpty + } + } + + Context 'Get-UniqueNonEmptyString' { + + It 'returns distinct non-empty strings only' { + $result = Get-UniqueNonEmptyString -InputObject @( + 'alpha', + '', + 'beta', + 'alpha', + $null, + ' ', + 'gamma' + ) + + @($result) | Should -Be @('alpha', 'beta', 'gamma') + } + + It 'returns an empty collection for null input' { + @((Get-UniqueNonEmptyString -InputObject $null)).Count | Should -Be 0 + } + + It 'flattens one nested collection level and ignores empty inner values' { + $nested = [object[]]@(' beta ', $null, '', 'alpha') + + $result = Get-UniqueNonEmptyString -InputObject @($nested, 'gamma', 'alpha') + + @($result) | Should -Be @('alpha', 'beta', 'gamma') + } + } + + Context 'Add-HashSetValue' { + + It 'adds a new value to the hashset' { + $set = [System.Collections.Generic.HashSet[string]]::new() + Add-HashSetValue -Set $set -Values 'abc' | Out-Null + + $set.Contains('abc') | Should -BeTrue + } + + It 'does not fail when value is already present' { + $set = [System.Collections.Generic.HashSet[string]]::new() + $set.Add('abc') | Out-Null + + { Add-HashSetValue -Set $set -Values 'abc' | Out-Null } | Should -Not -Throw + $set.Count | Should -Be 1 + } + + It 'ignores null or whitespace values' { + $set = [System.Collections.Generic.HashSet[string]]::new() + + Add-HashSetValue -Set $set -Values $null | Out-Null + Add-HashSetValue -Set $set -Values '' | Out-Null + Add-HashSetValue -Set $set -Values ' ' | Out-Null + + $set.Count | Should -Be 0 + } + + It 'adds normalized values from a nested collection' { + $set = [System.Collections.Generic.HashSet[string]]::new() + $nested = [object[]]@(' beta ', $null, '', 'alpha') + + Add-HashSetValue -Set $set -Values @($nested, 'gamma') + + $set.Count | Should -Be 3 + $set.Contains('alpha') | Should -BeTrue + $set.Contains('beta') | Should -BeTrue + $set.Contains('gamma') | Should -BeTrue + } + } + + Context 'Compare-StringSet' { + + It 'identifies missing items from baseline to current' { + $before = @('a', 'b', 'c') + $after = @('a', 'c') + + $result = Compare-StringSet -Before $before -After $after + + @($result.Missing) | Should -Be @('b') + } + + It 'identifies added items from current to baseline' { + $baseline = @('a') + $current = @('a', 'b', 'c') + + $result = Compare-StringSet -Before $baseline -After $current + + @($result.Added) | Should -Be @('b', 'c') + } + + It 'returns empty differences when sets match' { + $result = Compare-StringSet -Before @('a', 'b') -After @('a', 'b') + + @($result.Missing).Count | Should -Be 0 + @($result.Added).Count | Should -Be 0 + } + } + + Context 'Get-NetCleanDeviceManagementState' { + + It 'classifies a hybrid Entra/domain device as managed' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Succeeded = $true + Output = @( + ' AzureAdJoined : YES', + ' DomainJoined : YES', + ' EnterpriseJoined : NO', + ' WorkplaceJoined : NO' + ) + Error = $null + } + } + Mock Get-ScheduledTask { @() } + + $result = Get-NetCleanDeviceManagementState + + $result.IsManaged | Should -BeTrue + $result.JoinType | Should -Be 'MicrosoftEntraHybridJoined' + $result.EntraJoined | Should -BeTrue + $result.DomainJoined | Should -BeTrue + } + + It 'classifies a workgroup device without MDM evidence as unmanaged' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Succeeded = $true + Output = @( + ' AzureAdJoined : NO', + ' DomainJoined : NO', + ' EnterpriseJoined : NO', + ' WorkplaceJoined : NO' + ) + Error = $null + } + } + Mock Get-ScheduledTask { @() } + + $result = Get-NetCleanDeviceManagementState + + $result.IsManaged | Should -BeFalse + $result.JoinType | Should -Be 'Workgroup' + $result.MdmEnrolled | Should -BeFalse + } + + It 'does not treat Workplace registration alone as device management' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Succeeded = $true + Output = @( + ' AzureAdJoined : NO', + ' DomainJoined : NO', + ' EnterpriseJoined : NO', + ' WorkplaceJoined : YES' + ) + Error = $null + } + } + Mock Get-ScheduledTask { @() } + + $result = Get-NetCleanDeviceManagementState + + $result.IsManaged | Should -BeFalse + $result.JoinType | Should -Be 'WorkplaceRegistered' + $result.WorkplaceJoined | Should -BeTrue + $result.MdmEnrolled | Should -BeFalse + } + + It 'treats EnterpriseMgmt task evidence as managed conservatively' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Succeeded = $true + Output = @( + ' AzureAdJoined : NO', + ' DomainJoined : NO', + ' EnterpriseJoined : NO', + ' WorkplaceJoined : NO' + ) + Error = $null + } + } + Mock Get-ScheduledTask { + @([pscustomobject]@{ TaskName = 'Schedule #3'; TaskPath = '\Microsoft\Windows\EnterpriseMgmt\{guid}\' }) + } + + $result = Get-NetCleanDeviceManagementState + + $result.IsManaged | Should -BeTrue + $result.MdmEnrolled | Should -BeTrue + $result.JoinType | Should -Be 'MdmEnrolled' + } + + It 'falls back to the computer-system domain state when dsregcmd fails' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Succeeded = $false + Output = @() + Error = 'dsregcmd failed' + } + } + Mock Get-CimInstance { + [pscustomobject]@{ PartOfDomain = $true } + } -ParameterFilter { $ClassName -eq 'Win32_ComputerSystem' } + Mock Get-ScheduledTask { @() } + + $result = Get-NetCleanDeviceManagementState + + $result.IsManaged | Should -BeTrue + $result.DomainJoined | Should -BeTrue + $result.JoinType | Should -Be 'DomainJoined' + @($result.Warnings).Count | Should -BeGreaterThan 0 + } + } + + Context 'Invoke-InParallel' { + + It 'returns an empty collection for empty input' { + $result = @(Invoke-InParallel -ScriptBlock { param($item) $item } -InputObjects @()) + + $result.Count | Should -Be 0 + } + + It 'collects one result for each independent input' { + $result = @( + Invoke-InParallel -ScriptBlock { + param($item) + [pscustomobject]@{ Value = $item * 2 } + } -InputObjects @(1, 2, 3) -ThrottleLimit 2 + ) + + @($result.Value | Sort-Object) | Should -Be @(2, 4, 6) + } + + It 'isolates a failed worker and returns successful worker results' { + $result = @( + Invoke-InParallel -ScriptBlock { + param($item) + if ($item -eq 2) { throw 'worker failed' } + $item + } -InputObjects @(1, 2, 3) -ThrottleLimit 2 + ) + + @($result | Sort-Object) | Should -Be @(1, 3) + } + + It 'rejects a non-positive throttle limit' { + { + Invoke-InParallel -ScriptBlock { param($item) $item } -InputObjects @(1) -ThrottleLimit 0 + } | Should -Throw + } + } + + Context 'Test-RegistryPathExist' { + + It 'returns true when Test-Path returns true' { + Mock Test-Path { $true } + + Test-RegistryPathExist -Path 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Test' | Should -BeTrue + } + + It 'returns false when Test-Path returns false' { + Mock Test-Path { $false } + + Test-RegistryPathExist -Path 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Test' | Should -BeFalse + } + + It 'returns false when Test-Path throws' { + Mock Test-Path { throw 'boom' } + + Test-RegistryPathExist -Path 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Test' | Should -BeFalse + } + + It 'rethrows provider failures for verification callers' { + Mock Convert-RegToProviderPath { throw 'registry provider unavailable' } + + { + Test-RegistryPathExist -RegistryPath 'HKLM\SOFTWARE\Test' -ThrowOnError + } | Should -Throw '*registry provider unavailable*' + } + } + + Context 'Get-RegistryValuesSafe' { + + It 'returns registry property bag when item properties are available' { + Mock Get-ItemProperty { + [pscustomobject]@{ + Name = 'TestName' + Value = 'TestValue' + } + } + + $result = Get-RegistryValuesSafe -RegistryPath 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Test' + + $result.Name | Should -Be 'TestName' + $result.Value | Should -Be 'TestValue' + } + + It 'returns null when Get-ItemProperty throws' { + Mock Get-ItemProperty { throw 'boom' } + + Get-RegistryValuesSafe -RegistryPath 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Test' | Should -BeNullOrEmpty + } + } + + Context 'Get-RegistryChildKeyNamesSafe' { + + It 'returns child key names when present' { + Mock Get-ChildItem { + @( + [pscustomobject]@{ PSChildName = 'One' } + [pscustomobject]@{ PSChildName = 'Two' } + ) + } + + $result = Get-RegistryChildKeyNamesSafe -RegistryPath 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Test' + + @($result) | Should -Be @('One', 'Two') + } + + It 'returns empty collection when Get-ChildItem throws' { + Mock Get-ChildItem { throw 'boom' } + + @((Get-RegistryChildKeyNamesSafe -Path 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Test')).Count | + Should -Be 0 + } + } + + Context 'New-DirectoryIfNotExist' { + + It 'creates a directory when it does not already exist' { + $path = Join-Path $TestDrive 'NewFolder' + + Test-Path -LiteralPath $path | Should -BeFalse + New-DirectoryIfNotExist -Path $path + Test-Path -LiteralPath $path | Should -BeTrue + } + + It 'does not throw when the directory already exists' { + $path = Join-Path $TestDrive 'ExistingFolder' + New-Item -Path $path -ItemType Directory -Force | Out-Null + + { New-DirectoryIfNotExist -Path $path } | Should -Not -Throw + Test-Path -LiteralPath $path | Should -BeTrue + } + } + + Context 'Get-NetCleanLogFile' { + + It 'returns null when no log file has been initialized' { + $script:LogFile = $null + Get-NetCleanLogFile | Should -BeNullOrEmpty + } + + It 'returns the current module log file path when initialized' { + $script:LogFile = 'C:\Temp\NetClean.log' + Get-NetCleanLogFile | Should -Be 'C:\Temp\NetClean.log' + } + } + + Context 'Set-NetCleanPrivateDirectoryAcl' { + + It 'applies a protected ACL to an existing directory' { + Mock Set-Acl {} + + Set-NetCleanPrivateDirectoryAcl -Path $TestDrive + + Should -Invoke Set-Acl -Times 1 -ParameterFilter { + $LiteralPath -eq $TestDrive -and + $AclObject.AreAccessRulesProtected + } + } + + It 'honors WhatIf without applying an ACL' { + Mock Set-Acl {} + + Set-NetCleanPrivateDirectoryAcl -Path $TestDrive -WhatIf + + Should -Invoke Set-Acl -Times 0 + } + + It 'fails closed when the directory does not exist' { + { + Set-NetCleanPrivateDirectoryAcl -Path (Join-Path $TestDrive 'missing') + } | Should -Throw + } + } + + Context 'Start-NetCleanLog' { + + It 'secures the directory before creating a log file' { + $logDir = Join-Path $TestDrive 'PrivateLogs' + Mock Set-NetCleanPrivateDirectoryAcl {} + + Start-NetCleanLog -Directory $logDir + + Should -Invoke Set-NetCleanPrivateDirectoryAcl -Times 1 -ParameterFilter { + $Path -eq $logDir + } + } + + It 'creates a log file in the target directory' { + $logDir = Join-Path $TestDrive 'Logs' + + Start-NetCleanLog -Directory $logDir + + $logFile = Get-NetCleanLogFile + $logFile | Should -Not -BeNullOrEmpty + Test-Path -LiteralPath $logFile | Should -BeTrue + } + + It 'creates the directory if it does not exist' { + $logDir = Join-Path $TestDrive 'BrandNewLogs' + + Test-Path -LiteralPath $logDir | Should -BeFalse + Start-NetCleanLog -Directory $logDir + Test-Path -LiteralPath $logDir | Should -BeTrue + } + } + + Context 'Write-NetCleanLog' { + + It 'writes a log line to the current log file' { + $logDir = Join-Path $TestDrive 'Logs' + Start-NetCleanLog -Directory $logDir + + Write-NetCleanLog -Level INFO -Message 'hello world' + + $logFile = Get-NetCleanLogFile + $content = Get-Content -LiteralPath $logFile -Raw + + $content | Should -Match 'hello world' + $content | Should -Match '\[INFO\]' + } + + It 'does not throw when no log file is initialized' { + $script:LogFile = $null + + { Write-NetCleanLog -Level INFO -Message 'no file' } | Should -Not -Throw + } + + It 'writes to the warning stream for WARN level' { + $logDir = Join-Path $TestDrive 'WarnLogs' + Start-NetCleanLog -Directory $logDir + + { Write-NetCleanLog -Level WARN -Message 'warn message' } | Should -Not -Throw + } + + It 'writes to the error stream for ERROR level' { + $logDir = Join-Path $TestDrive 'ErrorLogs' + Start-NetCleanLog -Directory $logDir + + $streamOutput = @(Write-NetCleanLog -Level ERROR -Message 'error message' 2>&1) + + @($streamOutput | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }).Count | + Should -BeGreaterThan 0 + } + } + + Context 'Invoke-ExternalCommandSafe' { + + It 'returns a successful dry-run result' { + $r = Invoke-ExternalCommandSafe -Name Test -FilePath cmd.exe -ArgumentList '/c','echo ok' -DryRun + + $r.Name | Should -Be 'Test' + $r.Succeeded | Should -BeTrue + $r.DryRun | Should -BeTrue + $r.ExitCode | Should -Be 0 + } + + It 'captures successful process execution' { + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + + $r = Invoke-ExternalCommandSafe -Name Test -FilePath cmd.exe -ArgumentList '/c','echo ok' + + $r.Succeeded | Should -BeTrue + $r.ExitCode | Should -Be 0 + Should -Invoke Start-Process -Times 1 + } + + It 'captures a non-zero exit code' { + Mock Start-Process { [pscustomobject]@{ ExitCode = 5 } } + + $r = Invoke-ExternalCommandSafe -Name Test -FilePath cmd.exe -ArgumentList '/c','exit 5' + + $r.Succeeded | Should -BeFalse + $r.ExitCode | Should -Be 5 + } + + It 'returns failure details when Start-Process throws' { + Mock Start-Process { throw 'start-process-failed' } + + $r = Invoke-ExternalCommandSafe -Name Test -FilePath cmd.exe -ArgumentList '/c','echo ok' + + $r.Succeeded | Should -BeFalse + $r.ExitCode | Should -Be -1 + $r.Error | Should -Match 'start-process-failed' + } + } + + Context 'Invoke-RegExport' { + + BeforeEach { + Mock Resolve-NetCleanRegistryPath { $RegistryPath } + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + Mock Test-Path { $true } + } + + It 'returns the planned path without starting reg.exe during a dry run' { + Invoke-RegExport -Key 'HKLM\SOFTWARE\Test' -FilePath 'C:\backup\test.reg' -DryRun | + Should -Be 'C:\backup\test.reg' + Should -Invoke Start-Process -Times 0 + } + + It 'rejects double quotes in the key or output path' -ForEach @( + @{ Key = 'HKLM\SOFTWARE\"Test'; FilePath = 'C:\backup\test.reg' } + @{ Key = 'HKLM\SOFTWARE\Test'; FilePath = 'C:\backup\"test.reg' } + ) { + { Invoke-RegExport -Key $Key -FilePath $FilePath } | + Should -Throw '*must not contain double-quote*' + Should -Invoke Start-Process -Times 0 + } + + It 'fails when reg.exe cannot be started' { + Mock Start-Process { $null } + + { Invoke-RegExport -Key 'HKLM\SOFTWARE\Test' -FilePath 'C:\backup\test.reg' } | + Should -Throw '*Failed to start reg.exe*' + } + + It 'fails when reg.exe returns a nonzero exit code' { + Mock Start-Process { [pscustomobject]@{ ExitCode = 5 } } + + { Invoke-RegExport -Key 'HKLM\SOFTWARE\Test' -FilePath 'C:\backup\test.reg' } | + Should -Throw '*exit code 5*' + } + + It 'fails when reg.exe reports success without creating the output file' { + Mock Test-Path { $false } + + { Invoke-RegExport -Key 'HKLM\SOFTWARE\Test' -FilePath 'C:\backup\test.reg' } | + Should -Throw '*output file was not created*' + } + + It 'quotes the normalized key and output path for a successful export' { + $result = Invoke-RegExport ` + -Key 'HKLM\SOFTWARE\Test' ` + -FilePath 'C:\backup folder\test.reg' + + $result | Should -Be 'C:\backup folder\test.reg' + Should -Invoke Start-Process -Times 1 -ParameterFilter { + $FilePath -eq 'reg.exe' -and + $ArgumentList[0] -eq 'export' -and + $ArgumentList[1] -eq '"HKLM\SOFTWARE\Test"' -and + $ArgumentList[2] -eq '"C:\backup folder\test.reg"' -and + $ArgumentList[3] -eq '/y' -and + $NoNewWindow -and $Wait -and $PassThru + } + } + } + + Context 'Invoke-NetCleanNativeCapture' { + + It 'captures successful native output' { + Mock Write-NetCleanLog {} + Mock Start-Process { throw 'This test expects direct invocation path to remain callable only if implemented differently.' } + + # If your implementation directly invokes native commands rather than Start-Process, + # replace this test later with a cmd.exe-based invocation using the real command. + { Invoke-NetCleanNativeCapture -FilePath 'cmd.exe' -ArgumentList @('/c', 'echo ok') -IgnoreExitCode } | + Should -Not -Throw + } + + It 'captures non-zero exit code and respects IgnoreExitCode' { + $bat = Join-Path $TestDrive 'exit5.bat' + Set-Content -Path $bat -Value 'exit /b 5' -NoNewline + + $r = Invoke-NetCleanNativeCapture -FilePath $bat -ArgumentList @() + + $r.Succeeded | Should -BeFalse + $r.ExitCode | Should -Be 5 + + $r2 = Invoke-NetCleanNativeCapture -FilePath $bat -ArgumentList @() -IgnoreExitCode + $r2.Succeeded | Should -BeTrue + } + } + + Context 'Get-NormalizedFilePathFromCommandLine' { + + It 'returns null for null or whitespace input' -ForEach @( + @{ CommandLine = $null } + @{ CommandLine = ' ' } + ) { + Get-NormalizedFilePathFromCommandLine -CommandLine $CommandLine | + Should -BeNullOrEmpty + } + + It 'expands environment variables before extracting an executable path' { + $env:NETCLEAN_TEST_ROOT = 'C:\Tools' + try { + Get-NormalizedFilePathFromCommandLine ` + -CommandLine '%NETCLEAN_TEST_ROOT%\agent.exe --service' | + Should -Be 'C:\Tools\agent.exe' + } + finally { + Remove-Item Env:\NETCLEAN_TEST_ROOT -ErrorAction SilentlyContinue + } + } + + It 'normalizes a SystemRoot-prefixed driver path' { + $expected = Join-Path $env:windir 'System32\drivers\agent.sys' + + Get-NormalizedFilePathFromCommandLine ` + -CommandLine '\SystemRoot\System32\drivers\agent.sys -k' | + Should -Be $expected + } + + It 'extracts quoted and unquoted executable paths' -ForEach @( + @{ + CommandLine = '"C:\Program Files\Contoso\agent.exe" --service' + Expected = 'C:\Program Files\Contoso\agent.exe' + } + @{ + CommandLine = 'C:\Tools\agent.com /quiet' + Expected = 'C:\Tools\agent.com' + } + @{ + CommandLine = 'C:\Program Files\Contoso\agent.exe --service' + Expected = 'C:\Program Files\Contoso\agent.exe' + } + ) { + Get-NormalizedFilePathFromCommandLine -CommandLine $CommandLine | + Should -Be $Expected + } + + It 'returns a trimmed fallback when no recognized binary extension is present' { + Get-NormalizedFilePathFromCommandLine -CommandLine ' "custom command" ' | + Should -Be 'custom command' + } + } + + Context 'Get-VendorRootsFromInstallPath' { + + It 'returns no roots for an empty install path' { + @(Get-VendorRootsFromInstallPath -InstallPath $null).Count | Should -Be 0 + } + + It 'derives native and WOW6432Node roots from the leaf and its parent' { + $result = Get-VendorRootsFromInstallPath ` + -InstallPath '"C:\Program Files\CrowdStrike\Falcon Sensor"' + + @($result) | Should -Be @( + 'HKLM\SOFTWARE\CrowdStrike' + 'HKLM\SOFTWARE\Falcon Sensor' + 'HKLM\SOFTWARE\WOW6432Node\CrowdStrike' + 'HKLM\SOFTWARE\WOW6432Node\Falcon Sensor' + ) + } + + It 'fails soft when the install path cannot be split' { + Mock Split-Path { throw 'invalid path' } + + @(Get-VendorRootsFromInstallPath -InstallPath 'invalid').Count | Should -Be 0 + } + } + + Context 'Test-VendorPatternMatch' { + + It 'trusts an exact inferred-vendor match' { + $evidence = [pscustomobject]@{ InferredVendor = 'CrowdStrike' } + + Test-VendorPatternMatch ` + -Vendor 'CrowdStrike' ` + -Signature @{ Patterns = @('falcon') } ` + -Evidence $evidence | + Should -BeTrue + } + + It 'matches a pattern against the properties present on sparse evidence' { + $evidence = [pscustomobject]@{ Name = 'CrowdStrike Falcon Sensor' } + + Test-VendorPatternMatch ` + -Vendor 'CrowdStrike' ` + -Signature @{ Patterns = @('falcon') } ` + -Evidence $evidence | + Should -BeTrue + } + + It 'returns false when sparse evidence has no vendor pattern' { + $evidence = [pscustomobject]@{ DisplayName = 'Unrelated network component' } + + Test-VendorPatternMatch ` + -Vendor 'CrowdStrike' ` + -Signature @{ Patterns = @('crowdstrike', 'falcon') } ` + -Evidence $evidence | + Should -BeFalse + } + } + + Context 'Get-FileMetadatum' { + + BeforeEach { + Mock Get-NormalizedFilePathFromCommandLine { $CommandLine } + } + + It 'returns null for an empty or unresolvable path' { + Get-FileMetadatum -Path $null | Should -BeNullOrEmpty + + Mock Get-NormalizedFilePathFromCommandLine { $null } + Get-FileMetadatum -Path 'unresolvable.exe' | Should -BeNullOrEmpty + } + + It 'returns a structured absent-file result' { + Mock Test-Path { $false } + + $result = Get-FileMetadatum -Path 'C:\missing\agent.exe' + + $result.Path | Should -Be 'C:\missing\agent.exe' + $result.Exists | Should -BeFalse + $result.SignatureStatus | Should -BeNullOrEmpty + } + + It 'returns null when the normalized path cannot be queried' { + Mock Test-Path { throw 'invalid path syntax' } + + Get-FileMetadatum -Path 'C:\invalid\agent.exe' | Should -BeNullOrEmpty + } + + It 'returns version, signature, and inferred-vendor metadata for an existing file' { + Mock Test-Path { $true } + Mock Get-Item { + [pscustomobject]@{ + FullName = 'C:\Program Files\CrowdStrike\sensor.exe' + Name = 'sensor.exe' + VersionInfo = [pscustomobject]@{ + CompanyName = 'CrowdStrike, Inc.' + FileDescription = 'Falcon Sensor' + ProductName = 'Falcon' + OriginalFilename = 'sensor.exe' + FileVersion = '7.1.2.3' + } + } + } + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ + Subject = 'CN=CrowdStrike' + Issuer = 'CN=Trusted Issuer' + Thumbprint = 'ABC123' + } + } + } + Mock Resolve-VendorFromText { 'CrowdStrike' } + + $result = Get-FileMetadatum -Path '"C:\Program Files\CrowdStrike\sensor.exe" --service' + + $result.Exists | Should -BeTrue + $result.CompanyName | Should -Be 'CrowdStrike, Inc.' + $result.FileVersion | Should -Be '7.1.2.3' + $result.SignerSubject | Should -Be 'CN=CrowdStrike' + $result.SignatureStatus | Should -Be 'Valid' + $result.InferredVendor | Should -Be 'CrowdStrike' + } + + It 'retains file metadata when Authenticode inspection fails' { + Mock Test-Path { $true } + Mock Get-Item { + [pscustomobject]@{ + FullName = 'C:\Tools\unsigned.exe' + Name = 'unsigned.exe' + VersionInfo = $null + } + } + Mock Get-AuthenticodeSignature { throw 'signature provider unavailable' } + Mock Resolve-VendorFromText { $null } + + $result = Get-FileMetadatum -Path 'C:\Tools\unsigned.exe' + + $result.Exists | Should -BeTrue + $result.CompanyName | Should -BeNullOrEmpty + $result.SignerSubject | Should -BeNullOrEmpty + $result.SignatureStatus | Should -BeNullOrEmpty + } + + It 'returns null when an existing file cannot be read' { + Mock Test-Path { $true } + Mock Get-Item { throw 'access denied' } + + Get-FileMetadatum -Path 'C:\protected\agent.exe' | Should -BeNullOrEmpty + } + } + + Context 'Get-ServiceRegistryMap' { + + It 'maps service values and existing child registry paths by normalized service name' { + Mock Get-RegistryChildKeyNamesSafe { @('CSFalconService', 'MinimalService') } + Mock Get-RegistryValuesSafe { + if ($RegistryPath -like '*CSFalconService') { + [pscustomobject]@{ + ImagePath = 'C:\Program Files\CrowdStrike\sensor.exe' + DisplayName = 'CrowdStrike Falcon Sensor' + Type = 16 + Start = 2 + Group = 'NetworkProvider' + } + } + } + Mock Test-RegistryPathExist { + $RegistryPath -match '\\(Enum|Parameters)$' + } + + $result = Get-ServiceRegistryMap + + $result.Count | Should -Be 2 + $result.ContainsKey('csfalconservice') | Should -BeTrue + $result.csfalconservice.DisplayName | Should -Be 'CrowdStrike Falcon Sensor' + $result.csfalconservice.EnumPath | Should -Match '\\Enum$' + $result.csfalconservice.LinkagePath | Should -BeNullOrEmpty + $result.minimalservice.ImagePath | Should -BeNullOrEmpty + Should -Invoke Test-RegistryPathExist -Times 8 + } + + It 'returns an empty map when the services root has no children' { + Mock Get-RegistryChildKeyNamesSafe { @() } + + $result = Get-ServiceRegistryMap + + $result | Should -BeOfType [hashtable] + $result.Count | Should -Be 0 + } + } + + Context 'Get-AdapterRegistryCorrelation' { + + It 'correlates valid and fallback interface identifiers while skipping unusable class entries' { + Mock Get-RegistryChildKeyNamesSafe { @('Metadata', '0000', '0001', '0002', '0003') } + Mock Get-RegistryValuesSafe { + switch -Wildcard ($RegistryPath) { + '*\0000' { return $null } + '*\0001' { + return [pscustomobject]@{ + ComponentId = 'crowdstrike_filter' + DriverDesc = 'CrowdStrike Network Filter' + ProviderName = 'CrowdStrike, Inc.' + NetCfgInstanceId = '{11111111-2222-3333-4444-555555555555}' + } + } + '*\0002' { + return [pscustomobject]@{ + ComponentId = 'contoso_filter' + DriverDesc = 'Contoso Filter' + ProviderName = 'Contoso' + NetCfgInstanceId = '{NOT-A-GUID}' + } + } + '*\0003' { + return [pscustomobject]@{ + ComponentId = 'no_interface' + DriverDesc = 'No Interface' + ProviderName = 'Contoso' + NetCfgInstanceId = $null + } + } + } + } + Mock Test-RegistryPathExist { $true } + + $result = @(Get-AdapterRegistryCorrelation) + + $result.Count | Should -Be 2 + $result[0].InterfaceGuid | Should -Be '11111111-2222-3333-4444-555555555555' + $result[1].InterfaceGuid | Should -Be 'not-a-guid' + $result[0].ConnectionPath | Should -Match '\\Connection$' + $result[0].TcpipPath | Should -Match '\\Interfaces\\\{11111111-2222-3333-4444-555555555555\}$' + Should -Invoke Test-RegistryPathExist -Times 6 + } + + It 'returns empty when no adapter class entries are present' { + Mock Get-RegistryChildKeyNamesSafe { @() } + + @(Get-AdapterRegistryCorrelation).Count | Should -Be 0 + } + } + } +} diff --git a/tests/Unit/NetClean.Coverage.Unit.Tests.ps1 b/tests/Unit/NetClean.Coverage.Unit.Tests.ps1 new file mode 100644 index 0000000..806a206 --- /dev/null +++ b/tests/Unit/NetClean.Coverage.Unit.Tests.ps1 @@ -0,0 +1,54 @@ +Describe 'NetClean coverage runner' { + BeforeAll { + $script:runnerPath = Join-Path $PSScriptRoot '..\Run-NetClean-Coverage.ps1' + $script:runnerText = Get-Content -LiteralPath $script:runnerPath -Raw + $ciPath = Join-Path $PSScriptRoot '..\..\.github\workflows\ci.yml' + $script:ciText = Get-Content -LiteralPath $ciPath -Raw + } + + It 'rejects test files as coverage sources' { + { + & $script:runnerPath ` + -CoveragePath $PSCommandPath ` + -OutputPath $TestDrive + } | Should -Throw '*must not include files under tests*' + } + + It 'pins the selected stable Pester version' { + $script:runnerText | Should -Match "\[version\]'6\.0\.1'" + } + + It 'treats discovery and container failures as fatal' { + $script:runnerText | Should -Match '\$config\.Run\.Throw\s*=\s*\$true' + } + + It 'includes isolated system-component tests in the coverage run' { + $script:runnerText | Should -Match 'Join-Path\s+\$testsPath\s+''System''' + } + + It 'ratchets the runner and pull-request overall coverage gates together' { + $script:runnerText | Should -Match '\[double\]\$MinimumCoverage\s*=\s*94\.0' + $script:runnerText | Should -Match '\$config\.CodeCoverage\.CoveragePercentTarget\s*=\s*\$MinimumCoverage' + $script:ciText | Should -Match 'Run-NetClean-Coverage\.ps1\s+-MinimumCoverage\s+94' + $script:ciText | Should -Match 'min-coverage-overall:\s+94' + } + + It 'pins workflow actions to immutable Node.js 24 releases' { + $script:ciText | Should -Match 'actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8\s+# v6\.0\.1' + $script:ciText | Should -Match 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a\s+# v7\.0\.1' + $script:ciText | Should -Match 'actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3\s+# v8\.0\.0' + $script:ciText | Should -Match 'madrapps/jacoco-report@e51ce1f46f7f8b5331593f935e59cbaf44b84920\s+# v1\.8\.0' + } + + It 'runs the complete suite under Windows PowerShell 5.1 in CI' { + $jobPattern = '(?ms)^ windows-powershell-compatibility:.*?^ comment-coverage:' + $jobMatch = [regex]::Match($script:ciText, $jobPattern) + $jobMatch.Success | Should -BeTrue + $compatibilityJob = $jobMatch.Value + + $compatibilityJob | Should -Match 'shell:\s+powershell' + $compatibilityJob | Should -Match 'Install-Module Pester[^\r\n]+RequiredVersion 6\.0\.1' + $compatibilityJob | Should -Match 'Invoke-Pester' + $compatibilityJob | Should -Match 'Test-ModuleManifest' + } +} diff --git a/tests/Unit/NetClean.Phase1.Unit.Tests.ps1 b/tests/Unit/NetClean.Phase1.Unit.Tests.ps1 new file mode 100644 index 0000000..5930c4b --- /dev/null +++ b/tests/Unit/NetClean.Phase1.Unit.Tests.ps1 @@ -0,0 +1,980 @@ +$manifestPath = Join-Path $PSScriptRoot '..\..\NetClean.psd1' + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" +} + +Remove-Module NetClean -ErrorAction SilentlyContinue +Import-Module $manifestPath -Force + +Describe 'NetClean Phase 1 unit tests' { + + InModuleScope 'NetClean' { + + Context 'Resolve-VendorFromText' { + + It 'returns the matched vendor when known text is present' { + $result = Resolve-VendorFromText -Text 'CrowdStrike Falcon Sensor service' + $result | Should -Be 'CrowdStrike' + } + + It 'returns null when no vendor text matches' { + $result = Resolve-VendorFromText -Text 'Some random text without a known vendor' + $result | Should -BeNullOrEmpty + } + + It 'returns null when input text is null' { + $result = Resolve-VendorFromText -Text $null + $result | Should -BeNullOrEmpty + } + } + + Context 'Get-VendorSignature' { + + It 'returns the normalized signature table' { + $result = Get-VendorSignature + + $result | Should -BeOfType [hashtable] + $result.ContainsKey('CrowdStrike') | Should -BeTrue + } + + It 'includes the fields required for vendor matching and protection' { + $signature = (Get-VendorSignature)['CrowdStrike'] + + @($signature.Patterns).Count | Should -BeGreaterThan 0 + @($signature.Categories).Count | Should -BeGreaterThan 0 + @($signature.RegistryRoots) | Should -Contain 'HKLM\SOFTWARE\CrowdStrike' + } + } + + Context 'Get-WfpStateEvidence' { + + It 'returns evidence when firewall/WFP state is present' { + Mock netsh {} + Mock Test-Path { $true } + Mock Get-Content { 'CrowdStrike WFP Provider' } + Mock Remove-Item {} + + $result = Get-WfpStateEvidence + $result | Should -Not -BeNullOrEmpty + $result[0].InferredVendor | Should -Be 'CrowdStrike' + } + + It 'returns empty when no WFP state evidence is present' { + Mock netsh {} + Mock Test-Path { $false } + + $result = @(Get-WfpStateEvidence) + $result.Count | Should -Be 0 + } + } + + Context 'Get-NdisFilterClassEvidence' { + + It 'returns evidence when NDIS filter classes are detected' { + Mock Get-RegistryChildKeyNamesSafe { @('0001') } + Mock Get-RegistryValuesSafe { + [pscustomobject]@{ + FilterClass = 'compression' + ComponentId = 'CrowdStrikeFilter' + } + } + + $result = @(Get-NdisFilterClassEvidence) + $result.Count | Should -BeGreaterThan 0 + } + + It 'returns empty when filter keys are absent' { + Mock Get-RegistryChildKeyNamesSafe { @() } + + $result = @(Get-NdisFilterClassEvidence) + $result.Count | Should -Be 0 + } + } + + Context 'Get-NdisServiceBindingEvidence' { + + It 'returns evidence when service bindings are present' { + Mock Get-RegistryChildKeyNamesSafe { @('CSFalconService') } + Mock Get-RegistryValuesSafe { + [pscustomobject]@{ + Bind = @('CSFalconService') + } + } + + $result = @(Get-NdisServiceBindingEvidence) + $result.Count | Should -BeGreaterThan 0 + } + + It 'returns empty when no service bindings are found' { + Mock Get-RegistryChildKeyNamesSafe { @() } + + $result = @(Get-NdisServiceBindingEvidence) + $result.Count | Should -Be 0 + } + + It 'preserves service metadata plus bind, export, and route linkage evidence' { + Mock Get-RegistryChildKeyNamesSafe { @('ContosoFilter') } + Mock Get-RegistryValuesSafe { + if ($RegistryPath -like '*\Linkage') { + [pscustomobject]@{ + Bind = @($null, '', '\Device\ContosoFilter') + Export = @($null, '\Device\ContosoExport') + Route = @('', 'ContosoVpnRoute') + } + } + else { + [pscustomobject]@{ + DisplayName = 'Contoso NDIS Filter' + Group = 'NDIS' + ImagePath = 'C:\Windows\System32\drivers\contoso.sys' + } + } + } + Mock Resolve-VendorFromText { 'Contoso' } + + $result = @(Get-NdisServiceBindingEvidence) + + $result.Count | Should -Be 1 + $result[0].DisplayName | Should -Be 'Contoso NDIS Filter' + $result[0].Path | Should -Be 'C:\Windows\System32\drivers\contoso.sys' + $result[0].InferredVendor | Should -Be 'Contoso' + @($result[0].Instance.Linkage.Export) | Should -Contain '\Device\ContosoExport' + @($result[0].Instance.Linkage.Route) | Should -Contain 'ContosoVpnRoute' + } + + It 'falls back to the service name when the display name is blank' { + Mock Get-RegistryChildKeyNamesSafe { @('PacketFilter') } + Mock Get-RegistryValuesSafe { + if ($RegistryPath -like '*\Linkage') { + [pscustomobject]@{ Bind = @('packet filter') } + } + else { + [pscustomobject]@{ DisplayName = ' '; Group = ''; ImagePath = $null } + } + } + + $result = @(Get-NdisServiceBindingEvidence) + + $result.Count | Should -Be 1 + $result[0].DisplayName | Should -Be 'PacketFilter' + } + } + + Context 'Get-MsiRegistryEvidence' { + + It 'returns evidence when MSI uninstall entries exist' { + Mock Get-RegistryChildKeyNamesSafe { @('{ABC-123}') } + Mock Get-RegistryValuesSafe { + [pscustomobject]@{ + DisplayName = 'CrowdStrike Falcon Sensor' + Publisher = 'CrowdStrike' + UninstallString = 'msiexec /x {ABC-123}' + } + } + + $result = @(Get-MsiRegistryEvidence) + $result.Count | Should -BeGreaterThan 0 + } + + It 'returns empty when no uninstall entries exist' { + Mock Get-RegistryChildKeyNamesSafe { @() } + + $result = @(Get-MsiRegistryEvidence) + $result.Count | Should -Be 0 + } + } + + Context 'Get-InfFileEvidence' { + + It 'returns evidence when INF files contain vendor text' { + Mock Test-Path { $true } + Mock Get-ChildItem { + @([pscustomobject]@{ + Name = 'oem42.inf' + FullName = 'C:\Windows\INF\oem42.inf' + }) + } + Mock Get-Content { @('Provider = CrowdStrike') } + + $result = @(Get-InfFileEvidence) + $result.Count | Should -BeGreaterThan 0 + } + + It 'returns empty when no INF files match' { + Mock Get-ChildItem { @() } + + $result = @(Get-InfFileEvidence) + $result.Count | Should -Be 0 + } + } + + Context 'Get-ScheduledTaskEvidence' { + + It 'returns evidence when a scheduled task contains known vendor text' { + Mock Get-ScheduledTask { + @( + [pscustomobject]@{ + TaskName = 'CrowdStrikeSensorTask' + TaskPath = '\' + Actions = @( + [pscustomobject]@{ + Execute = 'C:\Program Files\CrowdStrike\sensor.exe' + Arguments = '' + WorkingDirectory = 'C:\Program Files\CrowdStrike' + } + ) + } + ) + } + + $result = @(Get-ScheduledTaskEvidence) + $result.Count | Should -BeGreaterThan 0 + } + + It 'returns empty when no scheduled tasks are found' { + Mock Get-ScheduledTask { @() } + + $result = @(Get-ScheduledTaskEvidence) + $result.Count | Should -Be 0 + } + } + + Context 'Get-AppxPackageEvidence' { + + It 'returns evidence when an app package contains known vendor text' { + Mock Get-AppxPackage { + @( + [pscustomobject]@{ + Name = 'Cisco.SecureClient' + PackageFamilyName = 'Cisco.SecureClient_abc123' + PublisherDisplayName = 'Cisco' + InstallLocation = 'C:\Program Files\WindowsApps\Cisco.SecureClient' + Publisher = 'CN=Cisco' + } + ) + } + + $result = @(Get-AppxPackageEvidence) + $result.Count | Should -BeGreaterThan 0 + } + + It 'returns empty when no matching app packages are found' { + Mock Get-AppxPackage { @() } + + $result = @(Get-AppxPackageEvidence) + $result.Count | Should -Be 0 + } + } + + Context 'Get-ProtectionEvidence' { + + BeforeEach { + Mock Get-CimInstance { @() } + Mock Get-ItemProperty { @() } + Mock Get-NetAdapter { @() } + Mock Get-PnpDevice { @() } + Mock Get-RegistryChildKeyNamesSafe { @() } + Mock Get-WfpStateEvidence { @() } + Mock Get-NdisFilterClassEvidence { @() } + Mock Get-NdisServiceBindingEvidence { @() } + Mock Get-MsiRegistryEvidence { @() } + Mock Get-InfFileEvidence { @() } + Mock Get-ScheduledTaskEvidence { @() } + Mock Get-AppxPackageEvidence { @() } + } + + It 'aggregates evidence from all enabled evidence sources' { + Mock Get-WfpStateEvidence { @([pscustomobject]@{ Vendor = 'CrowdStrike'; Evidence = 'WFP' }) } + Mock Get-NdisFilterClassEvidence { @([pscustomobject]@{ Vendor = 'CrowdStrike'; Evidence = 'NDIS' }) } + Mock Get-NdisServiceBindingEvidence { @([pscustomobject]@{ Vendor = 'CrowdStrike'; Evidence = 'Binding' }) } + Mock Get-MsiRegistryEvidence { @([pscustomobject]@{ Vendor = 'CrowdStrike'; Evidence = 'MSI' }) } + Mock Get-InfFileEvidence { @([pscustomobject]@{ Vendor = 'CrowdStrike'; Evidence = 'INF' }) } + Mock Get-ScheduledTaskEvidence { @([pscustomobject]@{ Vendor = 'CrowdStrike'; Evidence = 'Task' }) } + Mock Get-AppxPackageEvidence { @([pscustomobject]@{ Vendor = 'CrowdStrike'; Evidence = 'AppX' }) } + + $result = @(Get-ProtectionEvidence) + $result.Count | Should -Be 7 + } + + It 'returns empty when no evidence sources produce results' { + $result = @(Get-ProtectionEvidence) + $result.Count | Should -Be 0 + } + + It 'collects Security Center antivirus and firewall evidence with metadata fallbacks' { + Mock Get-CimInstance { + [pscustomobject]@{ + displayName = 'CrowdStrike Falcon Sensor' + pathToSignedProductExe = 'C:\Program Files\CrowdStrike\sensor.exe' + } + } -ParameterFilter { $Namespace -eq 'root/SecurityCenter2' -and $ClassName -eq 'AntivirusProduct' } + Mock Get-CimInstance { + [pscustomobject]@{ + displayName = 'Contoso Firewall' + pathToSignedProductExe = 'C:\Program Files\Contoso\firewall.exe' + } + } -ParameterFilter { $Namespace -eq 'root/SecurityCenter2' -and $ClassName -eq 'FirewallProduct' } + Mock Get-FileMetadatum { + if ($Path -like '*sensor.exe') { + [pscustomobject]@{ + CompanyName = 'CrowdStrike, Inc.' + FileDescription = 'Falcon Sensor' + ProductName = 'Falcon' + SignerSubject = 'CN=CrowdStrike' + InferredVendor = 'CrowdStrike' + } + } + } + Mock Resolve-VendorFromText { 'Contoso' } + + $result = @(Get-ProtectionEvidence) + $antivirus = $result | Where-Object ProductClass -EQ 'AntivirusProduct' + $firewall = $result | Where-Object ProductClass -EQ 'FirewallProduct' + + $antivirus.CompanyName | Should -Be 'CrowdStrike, Inc.' + $antivirus.InferredVendor | Should -Be 'CrowdStrike' + $firewall.CompanyName | Should -BeNullOrEmpty + $firewall.InferredVendor | Should -Be 'Contoso' + } + + It 'collects service and driver evidence with operational state' { + Mock Get-CimInstance { + [pscustomobject]@{ + Name = 'CSFalconService' + DisplayName = 'CrowdStrike Falcon Sensor' + PathName = 'C:\Program Files\CrowdStrike\sensor.exe' + State = 'Running' + StartMode = 'Auto' + ServiceType = 'Own Process' + } + } -ParameterFilter { $ClassName -eq 'Win32_Service' } + Mock Get-CimInstance { + [pscustomobject]@{ + Name = 'ContosoFilter' + DisplayName = 'Contoso Filter Driver' + PathName = 'C:\Windows\System32\drivers\contoso.sys' + State = 'Running' + StartMode = 'System' + ServiceType = 'Kernel Driver' + } + } -ParameterFilter { $ClassName -eq 'Win32_SystemDriver' } + Mock Get-FileMetadatum { + if ($Path -like '*sensor.exe') { + [pscustomobject]@{ + CompanyName = 'CrowdStrike, Inc.' + FileDescription = 'Falcon Sensor' + ProductName = 'Falcon' + SignerSubject = 'CN=CrowdStrike' + InferredVendor = 'CrowdStrike' + } + } + } + Mock Resolve-VendorFromText { 'Contoso' } + + $result = @(Get-ProtectionEvidence) + $service = $result | Where-Object Source -EQ 'Service' + $driver = $result | Where-Object Source -EQ 'Driver' + + $service.State | Should -Be 'Running' + $service.InferredVendor | Should -Be 'CrowdStrike' + $driver.ServiceType | Should -Be 'Kernel Driver' + $driver.InferredVendor | Should -Be 'Contoso' + } + + It 'falls back to textual vendor detection when file metadata has no inferred vendor' { + Mock Get-CimInstance { + [pscustomobject]@{ + displayName = 'Contoso Endpoint' + pathToSignedProductExe = 'C:\Contoso\endpoint.exe' + } + } -ParameterFilter { $Namespace -eq 'root/SecurityCenter2' -and $ClassName -eq 'AntivirusProduct' } + Mock Get-CimInstance { + [pscustomobject]@{ + displayName = 'Contoso Firewall' + pathToSignedProductExe = 'C:\Contoso\firewall.exe' + } + } -ParameterFilter { $Namespace -eq 'root/SecurityCenter2' -and $ClassName -eq 'FirewallProduct' } + Mock Get-CimInstance { + [pscustomobject]@{ + Name = 'ContosoService'; DisplayName = 'Contoso Service' + PathName = 'C:\Contoso\service.exe'; State = 'Running' + StartMode = 'Auto'; ServiceType = 'Own Process' + } + } -ParameterFilter { $ClassName -eq 'Win32_Service' } + Mock Get-CimInstance { + [pscustomobject]@{ + Name = 'ContosoDriver'; DisplayName = 'Contoso Driver' + PathName = 'C:\Contoso\driver.sys'; State = 'Running' + StartMode = 'System'; ServiceType = 'Kernel Driver' + } + } -ParameterFilter { $ClassName -eq 'Win32_SystemDriver' } + Mock Get-FileMetadatum { + [pscustomobject]@{ + CompanyName = 'Contoso'; FileDescription = 'Endpoint component' + ProductName = 'Contoso Endpoint'; SignerSubject = 'CN=Contoso' + InferredVendor = $null + } + } + Mock Resolve-VendorFromText { 'Contoso' } + + $result = @(Get-ProtectionEvidence) + + @($result | Where-Object { + $_.Source -in @('SecurityCenter2', 'Service', 'Driver') -and + $_.InferredVendor -eq 'Contoso' + }).Count | Should -Be 4 + } + + It 'collects uninstall evidence with metadata and registry fallbacks' { + Mock Get-ItemProperty { + @( + [pscustomobject]@{ + DisplayName = 'CrowdStrike Falcon Sensor' + DisplayIcon = 'C:\Program Files\CrowdStrike\sensor.exe,0' + Publisher = 'CrowdStrike, Inc.' + InstallLocation = 'C:\Program Files\CrowdStrike' + UninstallString = 'msiexec /x {FALCON}' + }, + [pscustomobject]@{ + DisplayName = 'Contoso Secure Client' + DisplayIcon = $null + Publisher = 'Contoso' + InstallLocation = 'C:\Program Files\Contoso' + UninstallString = 'uninstall.exe' + } + ) + } -ParameterFilter { $Path -notlike '*WOW6432Node*' } + Mock Get-FileMetadatum { + [pscustomobject]@{ + CompanyName = 'CrowdStrike, Inc.' + FileDescription = 'Falcon Sensor' + ProductName = 'Falcon' + SignerSubject = 'CN=CrowdStrike' + InferredVendor = 'CrowdStrike' + } + } + Mock Resolve-VendorFromText { 'Contoso' } + + $result = @(Get-ProtectionEvidence | Where-Object Source -EQ 'Uninstall') + + $result.Count | Should -Be 2 + ($result | Where-Object Name -EQ 'CrowdStrike Falcon Sensor').ProductName | Should -Be 'Falcon' + ($result | Where-Object Name -EQ 'Contoso Secure Client').CompanyName | Should -Be 'Contoso' + ($result | Where-Object Name -EQ 'Contoso Secure Client').InferredVendor | Should -Be 'Contoso' + } + + It 'normalizes adapter GUIDs and preserves adapter identity evidence' { + Mock Get-NetAdapter { + @( + [pscustomobject]@{ + Name = 'Falcon Adapter' + InterfaceDescription = 'CrowdStrike Network Filter' + DriverDescription = 'CrowdStrike Driver' + DriverFileName = 'csfilter.sys' + InterfaceGuid = [pscustomobject]@{ Guid = [guid]'11111111-2222-3333-4444-555555555555' } + MacAddress = '00-11-22-33-44-55' + Status = 'Up' + }, + [pscustomobject]@{ + Name = 'Contoso Adapter' + InterfaceDescription = 'Contoso VPN' + DriverDescription = 'Contoso Driver' + DriverFileName = 'contoso.sys' + InterfaceGuid = '{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}' + MacAddress = 'AA-BB-CC-DD-EE-FF' + Status = 'Disconnected' + }, + [pscustomobject]@{ + Name = 'Unknown Adapter' + InterfaceDescription = 'Unknown' + DriverDescription = 'Unknown' + DriverFileName = 'unknown.sys' + InterfaceGuid = $null + MacAddress = $null + Status = 'Not Present' + } + ) + } + Mock Resolve-VendorFromText { 'DetectedVendor' } + + $result = @(Get-ProtectionEvidence | Where-Object Source -EQ 'NetAdapter') + + $result.Count | Should -Be 3 + $result[0].InterfaceGuid | Should -Be '11111111-2222-3333-4444-555555555555' + $result[1].InterfaceGuid | Should -Be 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + $result[2].InterfaceGuid | Should -BeNullOrEmpty + $result[0].InferredVendor | Should -Be 'DetectedVendor' + } + + It 'collects PnP network-device evidence' { + Mock Get-PnpDevice { + [pscustomobject]@{ + FriendlyName = 'Cisco Secure Client Adapter' + Manufacturer = 'Cisco Systems' + InstanceId = 'ROOT\NET\0001' + Status = 'OK' + Class = 'Net' + } + } + Mock Resolve-VendorFromText { 'Cisco' } + + $result = @(Get-ProtectionEvidence | Where-Object Source -EQ 'PnpDevice') + + $result.Count | Should -Be 1 + $result[0].Manufacturer | Should -Be 'Cisco Systems' + $result[0].InstanceId | Should -Be 'ROOT\NET\0001' + $result[0].InferredVendor | Should -Be 'Cisco' + } + + It 'collects service-registry evidence and skips unreadable service keys' { + Mock Get-RegistryChildKeyNamesSafe { @('UnreadableService', 'CSFalconService') } + Mock Get-RegistryValuesSafe { + if ($RegistryPath -like '*CSFalconService') { + [pscustomobject]@{ + ImagePath = 'C:\Program Files\CrowdStrike\sensor.exe' + DisplayName = 'CrowdStrike Falcon Sensor' + Start = 2 + Type = 16 + Group = 'NetworkProvider' + } + } + } + Mock Get-FileMetadatum { + [pscustomobject]@{ + CompanyName = 'CrowdStrike, Inc.' + FileDescription = 'Falcon Sensor' + ProductName = 'Falcon' + SignerSubject = 'CN=CrowdStrike' + InferredVendor = 'CrowdStrike' + } + } + + $result = @(Get-ProtectionEvidence | Where-Object Source -EQ 'ServiceRegistry') + + $result.Count | Should -Be 1 + $result[0].Name | Should -Be 'CSFalconService' + $result[0].ServiceRegistryPath | Should -Be 'HKLM\SYSTEM\CurrentControlSet\Services\CSFalconService' + $result[0].Start | Should -Be 2 + } + + It 'isolates unavailable native evidence sources and retains supplemental evidence' { + Mock Get-CimInstance { throw 'cim-failure' } + Mock Get-ItemProperty { throw 'registry-failure' } + Mock Get-NetAdapter { throw 'adapter-failure' } + Mock Get-PnpDevice { throw 'pnp-failure' } + Mock Get-RegistryChildKeyNamesSafe { throw 'service-registry-failure' } + Mock Get-WfpStateEvidence { @([pscustomobject]@{ Vendor = 'CrowdStrike'; Evidence = 'WFP' }) } + + $res = @(Get-ProtectionEvidence) + $res.Count | Should -Be 1 + $res[0].Evidence | Should -Be 'WFP' + } + } + + Context 'Get-ProtectionInventory' { + + BeforeEach { + Mock Get-VendorSignature { + @{ + CrowdStrike = @{ + Categories = @('EDR') + Patterns = @('crowdstrike', 'csfalcon') + RegistryRoots = @('HKLM\SOFTWARE\CrowdStrike') + } + } + } + Mock Get-ServiceRegistryMap { @{} } + Mock Get-AdapterRegistryCorrelation { @() } + } + + It 'builds vendor inventory from evidence' { + Mock Get-ProtectionEvidence { + @( + [pscustomobject]@{ + Source = 'Service' + ProductClass = 'Service' + Name = 'CSFalconService' + DisplayName = 'CrowdStrike Falcon Sensor' + Path = 'C:\Program Files\CrowdStrike\sensor.exe' + Publisher = 'CrowdStrike' + InstallPath = $null + InterfaceDescription = $null + Manufacturer = 'CrowdStrike' + CompanyName = 'CrowdStrike' + FileDescription = 'Falcon Sensor' + ProductName = 'CrowdStrike Falcon Sensor' + SignerSubject = 'CN=CrowdStrike' + InferredVendor = 'CrowdStrike' + Instance = $null + } + ) + } + + $result = @(Get-ProtectionInventory) + + $result.Count | Should -Be 1 + $result[0].Vendor | Should -Be 'CrowdStrike' + @($result[0].Services) | Should -Contain 'CSFalconService' + } + + It 'returns empty inventory when no evidence exists' { + Mock Get-ProtectionEvidence { @() } + + $result = @(Get-ProtectionInventory) + $result.Count | Should -Be 0 + } + + It 'correlates service, driver, adapter, registry, and filter evidence into protection boundaries' { + $script:complexEvidence = @( + @{ Source = 'Service'; Name = 'vmfalconvpnfire' } + @{ Source = 'ServiceRegistry'; Name = 'RegistryService'; RegistryPath = 'HKLM\SOFTWARE\CrowdStrike\Service' } + @{ Source = 'NDIS'; Name = 'NdisFilter'; RegistryPath = 'HKLM\SYSTEM\CurrentControlSet\Services\NdisFilter' } + @{ Source = 'Driver'; Name = 'vboxdriver' } + @{ + Source = 'NetAdapter' + Name = 'FalconAdapter' + DisplayName = 'VMware VPN Adapter' + InterfaceDescription = 'vEthernet VMware' + InterfaceGuid = '11111111-2222-3333-4444-555555555555' + } + @{ Source = 'WFP'; Name = 'FalconProvider' } + @{ Source = 'UnknownSource'; Name = 'AdditionalEvidence' } + ) | ForEach-Object { + $record = [ordered]@{ + Source = $_['Source'] + Name = $_['Name'] + DisplayName = $_['DisplayName'] + Path = $null + InterfaceDescription = $_['InterfaceDescription'] + CompanyName = 'CrowdStrike' + InferredVendor = 'CrowdStrike' + InstallPath = $null + } + foreach ($optionalProperty in @('RegistryPath', 'InterfaceGuid')) { + if ($_.ContainsKey($optionalProperty)) { + $record[$optionalProperty] = $_[$optionalProperty] + } + } + [pscustomobject]$record + } + Mock Get-ProtectionEvidence { $script:complexEvidence } + Mock Test-VendorPatternMatch { $true } + Mock Get-ServiceRegistryMap { + @{ + vmfalconvpnfire = [pscustomobject]@{ + RegistryPath = 'HKLM\SYSTEM\CurrentControlSet\Services\vmfalconvpnfire' + EnumPath = 'HKLM\SYSTEM\CurrentControlSet\Services\vmfalconvpnfire\Enum' + LinkagePath = 'HKLM\SYSTEM\CurrentControlSet\Services\vmfalconvpnfire\Linkage' + ParamsPath = 'HKLM\SYSTEM\CurrentControlSet\Services\vmfalconvpnfire\Parameters' + InstancesPath = 'HKLM\SYSTEM\CurrentControlSet\Services\vmfalconvpnfire\Instances' + ImagePath = 'C:\Program Files\CrowdStrike\sensor.exe' + } + vboxdriver = [pscustomobject]@{ + RegistryPath = 'HKLM\SYSTEM\CurrentControlSet\Services\vboxdriver' + EnumPath = $null + LinkagePath = 'HKLM\SYSTEM\CurrentControlSet\Services\vboxdriver\Linkage' + ParamsPath = $null + InstancesPath = $null + ImagePath = 'C:\Windows\System32\drivers\vboxdriver.sys' + } + } + } + Mock Get-AdapterRegistryCorrelation { + [pscustomobject]@{ + ComponentId = 'crowdstrike_filter' + DriverDesc = 'CrowdStrike Network Adapter' + ProviderName = 'CrowdStrike, Inc.' + InterfaceGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + ClassPath = 'HKLM\SYSTEM\Class\0001' + NetworkPath = 'HKLM\SYSTEM\Network\{aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}' + ConnectionPath = 'HKLM\SYSTEM\Network\{aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}\Connection' + TcpipPath = 'HKLM\SYSTEM\Tcpip\{aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}' + } + } + Mock Get-FileMetadatum { [pscustomobject]@{ Path = 'C:\Program Files\CrowdStrike\sensor.exe' } } + + $result = @(Get-ProtectionInventory) + + $result.Count | Should -Be 1 + @($result[0].Services) | Should -Contain 'vmfalconvpnfire' + @($result[0].Services) | Should -Contain 'NdisFilter' + @($result[0].Drivers) | Should -Contain 'vboxdriver' + @($result[0].ProtectedInterfaceGuids) | Should -Contain '11111111-2222-3333-4444-555555555555' + @($result[0].ProtectedInterfaceGuids) | Should -Contain 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + @($result[0].RegistryKeys) | Should -Contain 'HKLM\SYSTEM\CurrentControlSet\Services\vmfalconvpnfire\Parameters' + @($result[0].RegistryKeys) | Should -Contain 'HKLM\SYSTEM\Class\0001' + @($result[0].Categories) | Should -Contain 'NetworkFilter' + @($result[0].Categories) | Should -Contain 'Hypervisor' + @($result[0].Categories) | Should -Contain 'VPN' + $result[0].Confidence | Should -Be 100 + } + + It 'infers VirtualBox, Sentinel, Defender, and Hyper-V protection categories' { + $script:categoryEvidence = @( + @{ Source = 'Service'; Name = 'vboxfilter'; DisplayName = 'VirtualBox Filter' } + @{ Source = 'Service'; Name = 'SentinelAgent'; DisplayName = 'Sentinel Agent' } + @{ Source = 'Service'; Name = 'DefenderService'; DisplayName = 'Defender Service' } + @{ Source = 'NetAdapter'; Name = 'VirtualBox'; DisplayName = 'VirtualBox Adapter'; InterfaceDescription = 'VirtualBox Host-Only Ethernet Adapter' } + @{ Source = 'NetAdapter'; Name = 'VBox'; DisplayName = 'VBox Adapter'; InterfaceDescription = 'VBox Network Adapter' } + @{ Source = 'NetAdapter'; Name = 'Hyper-V'; DisplayName = 'Hyper-V Adapter'; InterfaceDescription = 'Hyper-V Virtual Ethernet Adapter' } + ) | ForEach-Object { + [pscustomobject]@{ + Source = $_.Source + Name = $_.Name + DisplayName = $_.DisplayName + Path = $null + InterfaceDescription = $_['InterfaceDescription'] + CompanyName = 'CrowdStrike' + InferredVendor = 'CrowdStrike' + InstallPath = $null + } + } + Mock Get-ProtectionEvidence { $script:categoryEvidence } + Mock Test-VendorPatternMatch { $true } + + $result = @(Get-ProtectionInventory) + + @($result[0].Categories) | Should -Contain 'VirtualAdapter' + @($result[0].Categories) | Should -Contain 'Hypervisor' + @($result[0].Categories) | Should -Contain 'EDR' + @($result[0].Categories) | Should -Contain 'XDR' + @($result[0].Categories) | Should -Contain 'AV' + } + } + + Context 'Get-ProtectionRegistryMap' { + + It 'returns a registry map based on inventory' { + Mock Get-ProtectionInventory { + @( + [pscustomobject]@{ + Vendor = 'CrowdStrike' + Categories = @('EDR') + Confidence = 100 + Services = @('CSFalconService') + Drivers = @('csagent') + Adapters = @('CrowdStrike Adapter') + ProtectedInterfaceGuids = @('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + RegistryKeys = @('HKLM\SOFTWARE\CrowdStrike') + Evidence = @('Service | CSFalconService') + } + ) + } + + $result = @(Get-ProtectionRegistryMap) + $result.Count | Should -Be 1 + $result[0].Vendor | Should -Be 'CrowdStrike' + @($result[0].RegistryKeys) | Should -Contain 'HKLM\SOFTWARE\CrowdStrike' + } + + It 'returns empty when inventory is empty' { + Mock Get-ProtectionInventory { @() } + + $result = @(Get-ProtectionRegistryMap) + $result.Count | Should -Be 0 + } + } + + Context 'Get-ProtectedInterfaceGuidSet' { + + It 'returns distinct protected interface GUIDs from inventory' { + Mock Get-ProtectionInventory { + @( + [pscustomobject]@{ + Vendor = 'CrowdStrike' + ProtectedInterfaceGuids = @( + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + ) + } + [pscustomobject]@{ + Vendor = 'Cisco' + ProtectedInterfaceGuids = @('11111111-2222-3333-4444-555555555555') + } + ) + } + + $result = @(Get-ProtectedInterfaceGuidSet) + $result.Count | Should -Be 2 + $result | Should -Contain 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + $result | Should -Contain '11111111-2222-3333-4444-555555555555' + } + + It 'returns empty when no protected GUIDs exist' { + Mock Get-ProtectionInventory { @() } + + $result = @(Get-ProtectedInterfaceGuidSet) + $result.Count | Should -Be 0 + } + } + + Context 'Get-NetworkPrivacyArtifactCandidate' { + + It 'returns candidate artifacts when registry paths exist' { + Mock Test-RegistryPathExist { $true } + Mock Get-RegistryChildKeyNamesSafe { @('Profile1') } + + $result = @(Get-NetworkPrivacyArtifactCandidate -Inventory @()) + $result.Count | Should -BeGreaterThan 0 + } + + It 'returns empty when candidate paths do not exist' { + Mock Test-RegistryPathExist { $false } + Mock Get-RegistryChildKeyNamesSafe { @() } + + $result = @(Get-NetworkPrivacyArtifactCandidate -Inventory @()) + $result.Count | Should -Be 0 + } + } + + Context 'Get-SanitizableNetworkArtifact' { + + It 'filters out protected artifacts and keeps sanitizable ones' { + Mock Get-NetworkPrivacyArtifactCandidate { + @( + [pscustomobject]@{ + ArtifactType = 'NetworkList' + RegistryPath = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + InterfaceGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + IsProtected = $false + Reason = 'history' + }, + [pscustomobject]@{ + ArtifactType = 'NetworkList' + RegistryPath = 'HKLM\SOFTWARE\Vendor\Protected' + InterfaceGuid = '11111111-2222-3333-4444-555555555555' + IsProtected = $true + Reason = 'protected' + } + ) + } + + $result = @(Get-SanitizableNetworkArtifact -Inventory @()) + $result.Count | Should -Be 1 + $result[0].RegistryPath | Should -Be 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + } + + It 'returns empty when no candidate artifacts are sanitizable' { + Mock Get-NetworkPrivacyArtifactCandidate { + @( + [pscustomobject]@{ + ArtifactType = 'NetworkList' + RegistryPath = 'HKLM\SOFTWARE\Vendor\Protected' + InterfaceGuid = '11111111-2222-3333-4444-555555555555' + IsProtected = $true + Reason = 'protected' + } + ) + } + + $result = @(Get-SanitizableNetworkArtifact -Inventory @()) + $result.Count | Should -Be 0 + } + } + + Context 'Invoke-NetCleanPhase1Detect' { + + BeforeEach { + Mock Get-ProtectionInventory { + @( + [pscustomobject]@{ + Vendor = 'CrowdStrike' + Categories = @('EDR') + Confidence = 100 + Services = @('CSFalconService') + Drivers = @('csagent') + Adapters = @('CrowdStrike Adapter') + ProtectedInterfaceGuids = @('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + RegistryKeys = @('HKLM\SOFTWARE\CrowdStrike') + Evidence = @('Service | CSFalconService') + } + ) + } + + Mock Get-ProtectionRegistryMap { + @( + [pscustomobject]@{ + Vendor = 'CrowdStrike' + Categories = @('EDR') + Confidence = 100 + Services = @('CSFalconService') + Drivers = @('csagent') + Adapters = @('CrowdStrike Adapter') + ProtectedInterfaceGuids = @('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + RegistryKeys = @('HKLM\SOFTWARE\CrowdStrike') + Evidence = @('Service | CSFalconService') + } + ) + } + + Mock Get-ProtectedInterfaceGuidSet { + @('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + } + + Mock Get-NetworkPrivacyArtifactCandidate { + @( + [pscustomobject]@{ + ArtifactType = 'NetworkList' + RegistryPath = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + InterfaceGuid = $null + IsProtected = $false + Reason = 'history' + } + ) + } + + Mock Get-SanitizableNetworkArtifact { + @( + [pscustomobject]@{ + ArtifactType = 'NetworkList' + RegistryPath = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + InterfaceGuid = $null + IsProtected = $false + Reason = 'history' + } + ) + } + + Mock Get-NetCleanDeviceManagementState { + [pscustomobject]@{ + IsManaged = $true + JoinType = 'MicrosoftEntraJoined' + DomainJoined = $false + EntraJoined = $true + MdmEnrolled = $true + WorkplaceJoined = $false + } + } + } + + It 'returns a detect context with populated summary fields' { + $ctx = Invoke-NetCleanPhase1Detect + + $ctx.Phase | Should -Be 'Detect' + $ctx.Summary.ProtectedVendorsCount | Should -Be 1 + $ctx.Summary.ProtectedInterfaceGuidCount | Should -Be 1 + $ctx.Summary.CandidateArtifactCount | Should -Be 1 + $ctx.Summary.SanitizableArtifactCount | Should -Be 1 + $ctx.ManagementState.IsManaged | Should -BeTrue + $ctx.Summary.ManagedDevice | Should -BeTrue + } + + It 'returns zero counts when no inventory or artifacts are found' { + Mock Get-ProtectionInventory { @() } + Mock Get-ProtectionRegistryMap { @() } + Mock Get-ProtectedInterfaceGuidSet { @() } + Mock Get-NetworkPrivacyArtifactCandidate { @() } + Mock Get-SanitizableNetworkArtifact { @() } + + $ctx = Invoke-NetCleanPhase1Detect + + $ctx.Phase | Should -Be 'Detect' + $ctx.Summary.ProtectedVendorsCount | Should -Be 0 + $ctx.Summary.ProtectedInterfaceGuidCount | Should -Be 0 + $ctx.Summary.CandidateArtifactCount | Should -Be 0 + $ctx.Summary.SanitizableArtifactCount | Should -Be 0 + } + } + } +} diff --git a/tests/Unit/NetClean.Phase2.Unit.Tests.ps1 b/tests/Unit/NetClean.Phase2.Unit.Tests.ps1 new file mode 100644 index 0000000..48e960c --- /dev/null +++ b/tests/Unit/NetClean.Phase2.Unit.Tests.ps1 @@ -0,0 +1,641 @@ +$manifestPath = Join-Path $PSScriptRoot '..\..\NetClean.psd1' + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" +} + +Remove-Module NetClean -ErrorAction SilentlyContinue +Import-Module $manifestPath -Force + +Describe 'NetClean Phase 2 unit tests' { + + InModuleScope 'NetClean' { + + BeforeEach { + $script:LogFile = $null + } + + Context 'Get-WiFiProfileName' { + + It 'returns Wi-Fi profile names parsed from netsh output' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Name = 'List Wi-Fi profiles' + ExitCode = 0 + Succeeded = $true + Output = @( + 'Profiles on interface Wi-Fi:' + 'Group policy profiles (read only)' + '' + 'User profiles' + '-------------' + ' All User Profile : HomeSSID' + ' All User Profile : OfficeSSID' + ) + Error = $null + } + } + + $result = @(Get-WiFiProfileName) + + $result.Count | Should -Be 2 + $result | Should -Contain 'HomeSSID' + $result | Should -Contain 'OfficeSSID' + } + + It 'returns distinct profile names only' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Name = 'List Wi-Fi profiles' + ExitCode = 0 + Succeeded = $true + Output = @( + ' All User Profile : HomeSSID' + ' All User Profile : HomeSSID' + ' All User Profile : OfficeSSID' + ) + Error = $null + } + } + + $result = @(Get-WiFiProfileName) + + $result.Count | Should -Be 2 + @($result | Where-Object { $_ -eq 'HomeSSID' }).Count | Should -Be 1 + } + + It 'captures netsh profile names as UTF-8 and restores the host encoding' { + $originalEncoding = [Console]::OutputEncoding + $testHostEncoding = [System.Text.Encoding]::GetEncoding(437) + $profileName = 'Edward{0}s iPhone' -f [char]0x2019 + $script:captureEncoding = $null + + try { + [Console]::OutputEncoding = $testHostEncoding + Mock Invoke-NetCleanNativeCapture { + $script:captureEncoding = [Console]::OutputEncoding.WebName + [pscustomobject]@{ + Name = 'List Wi-Fi profiles' + ExitCode = 0 + Succeeded = $true + Output = @(" All User Profile : $profileName") + Error = $null + } + } + + $result = @(Get-WiFiProfileName) + $restoredEncoding = [Console]::OutputEncoding.WebName + } + finally { + [Console]::OutputEncoding = $originalEncoding + } + + $script:captureEncoding | Should -Be 'utf-8' + $restoredEncoding | Should -Be $testHostEncoding.WebName + $result | Should -Be @($profileName) + } + + It 'returns an empty collection when netsh returns no output' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Name = 'List Wi-Fi profiles' + ExitCode = 0 + Succeeded = $true + Output = @() + Error = $null + } + } + + @((Get-WiFiProfileName)).Count | Should -Be 0 + } + + It 'returns an empty collection when native capture reports failure' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Name = 'List Wi-Fi profiles' + ExitCode = 1 + Succeeded = $false + Output = @() + Error = 'netsh failed' + } + } + + @((Get-WiFiProfileName)).Count | Should -Be 0 + } + + It 'ignores null output and parses alternate profile labels' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Name = 'List Wi-Fi profiles' + ExitCode = 0 + Succeeded = $true + Output = @( + $null + ' Current Profile : ConferenceSSID' + ) + Error = $null + } + } + + $result = @(Get-WiFiProfileName) + + $result | Should -Be @('ConferenceSSID') + } + } + + Context 'Export-WiFiProfile' { + + BeforeEach { + Mock New-DirectoryIfNotExist {} + } + + It 'returns empty when no Wi-Fi profiles are detected' { + Mock Get-WiFiProfileName { @() } + + $result = @(Export-WiFiProfile -Dest 'C:\backup') + + $result.Count | Should -Be 0 + } + + It 'returns planned outputs in dry-run mode' { + Mock Get-WiFiProfileName { @('HomeSSID', 'OfficeSSID') } + + $result = @(Export-WiFiProfile -Dest 'C:\backup' -DryRun) + + $result.Count | Should -Be 3 + $result[0] | Should -Match 'WiFiProfiles_' + $result | Should -Contain 'PROFILE:HomeSSID' + $result | Should -Contain 'PROFILE:OfficeSSID' + Should -Invoke New-DirectoryIfNotExist -Times 0 + } + + It 'writes the list file and records exported XMLs when bulk export succeeds' { + Mock Get-WiFiProfileName { @('HomeSSID') } + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = 'Export Wi-Fi profiles (bulk)' + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + + $script:BulkCallSeen = $false + Mock Get-ChildItem { + if (-not $script:BulkCallSeen) { + $script:BulkCallSeen = $true + @() + } + else { + @( + [pscustomobject]@{ FullName = 'C:\backup\Wi-Fi-HomeSSID.xml' } + ) + } + } + + Mock WriteAllLines {} + + $result = @(Export-WiFiProfile -Dest 'C:\backup') + + $result.Count | Should -Be 2 + $result[0] | Should -Match 'WiFiProfiles_' + $result | Should -Contain 'C:\backup\Wi-Fi-HomeSSID.xml' + Should -Invoke Invoke-ExternalCommandSafe -Times 1 -ParameterFilter { $Name -eq 'Export Wi-Fi profiles (bulk)' } + } + + It 'falls back to per-profile export when bulk export creates no files' { + Mock Get-WiFiProfileName { @('HomeSSID', 'OfficeSSID') } + + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = $Name + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + + $script:ChildItemCall = 0 + Mock Get-ChildItem { + $script:ChildItemCall++ + + switch ($script:ChildItemCall) { + 1 { @() } # bulk before + 2 { @() } # bulk after -> no new files + 3 { @() } # HomeSSID before + 4 { @([pscustomobject]@{ FullName = 'C:\backup\Wi-Fi-HomeSSID.xml' }) } # HomeSSID after + 5 { @([pscustomobject]@{ FullName = 'C:\backup\Wi-Fi-HomeSSID.xml' }) } # OfficeSSID before + 6 { + @( + [pscustomobject]@{ FullName = 'C:\backup\Wi-Fi-HomeSSID.xml' } + [pscustomobject]@{ FullName = 'C:\backup\Wi-Fi-OfficeSSID.xml' } + ) + } # OfficeSSID after + default { @() } + } + } + + $result = @(Export-WiFiProfile -Dest 'C:\backup') + + $result[0] | Should -Match 'WiFiProfiles_' + $result | Should -Contain 'C:\backup\Wi-Fi-HomeSSID.xml' + $result | Should -Contain 'C:\backup\Wi-Fi-OfficeSSID.xml' + + Should -Invoke Invoke-ExternalCommandSafe -Times 1 -ParameterFilter { $Name -eq 'Export Wi-Fi profiles (bulk)' } + Should -Invoke Invoke-ExternalCommandSafe -Times 1 -ParameterFilter { $Name -eq 'Export Wi-Fi profile HomeSSID' } + Should -Invoke Invoke-ExternalCommandSafe -Times 1 -ParameterFilter { $Name -eq 'Export Wi-Fi profile OfficeSSID' } + } + + It 'continues when a per-profile export fails and still returns successful exports' { + Mock Get-WiFiProfileName { @('FailSSID', 'GoodSSID') } + + Mock Invoke-ExternalCommandSafe { + if ($Name -match 'FailSSID') { [pscustomobject]@{ Name=$Name; ExitCode=1; Succeeded=$false; Error='fail' } } + else { [pscustomobject]@{ Name=$Name; ExitCode=0; Succeeded=$true; Error=$null } } + } + + $script:ChildItemCall = 0 + Mock Get-ChildItem { + $script:ChildItemCall++ + + switch ($script:ChildItemCall) { + 1 { @() } # bulk before + 2 { @() } # bulk after -> no new files + 3 { @() } # FailSSID before + 4 { @() } # FailSSID after -> still no file + 5 { @() } # GoodSSID before + 6 { @([pscustomobject]@{ FullName = 'C:\backup\Wi-Fi-GoodSSID.xml' }) } # GoodSSID after + default { @() } + } + } + + Mock WriteAllLines {} + + $result = @(Export-WiFiProfile -Dest 'C:\backup') + + # list file + one successful xml + $result.Count | Should -Be 2 + $result | Should -Contain 'C:\backup\Wi-Fi-GoodSSID.xml' + $result | Should -Not -Contain 'C:\backup\Wi-Fi-FailSSID.xml' + } + } + + Context 'Export-NetworkList' { + + It 'returns the expected file path in dry-run mode' { + $result = Export-NetworkList -Dest 'C:\backup' -DryRun + $result | Should -Match 'NetworkList' + } + + It 'calls reg export through safe external command helper' { + Mock Invoke-RegExport { + param($Key, $FilePath, $DryRun) + $null = $Key, $DryRun + $FilePath + } + + $result = Export-NetworkList -Dest 'C:\backup' + $result | Should -Match 'NetworkList' + + Should -Invoke Invoke-RegExport -Times 1 + } + } + + Context 'Export-ProtectedRegistryKey' { + + It 'returns planned output in dry-run mode' { + $result = @(Export-ProtectedRegistryKey -Paths @('HKLM\SOFTWARE\CrowdStrike') -Dest 'C:\backup' -DryRun) + + $result.Count | Should -Be 1 + $result[0] | Should -Match 'CrowdStrike' + } + + It 'exports each protected registry key path' { + Mock Invoke-RegExport { + param($Key, $FilePath, $DryRun) + $null = $Key, $DryRun + $FilePath + } + + $result = @(Export-ProtectedRegistryKey -Paths @( + 'HKLM\SOFTWARE\CrowdStrike', + 'HKLM\SOFTWARE\Cisco' + ) -Dest 'C:\backup') + + $result.Count | Should -Be 2 + Should -Invoke Invoke-RegExport -Times 2 + } + + It 'returns empty when no registry paths are supplied' { + $result = @(Export-ProtectedRegistryKey -Paths @() -Dest 'C:\backup') + $result.Count | Should -Be 0 + } + + It 'skips invalid registry paths and continues exporting valid ones' { + Mock Convert-RegKeyPath { + param($Path) + if ($Path -match 'badpath$') { throw 'invalid' } else { 'Registry::HKEY_LOCAL_MACHINE\\SOFTWARE\\Good' } + } + + Mock Invoke-RegExport { + param($Key, $FilePath, $DryRun) + $null = $Key, $DryRun + $FilePath + } + + $result = @(Export-ProtectedRegistryKey -Paths @('badpath', 'HKLM\\SOFTWARE\\Good') -Dest 'C:\\backup' -DryRun) + + $result.Count | Should -Be 1 + $result[0] | Should -Match 'reg_backup' + } + } + + Context 'Export-ProtectionInventory' { + + It 'returns expected output path in dry-run mode' { + $inventory = @([pscustomobject]@{ Vendor = 'CrowdStrike' }) + + $result = Export-ProtectionInventory -Inventory $inventory -Dest 'C:\backup' -DryRun + $result | Should -Match 'ProtectionInventory' + } + + It 'writes inventory JSON to disk' { + Mock WriteAllText {} + + $inventory = @([pscustomobject]@{ Vendor = 'CrowdStrike' }) + $result = Export-ProtectionInventory -Inventory $inventory -Dest $TestDrive + + $result | Should -Match 'ProtectionInventory' + Should -Invoke WriteAllText -Times 1 -Exactly -ParameterFilter { + $Path -eq $result -and + $Contents -match 'CrowdStrike' -and + $Encoding.WebName -eq 'utf-8' + } + } + + It 'detects the current inventory when none is supplied' { + Mock Get-ProtectionInventory { + @([pscustomobject]@{ Vendor = 'Microsoft Defender' }) + } + + $result = Export-ProtectionInventory -Dest 'C:\backup' -DryRun + + $result | Should -Match 'ProtectionInventory' + Should -Invoke Get-ProtectionInventory -Times 1 -Exactly + } + } + + Context 'Export-ProtectionRegistryMap' { + + It 'returns expected output path in dry-run mode' { + Mock Get-ProtectionRegistryMap { + @([pscustomobject]@{ Vendor = 'CrowdStrike' }) + } + + $result = Export-ProtectionRegistryMap -Inventory @() -Dest 'C:\backup' -DryRun + $result | Should -Match 'ProtectionRegistryMap' + } + + It 'writes the registry map through the UTF-8 text seam' { + Mock Get-ProtectionRegistryMap { + @([pscustomobject]@{ Vendor = 'CrowdStrike' }) + } + Mock WriteAllText {} + + $result = Export-ProtectionRegistryMap -Inventory @() -Dest $TestDrive + + Should -Invoke WriteAllText -Times 1 -Exactly -ParameterFilter { + $Path -eq $result -and + $Contents -match 'CrowdStrike' -and + $Encoding.WebName -eq 'utf-8' + } + } + + } + + Context 'Export-SanitizableNetworkArtifact' { + + It 'returns expected output path in dry-run mode' { + Mock Get-SanitizableNetworkArtifact { + @([pscustomobject]@{ RegistryPath = 'HKLM\SOFTWARE\Test' }) + } + + $result = Export-SanitizableNetworkArtifact -Inventory @() -Dest 'C:\backup' -DryRun + $result | Should -Match 'SanitizableNetworkArtifact' + } + + It 'writes sanitizable artifacts through the UTF-8 text seam' { + Mock Get-SanitizableNetworkArtifact { + @([pscustomobject]@{ RegistryPath = 'HKLM\SOFTWARE\Test' }) + } + Mock WriteAllText {} + + $result = Export-SanitizableNetworkArtifact -Inventory @() -Dest $TestDrive + + Should -Invoke WriteAllText -Times 1 -Exactly -ParameterFilter { + $Path -eq $result -and + $Contents -match 'RegistryPath' -and + $Encoding.WebName -eq 'utf-8' + } + } + } + + Context 'Export-FirewallPolicy' { + + It 'returns expected output path in dry-run mode' { + $result = Export-FirewallPolicy -Dest 'C:\backup' -DryRun + $result | Should -Match 'FirewallPolicy' + } + + It 'uses external command helper when not in dry-run mode' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = 'Export firewall policy' + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + + $result = Export-FirewallPolicy -Dest 'C:\backup' + $result | Should -Match 'FirewallPolicy' + + Should -Invoke Invoke-ExternalCommandSafe -Times 1 + } + } + + Context 'Export-NetCleanAdapterConfiguration' { + + It 'returns the planned adapter snapshot path in dry-run mode' { + $result = Export-NetCleanAdapterConfiguration -Dest 'C:\backup' -DryRun + + $result | Should -Match 'AdapterConfiguration' + } + + It 'writes compact addressing, route, DNS, and IPv6 preference state' { + Mock Get-NetAdapter { + [pscustomobject]@{ + Name = 'Wi-Fi' + InterfaceDescription = 'Test adapter' + InterfaceIndex = 12 + InterfaceGuid = '{11111111-1111-1111-1111-111111111111}' + Status = 'Up' + MacAddress = '00-11-22-33-44-55' + } + } + Mock Get-NetIPInterface { + [pscustomobject]@{ + InterfaceIndex = 12 + AddressFamily = 'IPv4' + Dhcp = 'Disabled' + } + } + Mock Get-NetIPAddress { + [pscustomobject]@{ + InterfaceIndex = 12 + IPAddress = '192.0.2.10' + PrefixLength = 24 + PrefixOrigin = 'Manual' + SuffixOrigin = 'Manual' + } + } + Mock Get-NetRoute { + [pscustomobject]@{ + InterfaceIndex = 12 + DestinationPrefix = '0.0.0.0/0' + NextHop = '192.0.2.1' + RouteMetric = 10 + Protocol = 'NetMgmt' + } + } + Mock Get-DnsClientServerAddress { + [pscustomobject]@{ + InterfaceIndex = 12 + AddressFamily = 'IPv4' + ServerAddresses = @('192.0.2.53') + } + } + Mock Get-ItemProperty { + [pscustomobject]@{ DisabledComponents = 0 } + } + Mock WriteAllText {} + + $result = Export-NetCleanAdapterConfiguration -Dest $TestDrive + + Should -Invoke WriteAllText -Times 1 -Exactly -ParameterFilter { + $Path -eq $result -and + $Contents -match '192.0.2.10' -and + $Contents -match '192.0.2.53' -and + $Contents -match 'DisabledComponents' -and + $Encoding.WebName -eq 'utf-8' + } + } + } + + Context 'Export-NetCleanManifest' { + + It 'returns expected output path in dry-run mode' { + $manifest = @{ BackupPath = 'C:\backup' } + + $result = Export-NetCleanManifest -Manifest $manifest -Dest 'C:\backup' -DryRun + $result | Should -Match 'Manifest' + } + + It 'writes manifest JSON when not in dry-run mode' { + Mock WriteAllText {} + + $manifest = @{ BackupPath = 'C:\backup' } + $result = Export-NetCleanManifest -Manifest $manifest -Dest $TestDrive + + $result | Should -Match 'Manifest' + Should -Invoke WriteAllText -Times 1 -Exactly -ParameterFilter { + $Path -eq $result -and + $Contents -match 'BackupPath' -and + $Encoding.WebName -eq 'utf-8' + } + } + } + + Context 'Invoke-NetCleanPhase2Protect' { + + BeforeEach { + $script:context = [pscustomobject]@{ + Phase = 'Detect' + Inventory = @( + [pscustomobject]@{ + Vendor = 'CrowdStrike' + Services = @('CSFalconService') + Drivers = @() + Adapters = @() + ProtectedInterfaceGuids = @() + RegistryKeys = @('HKLM\SOFTWARE\CrowdStrike') + Evidence = @() + } + ) + ProtectedRegistryPaths = @('HKLM\SOFTWARE\CrowdStrike') + SanitizableArtifacts = @( + [pscustomobject]@{ + RegistryPath = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + } + ) + } + + Mock New-DirectoryIfNotExist {} + Mock Set-NetCleanPrivateDirectoryAcl {} + Mock Export-ProtectionInventory { 'C:\backup\ProtectionInventory.json' } + Mock Export-ProtectionRegistryMap { 'C:\backup\ProtectionRegistryMap.json' } + Mock Export-SanitizableNetworkArtifact { 'C:\backup\SanitizableNetworkArtifacts.json' } + Mock Export-NetworkList { 'C:\backup\NetworkList.reg' } + Mock Export-WiFiProfile { @('C:\backup\WiFiProfiles.txt') } + Mock Export-FirewallPolicy { 'C:\backup\FirewallPolicy.wfw' } + Mock Export-NetCleanAdapterConfiguration { 'C:\backup\AdapterConfiguration.json' } + Mock Export-ProtectedRegistryKey { @('C:\backup\CrowdStrike.reg') } + Mock Export-NetCleanManifest { 'C:\backup\Manifest.json' } + } + + It 'returns a protect context with manifest and summary in dry-run mode' { + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + $result.Phase | Should -Be 'Protect' + $result.BackupPath | Should -Be 'C:\backup' + $result.Protect.Manifest.NetworkListBackup | Should -Be 'C:\backup\NetworkList.reg' + $result.Protect.Manifest.FirewallPolicyBackup | Should -Be 'C:\backup\FirewallPolicy.wfw' + $result.Protect.Manifest.AdapterConfigurationJson | Should -Be 'C:\backup\AdapterConfiguration.json' + $result.Protect.Summary.ProtectedRegistryPathCount | Should -Be 1 + $result.Protect.Summary.AdapterConfigurationBackupCount | Should -Be 1 + $result.Protect.Summary.WiFiBackupCount | Should -Be 1 + $result.Protect.Summary.ProtectedRegistryBackupCount | Should -Be 1 + } + + It 'skips firewall backup when requested' { + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun -SkipFirewallBackup + + $result.Protect.Manifest.FirewallPolicyBackup | Should -BeNullOrEmpty + Should -Invoke Export-FirewallPolicy -Times 0 + } + + It 'creates backup directory when not in dry-run mode' { + $null = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' + + Should -Invoke New-DirectoryIfNotExist -Times 1 -ParameterFilter { $Path -eq 'C:\backup' } + Should -Invoke Set-NetCleanPrivateDirectoryAcl -Times 1 -ParameterFilter { $Path -eq 'C:\backup' } + } + + It 'handles empty protected registry paths gracefully' { + $script:context.ProtectedRegistryPaths = @() + Mock Export-ProtectedRegistryKey { @() } + + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + $result.Protect.Summary.ProtectedRegistryPathCount | Should -Be 0 + $result.Protect.Summary.ProtectedRegistryBackupCount | Should -Be 0 + } + + It 'passes through manifest file path from export' { + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + $result.Protect.ManifestFile | Should -Be 'C:\backup\Manifest.json' + } + } + } +} diff --git a/tests/Unit/NetClean.Phase3.Unit.Tests.ps1 b/tests/Unit/NetClean.Phase3.Unit.Tests.ps1 new file mode 100644 index 0000000..b1784f4 --- /dev/null +++ b/tests/Unit/NetClean.Phase3.Unit.Tests.ps1 @@ -0,0 +1,1223 @@ +$manifestPath = Join-Path $PSScriptRoot '..\..\NetClean.psd1' + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" +} + +Remove-Module NetClean -ErrorAction SilentlyContinue +Import-Module $manifestPath -Force + +Describe 'NetClean Phase 3 unit tests' { + + InModuleScope 'NetClean' { + + BeforeEach { + $script:LogFile = $null + } + + Context 'Remove-WiFiProfilesSafe' { + + It 'returns empty results when no Wi-Fi profiles are found' { + Mock Get-WiFiProfileName { @() } + + $result = Remove-WiFiProfilesSafe -DryRun + + $result.Removed | Should -Be 0 + @($result.Profiles).Count | Should -Be 0 + @($result.Operations).Count | Should -Be 0 + } + + It 'returns planned removals in dry-run mode when profiles are discovered automatically' { + Mock Get-WiFiProfileName { @('HomeSSID', 'OfficeSSID') } + + $result = Remove-WiFiProfilesSafe -DryRun + + $result.Removed | Should -Be 2 + @($result.Profiles) | Should -Contain 'HomeSSID' + @($result.Profiles) | Should -Contain 'OfficeSSID' + @($result.Operations).Count | Should -Be 2 + @($result.Operations | Where-Object { $_.Reason -eq 'DryRun' }).Count | Should -Be 2 + } + + It 'uses explicitly supplied WifiProfiles instead of auto-discovery' { + Mock Get-WiFiProfileName { throw 'Should not be called' } + + $result = Remove-WiFiProfilesSafe -DryRun -WifiProfiles @('LabSSID') + + $result.Removed | Should -Be 1 + @($result.Profiles) | Should -Contain 'LabSSID' + } + + It 'honors an explicitly empty profile inventory without rescanning' { + Mock Get-WiFiProfileName { throw 'Should not be called' } + + $result = Remove-WiFiProfilesSafe -DryRun -WifiProfiles @() + + $result.Removed | Should -Be 0 + Should -Invoke Get-WiFiProfileName -Times 0 + } + + It 'returns WhatIf-skipped operations when ShouldProcess declines' { + Mock Get-WiFiProfileName { @('HomeSSID') } + + $result = Remove-WiFiProfilesSafe -WifiProfiles @('HomeSSID') -WhatIf + + $result.Removed | Should -Be 0 + @($result.Operations).Count | Should -Be 1 + $result.Operations[0].Skipped | Should -BeTrue + $result.Operations[0].Reason | Should -Be 'WhatIf' + } + + It 'returns successful removals when parallel processing succeeds' { + Mock Invoke-InParallel { + @( + [pscustomobject]@{ Name = 'HomeSSID'; Succeeded = $true; Skipped = $false; Reason = 'Removed' } + [pscustomobject]@{ Name = 'OfficeSSID'; Succeeded = $true; Skipped = $false; Reason = 'Removed' } + ) + } + + $result = Remove-WiFiProfilesSafe -WifiProfiles @('HomeSSID', 'OfficeSSID') + + $result.Removed | Should -Be 2 + @($result.Profiles) | Should -Contain 'HomeSSID' + @($result.Profiles) | Should -Contain 'OfficeSSID' + @($result.Operations).Count | Should -Be 2 + } + + It 'falls back to sequential native removal when parallel invocation fails' { + Mock Invoke-InParallel { throw 'parallel failure' } + + Mock Get-WiFiProfileName { @('HomeSSID') } + + Mock netsh.exe { $global:LASTEXITCODE = 0 } + + $result = Remove-WiFiProfilesSafe -WifiProfiles @('HomeSSID') + + $result.Removed | Should -Be 1 + @($result.Profiles) | Should -Contain 'HomeSSID' + @($result.Operations).Count | Should -Be 1 + $result.Operations[0].Succeeded | Should -BeTrue + } + + It 'records successful and failed native results returned by parallel processing' { + $script:ExitCodes = [System.Collections.Generic.Queue[int]]::new() + $script:ExitCodes.Enqueue(0) + $script:ExitCodes.Enqueue(1) + Mock netsh.exe { $global:LASTEXITCODE = $script:ExitCodes.Dequeue() } + Mock Invoke-InParallel { + foreach ($inputObject in $InputObjects) { + & $ScriptBlock $inputObject + } + } + Mock Write-NetCleanLog {} + + $result = Remove-WiFiProfilesSafe -WifiProfiles @('HomeSSID', 'OfficeSSID') + + $result.Removed | Should -Be 1 + @($result.Profiles) | Should -Be @('HomeSSID') + @($result.Operations).Count | Should -Be 2 + ($result.Operations | Where-Object Name -EQ 'OfficeSSID').Reason | Should -Be 'Failed' + Should -Invoke Write-NetCleanLog -Times 1 -ParameterFilter { + $Level -eq 'WARN' -and $Message -match "OfficeSSID.*Failed" + } + } + + It 'records a failed sequential native removal after parallel processing throws' { + Mock Invoke-InParallel { throw 'parallel failure' } + Mock netsh.exe { $global:LASTEXITCODE = 1 } + Mock Write-NetCleanLog {} + + $result = Remove-WiFiProfilesSafe -WifiProfiles @('HomeSSID') + + $result.Removed | Should -Be 0 + $result.Operations[0].Succeeded | Should -BeFalse + $result.Operations[0].Reason | Should -Be 'Failed' + } + } + + Context 'Set-NetCleanQuad9Doh' { + + It 'honors WhatIf without querying or changing the encrypted DNS table' { + Mock Get-DnsClientDohServerAddress {} + Mock Set-DnsClientDohServerAddress {} + Mock Add-DnsClientDohServerAddress {} + + $result = Set-NetCleanQuad9Doh -ServerAddresses @('9.9.9.9') -WhatIf + + $result.Reason | Should -Be 'WhatIf' + $result.ConfiguredCount | Should -Be 0 + Should -Invoke Get-DnsClientDohServerAddress -Times 0 + Should -Invoke Set-DnsClientDohServerAddress -Times 0 + Should -Invoke Add-DnsClientDohServerAddress -Times 0 + } + + It 'reports encrypted DNS as unsupported when a required command is unavailable' { + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-DnsClientDohServerAddress' } + + $result = Set-NetCleanQuad9Doh -ServerAddresses @('9.9.9.9') -Confirm:$false + + $result.Supported | Should -BeFalse + $result.Reason | Should -Be 'UnsupportedWindowsVersion' + $result.ConfiguredCount | Should -Be 0 + } + + It 'records a failed encrypted DNS update without hiding the error' { + Mock Get-DnsClientDohServerAddress { + [pscustomobject]@{ ServerAddress = $ServerAddress } + } + Mock Set-DnsClientDohServerAddress { throw 'DoH update denied' } + Mock Add-DnsClientDohServerAddress {} + + $result = Set-NetCleanQuad9Doh -ServerAddresses @('9.9.9.9') -Confirm:$false + + $result.ConfiguredCount | Should -Be 0 + $result.FailedCount | Should -Be 1 + $result.Operations[0].Action | Should -Be 'Failed' + $result.Operations[0].Error | Should -Be 'DoH update denied' + } + } + + Context 'Set-NetCleanIPv4Preference' { + + It 'honors WhatIf without writing the IP stack preference' { + Mock Set-ItemProperty {} + + $result = Set-NetCleanIPv4Preference -WhatIf + + $result.Skipped | Should -BeTrue + $result.Reason | Should -Be 'WhatIf' + Should -Invoke Set-ItemProperty -Times 0 + } + + It 'returns the registry error when the IP stack preference cannot be written' { + Mock Set-ItemProperty { throw 'registry write denied' } + + $result = Set-NetCleanIPv4Preference -Confirm:$false + + $result.Succeeded | Should -BeFalse + $result.Reason | Should -Be 'Failed' + $result.Error | Should -Be 'registry write denied' + } + } + + Context 'Reset-NetCleanAdapterConfigurationSafe' { + + BeforeEach { + $script:Adapters = @( + [pscustomobject]@{ + Name = 'Wi-Fi' + InterfaceIndex = 12 + InterfaceGuid = '{11111111-1111-1111-1111-111111111111}' + } + [pscustomobject]@{ + Name = 'Protected VPN' + InterfaceIndex = 13 + InterfaceGuid = '{22222222-2222-2222-2222-222222222222}' + } + ) + + $script:AdapterContext = [pscustomobject]@{ + ManagementState = [pscustomobject]@{ IsManaged = $false } + ProtectedInterfaceGuids = @('22222222-2222-2222-2222-222222222222') + } + } + + It 'plans IPv4 DHCP and Quad9 DNS only for unmanaged unprotected adapters' { + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -Adapters $script:Adapters ` + -DryRun + + $result.Provider | Should -Be 'Quad9 Secure' + @($result.DnsServers) | Should -Be @( + '9.9.9.9', + '149.112.112.112', + '2620:fe::fe', + '2620:fe::9' + ) + $result.PreferIPv4 | Should -BeTrue + $result.ConfiguredCount | Should -Be 1 + $result.SkippedCount | Should -Be 1 + + $wifi = $result.Operations | Where-Object Name -eq 'Wi-Fi' + $wifi.DhcpEnabled | Should -BeTrue + @($wifi.DnsServers) | Should -Be @( + '9.9.9.9', + '149.112.112.112', + '2620:fe::fe', + '2620:fe::9' + ) + $wifi.Reason | Should -Be 'DryRun' + + $vpn = $result.Operations | Where-Object Name -eq 'Protected VPN' + $vpn.Skipped | Should -BeTrue + $vpn.Reason | Should -Be 'ProtectedAdapter' + } + + It 'preserves every adapter on an organization-managed device' { + $script:AdapterContext.ManagementState.IsManaged = $true + + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -Adapters $script:Adapters ` + -DryRun + + $result.ConfiguredCount | Should -Be 0 + $result.SkippedCount | Should -Be 2 + @($result.Operations | Where-Object Reason -eq 'ManagedDevice').Count | Should -Be 2 + } + + It 'applies DHCP, Quad9 DNS, encrypted DNS, and the IPv4 preference' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = $Name + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + Mock Set-DnsClientServerAddress {} + Mock Get-DnsClientDohServerAddress { + [pscustomobject]@{ ServerAddress = $ServerAddress } + } + Mock Set-DnsClientDohServerAddress {} + Mock Add-DnsClientDohServerAddress {} + Mock Set-ItemProperty {} + + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -Adapters @($script:Adapters[0]) ` + -Confirm:$false + + $result.ConfiguredCount | Should -Be 1 + $result.FailedCount | Should -Be 0 + $result.DnsOverHttps.ConfiguredCount | Should -Be 4 + + Should -Invoke Invoke-ExternalCommandSafe -Times 1 -ParameterFilter { + $FilePath -eq 'netsh.exe' -and + $ArgumentList -contains 'ipv4' -and + $ArgumentList -contains 'name=12' -and + $ArgumentList -contains 'source=dhcp' + } + Should -Invoke Set-DnsClientServerAddress -Times 1 -ParameterFilter { + $InterfaceIndex -eq 12 -and + @($ServerAddresses).Count -eq 4 -and + $ServerAddresses -contains '9.9.9.9' -and + $ServerAddresses -contains '2620:fe::fe' + } + Should -Invoke Set-DnsClientDohServerAddress -Times 4 -ParameterFilter { + $DohTemplate -eq 'https://dns.quad9.net/dns-query' -and + $AutoUpgrade -eq $true -and + $AllowFallbackToUdp -eq $false + } + Should -Invoke Add-DnsClientDohServerAddress -Times 0 + Should -Invoke Set-ItemProperty -Times 1 -ParameterFilter { + $Name -eq 'DisabledComponents' -and + $Value -eq 32 -and + $Type -eq 'DWord' + } + } + + It 'adds Quad9 to the encrypted DNS table when entries are absent' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ ExitCode = 0; Succeeded = $true; Error = $null } + } + Mock Set-DnsClientServerAddress {} + Mock Get-DnsClientDohServerAddress { @() } + Mock Set-DnsClientDohServerAddress {} + Mock Add-DnsClientDohServerAddress {} + Mock Set-ItemProperty {} + + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -Adapters @($script:Adapters[0]) ` + -Confirm:$false + + $result.DnsOverHttps.ConfiguredCount | Should -Be 4 + Should -Invoke Add-DnsClientDohServerAddress -Times 4 -ParameterFilter { + $DohTemplate -eq 'https://dns.quad9.net/dns-query' -and + $AutoUpgrade -eq $true -and + $AllowFallbackToUdp -eq $false + } + Should -Invoke Set-DnsClientDohServerAddress -Times 0 + } + + It 'reports a failed DHCP reset and does not apply DNS to that adapter' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + ExitCode = 1 + Succeeded = $false + Error = 'DHCP reset failed' + } + } + Mock Set-DnsClientServerAddress {} + Mock Get-DnsClientDohServerAddress { @() } + Mock Set-DnsClientDohServerAddress {} + Mock Add-DnsClientDohServerAddress {} + Mock Set-ItemProperty {} + + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -Adapters @($script:Adapters[0]) ` + -Confirm:$false + + $result.ConfiguredCount | Should -Be 0 + $result.FailedCount | Should -Be 1 + $result.Operations[0].Succeeded | Should -BeFalse + $result.Operations[0].Error | Should -Be 'DHCP reset failed' + Should -Invoke Set-DnsClientServerAddress -Times 0 + } + + It 'honors WhatIf without changing adapter, DNS, or preference state' { + Mock Invoke-ExternalCommandSafe {} + Mock Set-DnsClientServerAddress {} + Mock Get-DnsClientDohServerAddress {} + Mock Set-DnsClientDohServerAddress {} + Mock Add-DnsClientDohServerAddress {} + Mock Set-ItemProperty {} + + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -Adapters @($script:Adapters[0]) ` + -WhatIf + + $result.ConfiguredCount | Should -Be 0 + $result.Operations[0].Reason | Should -Be 'WhatIf' + Should -Invoke Invoke-ExternalCommandSafe -Times 0 + Should -Invoke Set-DnsClientServerAddress -Times 0 + Should -Invoke Set-DnsClientDohServerAddress -Times 0 + Should -Invoke Add-DnsClientDohServerAddress -Times 0 + Should -Invoke Set-ItemProperty -Times 0 + } + + It 'discovers adapters when an explicit inventory is not supplied' { + Mock Get-NetAdapter { @($script:Adapters[0]) } + + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -DryRun + + $result.ConfiguredCount | Should -Be 1 + Should -Invoke Get-NetAdapter -Times 1 + } + + It 'does not configure encrypted DNS when every adapter is protected' { + Mock Set-NetCleanIPv4Preference { + [pscustomobject]@{ Succeeded = $true; Reason = 'Configured'; Error = $null } + } + Mock Set-NetCleanQuad9Doh { throw 'Should not be called' } + + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -Adapters @($script:Adapters[1]) ` + -Confirm:$false + + $result.DnsOverHttps.Reason | Should -Be 'NoEligibleAdapters' + $result.SkippedCount | Should -Be 1 + Should -Invoke Set-NetCleanQuad9Doh -Times 0 + } + + It 'records a DNS assignment failure after a successful DHCP reset' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ Succeeded = $true; Error = $null } + } + Mock Set-DnsClientServerAddress { throw 'DNS assignment denied' } + Mock Set-NetCleanIPv4Preference { + [pscustomobject]@{ Succeeded = $true; Reason = 'Configured'; Error = $null } + } + Mock Set-NetCleanQuad9Doh { + [pscustomobject]@{ + Supported = $true; ConfiguredCount = 4; FailedCount = 0 + Reason = 'Configured'; Operations = @() + } + } + + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -Adapters @($script:Adapters[0]) ` + -Confirm:$false + + $result.Succeeded | Should -BeFalse + $result.FailedCount | Should -Be 1 + $result.Operations[0].Reason | Should -Be 'DnsConfigurationFailed' + $result.Operations[0].Error | Should -Be 'DNS assignment denied' + } + + It 'fails the aggregate result when the IP preference or encrypted DNS setup fails' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ Succeeded = $true; Error = $null } + } + Mock Set-DnsClientServerAddress {} + Mock Set-NetCleanIPv4Preference { + [pscustomobject]@{ Succeeded = $false; Reason = 'Failed'; Error = 'registry denied' } + } + Mock Set-NetCleanQuad9Doh { + [pscustomobject]@{ + Supported = $true; ConfiguredCount = 3; FailedCount = 1 + Reason = 'Configured'; Operations = @() + } + } + + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -Adapters @($script:Adapters[0]) ` + -Confirm:$false + + $result.ConfiguredCount | Should -Be 1 + $result.Succeeded | Should -BeFalse + $result.IPv4Preference.Error | Should -Be 'registry denied' + $result.DnsOverHttps.FailedCount | Should -Be 1 + } + } + + Context 'Clear-DnsCacheSafe' { + + It 'returns a dry-run result when DryRun is specified' { + $result = Clear-DnsCacheSafe -DryRun + + $result.DryRun | Should -BeTrue + $result.Succeeded | Should -BeTrue + $result.Reason | Should -Be 'DryRun' + } + + It 'returns a skipped result when WhatIf is used' { + $result = Clear-DnsCacheSafe -WhatIf + + $result.Skipped | Should -BeTrue + $result.Reason | Should -Be 'WhatIf' + } + + It 'returns success when command execution succeeds' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = 'Clear DNS cache' + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + + $result = Clear-DnsCacheSafe + + $result.Succeeded | Should -BeTrue + $result.ExitCode | Should -Be 0 + } + + It 'returns and logs command failure details' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = 'Clear DNS cache'; ExitCode = 1; Succeeded = $false; Error = 'flush failed' + } + } + Mock Write-NetCleanLog {} + + $result = Clear-DnsCacheSafe + + $result.Succeeded | Should -BeFalse + $result.Error | Should -Be 'flush failed' + Should -Invoke Write-NetCleanLog -Times 1 -ParameterFilter { + $Level -eq 'WARN' -and $Message -match 'flush failed' + } + } + } + + Context 'Clear-ArpCacheSafe' { + + It 'returns a dry-run result when DryRun is specified' { + $result = Clear-ArpCacheSafe -DryRun + + $result.DryRun | Should -BeTrue + $result.Succeeded | Should -BeTrue + $result.Reason | Should -Be 'DryRun' + } + + It 'returns a skipped result when WhatIf is used' { + $result = Clear-ArpCacheSafe -WhatIf + + $result.Skipped | Should -BeTrue + $result.Reason | Should -Be 'WhatIf' + } + + It 'returns success when command execution succeeds' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = 'Clear ARP cache' + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + + $result = Clear-ArpCacheSafe + + $result.Succeeded | Should -BeTrue + $result.ExitCode | Should -Be 0 + } + + It 'returns and logs command failure details' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = 'Clear ARP cache'; ExitCode = 1; Succeeded = $false; Error = 'clear failed' + } + } + Mock Write-NetCleanLog {} + + $result = Clear-ArpCacheSafe + + $result.Succeeded | Should -BeFalse + $result.Error | Should -Be 'clear failed' + Should -Invoke Write-NetCleanLog -Times 1 -ParameterFilter { + $Level -eq 'WARN' -and $Message -match 'clear failed' + } + } + } + + Context 'Remove-RegistryPathSafe' { + + It 'returns a dry-run result when DryRun is specified' { + Mock Test-Path { $true } + + $result = Remove-RegistryPathSafe -Path 'HKLM\SOFTWARE\Test' -DryRun + + $result.DryRun | Should -BeTrue + $result.Removed | Should -BeTrue + $result.Succeeded | Should -BeTrue + $result.Reason | Should -Be 'DryRun' + } + + It 'returns not found when path does not exist' { + Mock Test-Path { $false } + + $result = Remove-RegistryPathSafe -Path 'HKLM\SOFTWARE\Test' + + $result.Succeeded | Should -BeTrue + $result.Removed | Should -BeFalse + $result.Reason | Should -Be 'NotFound' + } + + It 'returns skipped when WhatIf is used' { + Mock Test-Path { $true } + + $result = Remove-RegistryPathSafe -Path 'HKLM\SOFTWARE\Test' -WhatIf + + $result.Skipped | Should -BeTrue + $result.Reason | Should -Be 'WhatIf' + } + + It 'removes a registry path when it exists' { + Mock Test-Path { $true } + Mock Remove-Item {} + + $result = Remove-RegistryPathSafe -Path 'HKLM\SOFTWARE\Test' + + $result.Succeeded | Should -BeTrue + $result.Removed | Should -BeTrue + Should -Invoke Remove-Item -Times 1 + } + + It 'returns failure details when Remove-Item throws' { + Mock Test-Path { $true } + Mock Remove-Item { throw 'remove failed' } + + $result = Remove-RegistryPathSafe -Path 'HKLM\SOFTWARE\Test' + + $result.Succeeded | Should -BeFalse + $result.Removed | Should -BeFalse + $result.Reason | Should -Match 'remove failed' + } + + It 'rejects an invalid registry path without querying or removing it' { + Mock Convert-RegToProviderPath { throw 'invalid registry root' } + Mock Test-Path { throw 'Should not be called' } + Mock Remove-Item { throw 'Should not be called' } + + $result = Remove-RegistryPathSafe -Path 'INVALID\Path' + + $result.Succeeded | Should -BeFalse + $result.Skipped | Should -BeTrue + $result.Reason | Should -Be 'InvalidPath' + Should -Invoke Test-Path -Times 0 + Should -Invoke Remove-Item -Times 0 + } + } + + Context 'Remove-NetworkPrivacyArtifactsSafe' { + + BeforeEach { + $script:Artifacts = @( + [pscustomobject]@{ + RegistryPath = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + }, + [pscustomobject]@{ + RegistryPath = 'HKLM\SOFTWARE\Microsoft\WlanSvc\Interfaces' + } + ) + } + + It 'returns dry-run results without removing artifacts' { + $context = [pscustomobject]@{ SanitizableArtifacts = $script:Artifacts } + Mock Remove-RegistryPathSafe { + [pscustomobject]@{ + RegistryPath = $RegistryPath + Removed = $true + Skipped = $false + Succeeded = $true + Reason = 'DryRun' + DryRun = $true + } + } + + $result = Remove-NetworkPrivacyArtifactsSafe -Context $context -DryRun + + $result.TotalCandidates | Should -Be 2 + $result.RemovedCount | Should -Be 2 + $result.SkippedCount | Should -Be 0 + @($result.Results).Count | Should -Be 2 + Should -Invoke Remove-RegistryPathSafe -Times 2 -ParameterFilter { $DryRun } + } + + It 'calls Remove-RegistryPathSafe for each artifact in normal mode' { + $context = [pscustomobject]@{ SanitizableArtifacts = $script:Artifacts } + Mock Remove-RegistryPathSafe { + [pscustomobject]@{ + RegistryPath = $RegistryPath + Removed = $true + Skipped = $false + DryRun = $false + Succeeded = $true + Reason = $null + } + } + + $result = Remove-NetworkPrivacyArtifactsSafe -Context $context + + $result.TotalCandidates | Should -Be 2 + $result.RemovedCount | Should -Be 2 + Should -Invoke Remove-RegistryPathSafe -Times 2 + } + + It 'returns empty summary when no artifacts are supplied' { + $result = Remove-NetworkPrivacyArtifactsSafe -Context ([pscustomobject]@{ SanitizableArtifacts = @() }) + + $result.TotalCandidates | Should -Be 0 + $result.RemovedCount | Should -Be 0 + $result.SkippedCount | Should -Be 0 + @($result.Results).Count | Should -Be 0 + } + } + + Context 'Clear-NetworkEventLogsSafe' { + + It 'returns dry-run result objects for event logs' { + $result = @(Clear-NetworkEventLogsSafe -DryRun) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Reason -eq 'DryRun' }).Count | Should -Be $result.Count + } + + It 'limits event cleanup to network logs and excludes identity logs' { + $result = @(Clear-NetworkEventLogsSafe -DryRun) + + @($result.LogName) | Should -Be @( + 'Microsoft-Windows-WLAN-AutoConfig/Operational' + 'Microsoft-Windows-NetworkProfile/Operational' + 'Microsoft-Windows-DHCP-Client/Operational' + ) + @($result.LogName) | Should -Not -Match 'AAD|CloudAP|Identity|Token|User Device Registration|WebAuth' + } + + It 'returns skipped result objects when WhatIf is used' { + $result = @(Clear-NetworkEventLogsSafe -WhatIf) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Reason -eq 'WhatIf' }).Count | Should -Be $result.Count + } + + It 'calls external helper for each configured event log' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = $Name + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + + $result = @(Clear-NetworkEventLogsSafe) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $null -ne $_.CompletedAt }).Count | Should -Be $result.Count + Should -Invoke Invoke-ExternalCommandSafe -Times $result.Count + Should -Invoke Invoke-ExternalCommandSafe -Times $result.Count -ParameterFilter { -not $IgnoreExitCode } + } + + It 'records command failures without claiming the log was cleared' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = $Name; ExitCode = 5; Succeeded = $false; Error = 'access denied' + } + } + Mock Write-NetCleanLog {} + + $result = @(Clear-NetworkEventLogsSafe) + + @($result | Where-Object { -not $_.Succeeded -and -not $_.Cleared }).Count | Should -Be $result.Count + @($result | Where-Object Reason -EQ 'CommandFailed').Count | Should -Be $result.Count + @($result | Where-Object { $null -eq $_.CompletedAt }).Count | Should -Be $result.Count + Should -Invoke Write-NetCleanLog -Times $result.Count -ParameterFilter { + $Level -eq 'WARN' -and $Message -match 'access denied' + } + } + + It 'records exceptions from the event-log command helper' { + Mock Invoke-ExternalCommandSafe { throw 'event service unavailable' } + + $result = @(Clear-NetworkEventLogsSafe) + + @($result | Where-Object Reason -EQ 'Exception').Count | Should -Be $result.Count + @($result | Where-Object ExitCode -EQ -1).Count | Should -Be $result.Count + @($result | Where-Object Error -EQ 'event service unavailable').Count | Should -Be $result.Count + } + } + + Context 'Clear-UserNetworkArtifactsSafe' { + + It 'returns dry-run result objects for configured paths' { + $result = @(Clear-UserNetworkArtifactsSafe -DryRun) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Reason -eq 'DryRun' }).Count | Should -Be $result.Count + } + + It 'limits user cleanup to Explorer history and excludes SSO stores' { + $result = @(Clear-UserNetworkArtifactsSafe -DryRun) + + @($result.Path) | Should -Be @( + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU' + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths' + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\RecentDocs' + ) + @($result.Path) | Should -Not -Match 'AAD|BrokerPlugin|CloudAP|Credential|Identity|Ngc|Token|Vault|Workplace' + } + + It 'returns not-found entries when paths do not exist' { + Mock Test-Path { $false } + + $result = @(Clear-UserNetworkArtifactsSafe) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Reason -eq 'NotFound' }).Count | Should -Be $result.Count + } + + It 'returns skipped entries when WhatIf is used' { + Mock Test-Path { $true } + + $result = @(Clear-UserNetworkArtifactsSafe -WhatIf) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Reason -eq 'WhatIf' }).Count | Should -Be $result.Count + } + + It 'removes paths when they exist and execution is allowed' { + Mock Test-Path { $true } + Mock Remove-Item {} + + $result = @(Clear-UserNetworkArtifactsSafe) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Removed }).Count | Should -Be $result.Count + Should -Invoke Remove-Item -Times $result.Count + } + + It 'records removal errors for each user artifact path' { + Mock Test-Path { $true } + Mock Remove-Item { throw 'registry removal denied' } + + $result = @(Clear-UserNetworkArtifactsSafe) + + @($result | Where-Object { -not $_.Succeeded -and -not $_.Removed }).Count | Should -Be $result.Count + @($result | Where-Object Reason -EQ 'registry removal denied').Count | Should -Be $result.Count + } + } + + Context 'Invoke-AdvancedNetworkRepair' { + + It 'returns dry-run actions when DryRun is specified' { + $result = @(Invoke-AdvancedNetworkRepair -DryRun) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Reason -eq 'DryRun' }).Count | Should -Be $result.Count + } + + It 'returns skipped actions when WhatIf is used' { + $result = @(Invoke-AdvancedNetworkRepair -WhatIf) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Reason -eq 'WhatIf' }).Count | Should -Be $result.Count + } + + It 'executes each configured repair command in normal mode' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = $Name + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + + $result = @(Invoke-AdvancedNetworkRepair) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Succeeded }).Count | Should -Be $result.Count + Should -Invoke Invoke-ExternalCommandSafe -Times $result.Count + Should -Invoke Invoke-ExternalCommandSafe -Times $result.Count -ParameterFilter { -not $IgnoreExitCode } + } + + It 'records failed repair commands without treating them as applied' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = $Name; ExitCode = 5; Succeeded = $false; Error = 'repair denied' + } + } + + $result = @(Invoke-AdvancedNetworkRepair) + + @($result | Where-Object Reason -EQ 'CommandFailed').Count | Should -Be $result.Count + @($result | Where-Object Applied).Count | Should -Be 0 + @($result | Where-Object Error -EQ 'repair denied').Count | Should -Be $result.Count + } + + It 'records exceptions from the repair command helper' { + Mock Invoke-ExternalCommandSafe { throw 'repair helper unavailable' } + + $result = @(Invoke-AdvancedNetworkRepair) + + @($result | Where-Object Reason -EQ 'Exception').Count | Should -Be $result.Count + @($result | Where-Object ExitCode -EQ -1).Count | Should -Be $result.Count + @($result | Where-Object Error -EQ 'repair helper unavailable').Count | Should -Be $result.Count + } + } + + Context 'Invoke-NetworkPerformanceTune' { + + It 'throws when PerformanceProfile is missing or invalid if required by the function' { + { Invoke-NetworkPerformanceTune -PerformanceProfile Bogus } | Should -Throw + } + + It 'returns dry-run actions for the Conservative profile' { + $result = @(Invoke-NetworkPerformanceTune -PerformanceProfile Conservative -DryRun) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Profile -eq 'Conservative' -and $_.Reason -eq 'DryRun' }).Count | + Should -Be $result.Count + } + + It 'returns dry-run actions for the Optimal profile' { + $result = @(Invoke-NetworkPerformanceTune -PerformanceProfile Optimal -DryRun) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Profile -eq 'Optimal' -and $_.Reason -eq 'DryRun' }).Count | + Should -Be $result.Count + } + + It 'returns dry-run actions for the Gaming profile' { + $result = @(Invoke-NetworkPerformanceTune -PerformanceProfile Gaming -DryRun) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Profile -eq 'Gaming' -and $_.Reason -eq 'DryRun' }).Count | + Should -Be $result.Count + } + + It 'returns dry-run actions for the Default profile' { + $result = @(Invoke-NetworkPerformanceTune -PerformanceProfile Default -DryRun) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Profile -eq 'Default' -and $_.Reason -eq 'DryRun' }).Count | + Should -Be $result.Count + } + + It 'returns skipped actions when WhatIf is used' { + $result = @(Invoke-NetworkPerformanceTune -PerformanceProfile Optimal -WhatIf) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Reason -eq 'WhatIf' }).Count | Should -Be $result.Count + } + + It 'executes tuning actions in normal mode' { + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = $Name + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + + $result = @(Invoke-NetworkPerformanceTune -PerformanceProfile Optimal) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object { $_.Succeeded }).Count | Should -Be $result.Count + Should -Invoke Invoke-ExternalCommandSafe -Times $result.Count + } + } + + Context 'Invoke-NetCleanPhase3Clean' { + + BeforeEach { + $script:Context = [pscustomobject]@{ + Phase = 'Protect' + Inventory = @() + ProtectedRegistryPaths = @('HKLM\SOFTWARE\CrowdStrike') + SanitizableArtifacts = @( + [pscustomobject]@{ + RegistryPath = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + } + ) + } + + Mock Remove-WiFiProfilesSafe { + [pscustomobject]@{ + Removed = 2 + Profiles = @('ssid1', 'ssid2') + Operations = @() + } + } + + Mock Clear-DnsCacheSafe { + [pscustomobject]@{ + Name = 'Clear DNS cache' + ExitCode = 0 + Succeeded = $true + } + } + + Mock Clear-ArpCacheSafe { + [pscustomobject]@{ + Name = 'Clear ARP cache' + ExitCode = 0 + Succeeded = $true + } + } + + Mock Reset-NetCleanAdapterConfigurationSafe { + [pscustomobject]@{ + Provider = 'Quad9 Secure' + DnsServers = @( + '9.9.9.9', + '149.112.112.112', + '2620:fe::fe', + '2620:fe::9' + ) + PreferIPv4 = $true + RequiresRestart = $true + ConfiguredCount = 1 + SkippedCount = 0 + FailedCount = 0 + Succeeded = $true + Operations = @() + } + } + + Mock Remove-NetworkPrivacyArtifactsSafe { + [pscustomobject]@{ + TotalCandidates = 1 + RemovedCount = 1 + SkippedCount = 0 + Results = @( + [pscustomobject]@{ + RegistryPath = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + Removed = $true + } + ) + } + } + + Mock Clear-NetworkEventLogsSafe { + @([pscustomobject]@{ Name = 'Clear test event log'; Succeeded = $true; Error = $null }) + } + Mock Clear-UserNetworkArtifactsSafe { + @([pscustomobject]@{ Removed = $true; Path = 'HKCU:\Software\Test'; Succeeded = $true; Reason = $null }) + } + Mock Invoke-AdvancedNetworkRepair { + @([pscustomobject]@{ Name = 'Repair'; ExitCode = 0; Succeeded = $true }) + } + Mock Invoke-NetworkPerformanceTune { + @([pscustomobject]@{ Name = 'Tune'; ExitCode = 0; Succeeded = $true; Applied = $true; Profile = 'Optimal' }) + } + } + + It 'runs safe conference prep without advanced repair by default' { + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun + + $result.Phase | Should -Be 'Clean' + $result.Clean.Summary.WiFiProfilesRemoved | Should -Be 2 + $result.Clean.Summary.RegistryArtifactsRemoved | Should -Be 1 + $result.Clean.Summary.AdvancedRepairActions | Should -Be 0 + $result.Clean.Summary.PerformanceTuningActions | Should -Be 0 + } + + It 'resets eligible adapter state and propagates dry-run behavior' { + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun + + $result.Clean.AdapterConfiguration.Provider | Should -Be 'Quad9 Secure' + $result.Clean.DryRun | Should -BeTrue + $result.Clean.Summary.AdaptersConfigured | Should -Be 1 + $result.Clean.Summary.AdaptersSkipped | Should -Be 0 + $result.Clean.Summary.AdapterFailures | Should -Be 0 + $result.Clean.Summary.PreferIPv4 | Should -BeTrue + Should -Invoke Reset-NetCleanAdapterConfigurationSafe -Times 1 -ParameterFilter { + $Context -eq $script:Context -and $DryRun + } + } + + It 'preserves NLA connectivity-probe configuration during privacy cleanup' { + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun + + @($result.Clean.Nla).Count | Should -Be 0 + Get-Command Clear-NlaProbeStateSafe -ErrorAction SilentlyContinue | + Should -BeNullOrEmpty + } + + It 'skips Wi-Fi cleanup when SkipWifi is used' { + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun -SkipWifi + + $result.Clean.Summary.WiFiProfilesRemoved | Should -Be 0 + $result.Clean.WiFi.Skipped | Should -BeTrue + $result.Clean.WiFi.Reason | Should -Be 'SkippedByOption' + Should -Invoke Remove-WiFiProfilesSafe -Times 0 + } + + It 'removes the union of backed-up and newly discovered Wi-Fi profiles' { + $script:Context | Add-Member -NotePropertyName Protect -NotePropertyValue ([pscustomobject]@{ + Summary = [pscustomobject]@{ + WiFiProfilesFound = @('BackedUpSSID') + } + }) + Mock Get-WiFiProfileName { @('NewSSID') } + + $null = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun + + Should -Invoke Remove-WiFiProfilesSafe -Times 1 -ParameterFilter { + @($WifiProfiles).Count -eq 2 -and + $WifiProfiles -contains 'BackedUpSSID' -and + $WifiProfiles -contains 'NewSSID' + } + } + + It 'skips DNS cleanup when SkipDnsFlush is used' { + $null = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun -SkipDnsFlush + + Should -Invoke Clear-DnsCacheSafe -Times 0 + Should -Invoke Clear-ArpCacheSafe -Times 1 + } + + It 'skips event log cleanup when SkipEventLogs is used' { + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun -SkipEventLogs + + $result.Clean.Summary.EventLogsTouched | Should -Be 0 + Should -Invoke Clear-NetworkEventLogsSafe -Times 0 + } + + It 'skips user artifact cleanup when SkipUserArtifacts is used' { + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun -SkipUserArtifacts + + $result.Clean.Summary.UserArtifactsTouched | Should -Be 0 + Should -Invoke Clear-UserNetworkArtifactsSafe -Times 0 + } + + It 'includes advanced repair actions in AdvancedRepair mode' { + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode AdvancedRepair -DryRun + + $result.Clean.Summary.AdvancedRepairActions | Should -Be 1 + Should -Invoke Invoke-AdvancedNetworkRepair -Times 1 + } + + It 'includes performance tuning actions in PerformanceTune mode' { + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode PerformanceTune -PerformanceProfile Optimal -DryRun + + $result.Clean.Summary.PerformanceTuningActions | Should -Be 1 + Should -Invoke Invoke-NetworkPerformanceTune -Times 1 + } + + It 'throws when PerformanceTune mode is used without a PerformanceProfile' { + { Invoke-NetCleanPhase3Clean -Context $script:Context -Mode PerformanceTune -DryRun } | Should -Throw + } + + It 'logs the event operation name instead of a boolean expression result' { + $script:logMessages = [System.Collections.Generic.List[string]]::new() + Mock Write-NetCleanLog { [void]$script:logMessages.Add($Message) } + + $null = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun + + $script:logMessages | Should -Contain 'Event log operation: Clear test event log => OK' + $script:logMessages | Should -Not -Contain 'Event log operation: True => OK' + } + + It 'audits live cleanup failures, skips, and defensive event-log fallbacks' { + Mock Get-WiFiProfileName { @() } + Mock Remove-WiFiProfilesSafe { + [pscustomobject]@{ Removed = 0; Profiles = @(); Operations = @() } + } + Mock Remove-NetworkPrivacyArtifactsSafe { + [pscustomobject]@{ + TotalCandidates = 2 + RemovedCount = 0 + SkippedCount = 1 + Results = @( + [pscustomobject]@{ + RegistryPath = 'HKLM:\Skipped'; Removed = $false + Skipped = $true; Reason = 'Protected' + } + [pscustomobject]@{ + RegistryPath = 'HKLM:\Failed'; Removed = $false + Skipped = $false; Reason = 'access denied' + } + ) + } + } + Mock Clear-NetworkEventLogsSafe { + @( + [pscustomobject]@{ + Command = 'wevtutil cl WLAN'; Succeeded = $false; Error = 'event denied' + } + [pscustomobject]@{ LogName = 'NetworkProfile' } + $null + ) + } + Mock Clear-UserNetworkArtifactsSafe { + @([pscustomobject]@{ + Removed = $false; Path = 'HKCU:\Failed'; Succeeded = $false; Reason = 'user denied' + }) + } + Mock Reset-NetCleanAdapterConfigurationSafe { + [pscustomobject]@{ + Provider = 'Quad9 Secure'; PreferIPv4 = $true; RequiresRestart = $true + ConfiguredCount = 1; SkippedCount = 1; FailedCount = 1; Succeeded = $false + Operations = @( + [pscustomobject]@{ Name = 'VPN'; Skipped = $true; Succeeded = $true; Reason = 'ProtectedAdapter'; Error = $null } + [pscustomobject]@{ Name = 'Ethernet'; Skipped = $false; Succeeded = $true; Reason = 'Configured'; Error = $null } + [pscustomobject]@{ Name = 'Wi-Fi'; Skipped = $false; Succeeded = $false; Reason = 'DnsConfigurationFailed'; Error = 'DNS denied' } + ) + } + } + $script:logMessages = [System.Collections.Generic.List[string]]::new() + Mock Write-NetCleanLog { [void]$script:logMessages.Add($Message) } + + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep + + $result.Clean.Summary.AdapterFailures | Should -Be 1 + $script:logMessages | Should -Contain 'Registry artifact: HKLM:\Skipped => Skipped: Protected' + $script:logMessages | Should -Contain 'Registry artifact: HKLM:\Failed => Failed: access denied' + $script:logMessages | Should -Contain 'Event log operation: wevtutil cl WLAN => ERR: event denied' + $script:logMessages | Should -Contain 'Event log operation: NetworkProfile => (unknown)' + $script:logMessages | Should -Contain 'Event log operation: (unknown) => (unknown)' + $script:logMessages | Should -Contain 'User artifact: HKCU:\Failed => ERR: user denied' + $script:logMessages | Should -Contain 'Adapter configuration: VPN => Skipped: ProtectedAdapter' + $script:logMessages | Should -Contain 'Adapter configuration: Ethernet => IPv4 DHCP and Quad9 DNS configured' + $script:logMessages | Should -Contain 'Adapter configuration: Wi-Fi => Failed: DNS denied' + @($script:logMessages | Where-Object { $_ -match '^Phase 3 clean complete\.' }).Count | Should -Be 1 + } + } + } +} diff --git a/tests/Unit/NetClean.Phase4.Unit.Tests.ps1 b/tests/Unit/NetClean.Phase4.Unit.Tests.ps1 new file mode 100644 index 0000000..39522e5 --- /dev/null +++ b/tests/Unit/NetClean.Phase4.Unit.Tests.ps1 @@ -0,0 +1,1146 @@ +$manifestPath = Join-Path $PSScriptRoot '..\..\NetClean.psd1' + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" +} + +Remove-Module NetClean -ErrorAction SilentlyContinue +Import-Module $manifestPath -Force + +Describe 'NetClean Phase 4 unit tests' { + + InModuleScope 'NetClean' { + + BeforeEach { + $script:preInventory = @( + [pscustomobject]@{ + Vendor = 'Contoso Security' + Services = @('ContosoAgent') + ProtectedInterfaceGuids = @('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + } + ) + + $script:postInventory = @( + [pscustomobject]@{ + Vendor = 'Contoso Security' + Services = @('ContosoAgent') + ProtectedInterfaceGuids = @('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + } + ) + + $script:context = [pscustomobject]@{ + Phase = 'Clean' + BackupPath = 'C:\backup' + Inventory = $script:preInventory + } + + Mock Get-ProtectionInventory { $script:postInventory } + Mock Get-WiFiProfileName { @() } + Mock Get-NetworkListProfileName { @() } + Mock Write-NetCleanLog {} + Mock Write-Information {} + } + + Context 'Test-NetCleanAdapterPostState' { + + BeforeEach { + $script:context | Add-Member -NotePropertyName Clean -NotePropertyValue ([pscustomobject]@{ + DryRun = $false + AdapterConfiguration = [pscustomobject]@{ + DnsServers = @( + '9.9.9.9', + '149.112.112.112', + '2620:fe::fe', + '2620:fe::9' + ) + PreferIPv4 = $true + IPv4Preference = [pscustomobject]@{ + Succeeded = $true + Skipped = $false + } + DnsOverHttps = [pscustomobject]@{ + Supported = $true + ConfiguredCount = 4 + } + Operations = @( + [pscustomobject]@{ + Name = 'Wi-Fi' + InterfaceIndex = 12 + Succeeded = $true + Skipped = $false + DryRun = $false + } + ) + } + }) + + Mock Get-NetIPInterface { + [pscustomobject]@{ InterfaceIndex = 12; Dhcp = 'Enabled' } + } + Mock Get-DnsClientServerAddress { + @( + [pscustomobject]@{ + InterfaceIndex = 12 + AddressFamily = 'IPv4' + ServerAddresses = @('9.9.9.9', '149.112.112.112') + } + [pscustomobject]@{ + InterfaceIndex = 12 + AddressFamily = 'IPv6' + ServerAddresses = @('2620:fe::fe', '2620:fe::9') + } + ) + } + Mock Get-ItemProperty { + [pscustomobject]@{ DisabledComponents = 32 } + } + Mock Get-DnsClientDohServerAddress { + [pscustomobject]@{ + ServerAddress = $ServerAddress + DohTemplate = 'https://dns.quad9.net/dns-query' + AutoUpgrade = $true + AllowFallbackToUdp = $false + } + } + } + + It 'passes only when DHCP, the full Quad9 set, IPv4 preference, and DoH are observed' { + $result = Test-NetCleanAdapterPostState -Context $script:context + + $result.Applicable | Should -BeTrue + $result.Passed | Should -BeTrue + @($result.Checks | Where-Object { -not $_.Passed }).Count | Should -Be 0 + } + + It 'is not applicable when no adapter configuration was recorded' { + $result = Test-NetCleanAdapterPostState -Context ([pscustomobject]@{}) + + $result.Applicable | Should -BeFalse + $result.Passed | Should -BeTrue + $result.Reason | Should -Be 'NoAdapterChanges' + } + + It 'ignores skipped adapter operations and disabled optional verification' { + $configuration = $script:context.Clean.AdapterConfiguration + $configuration.Operations[0].Skipped = $true + $configuration.PreferIPv4 = $false + $configuration.DnsOverHttps.Supported = $false + + $result = Test-NetCleanAdapterPostState -Context $script:context + + $result.Passed | Should -BeTrue + $result.Checks.Count | Should -Be 0 + Should -Invoke Get-NetIPInterface -Times 0 + Should -Invoke Get-DnsClientServerAddress -Times 0 + Should -Invoke Get-ItemProperty -Times 0 + Should -Invoke Get-DnsClientDohServerAddress -Times 0 + } + + It 'reports a failed adapter command without querying adapter state' { + $operation = $script:context.Clean.AdapterConfiguration.Operations[0] + $operation.Succeeded = $false + $operation | Add-Member -NotePropertyName Reason -NotePropertyValue 'DhcpConfigurationFailed' + $operation | Add-Member -NotePropertyName Error -NotePropertyValue 'access denied' + + $result = Test-NetCleanAdapterPostState -Context $script:context + $check = $result.Checks | Where-Object Category -EQ 'AdapterCommand' + + $result.Passed | Should -BeFalse + $check.Actual | Should -Be 'DhcpConfigurationFailed' + $check.Error | Should -Be 'access denied' + Should -Invoke Get-NetIPInterface -Times 0 + Should -Invoke Get-DnsClientServerAddress -Times 0 + } + + It 'fails when IPv4 DHCP is disabled or absent' -ForEach @( + @{ States = @([pscustomobject]@{ Dhcp = 'Disabled' }) } + @{ States = @() } + ) { + Mock Get-NetIPInterface { $States } + + $result = Test-NetCleanAdapterPostState -Context $script:context + $check = $result.Checks | Where-Object Category -EQ 'IPv4Dhcp' + + $result.Passed | Should -BeFalse + $check.Passed | Should -BeFalse + } + + It 'fails closed when DHCP or DNS state cannot be queried' -ForEach @( + @{ Command = 'Dhcp'; ErrorText = 'IP interface unavailable'; Category = 'IPv4Dhcp' } + @{ Command = 'Dns'; ErrorText = 'DNS state unavailable'; Category = 'DnsServers' } + ) { + if ($Command -eq 'Dhcp') { + Mock Get-NetIPInterface { throw $ErrorText } + } + else { + Mock Get-DnsClientServerAddress { throw $ErrorText } + } + + $result = Test-NetCleanAdapterPostState -Context $script:context + $check = $result.Checks | Where-Object Category -EQ $Category + + $result.Passed | Should -BeFalse + $check.Passed | Should -BeFalse + $check.Error | Should -Be $ErrorText + } + + It 'fails when an adapter is missing any configured Quad9 resolver' { + Mock Get-DnsClientServerAddress { + [pscustomobject]@{ + InterfaceIndex = 12 + AddressFamily = 'IPv4' + ServerAddresses = @('9.9.9.9') + } + } + + (Test-NetCleanAdapterPostState -Context $script:context).Passed | Should -BeFalse + } + + It 'fails when the registry does not prefer IPv4' { + Mock Get-ItemProperty { + [pscustomobject]@{ DisabledComponents = 0 } + } + + (Test-NetCleanAdapterPostState -Context $script:context).Passed | Should -BeFalse + } + + It 'fails closed when the IPv4 preference cannot be read' { + Mock Get-ItemProperty { throw 'registry unavailable' } + + $result = Test-NetCleanAdapterPostState -Context $script:context + $check = $result.Checks | Where-Object Category -EQ 'IPv4Preference' + + $result.Passed | Should -BeFalse + $check.Passed | Should -BeFalse + $check.Error | Should -Be 'registry unavailable' + } + + It 'fails when encrypted DNS permits plaintext fallback' { + Mock Get-DnsClientDohServerAddress { + [pscustomobject]@{ + ServerAddress = $ServerAddress + DohTemplate = 'https://dns.quad9.net/dns-query' + AutoUpgrade = $true + AllowFallbackToUdp = $true + } + } + + (Test-NetCleanAdapterPostState -Context $script:context).Passed | Should -BeFalse + } + + It 'fails when an expected encrypted DNS server entry is missing' { + Mock Get-DnsClientDohServerAddress { @() } + + $result = Test-NetCleanAdapterPostState -Context $script:context + $checks = @($result.Checks | Where-Object Category -EQ 'DnsOverHttps') + + $result.Passed | Should -BeFalse + @($checks | Where-Object Actual -EQ 'Missing').Count | Should -Be 4 + } + + It 'fails closed when encrypted DNS state cannot be queried' { + Mock Get-DnsClientDohServerAddress { throw 'DoH state unavailable' } + + $result = Test-NetCleanAdapterPostState -Context $script:context + $checks = @($result.Checks | Where-Object Category -EQ 'DnsOverHttps') + + $result.Passed | Should -BeFalse + @($checks | Where-Object Error -EQ 'DoH state unavailable').Count | Should -Be 4 + } + + It 'marks adapter post-state checks not applicable during a dry run' { + $script:context.Clean.DryRun = $true + + $result = Test-NetCleanAdapterPostState -Context $script:context + + $result.Applicable | Should -BeFalse + $result.Passed | Should -BeTrue + $result.Reason | Should -Be 'DryRun' + Should -Invoke Get-NetIPInterface -Times 0 + } + } + + Context 'Test-NetCleanCleanupPostState' { + + BeforeEach { + $script:context | Add-Member -NotePropertyName Clean -NotePropertyValue ([pscustomobject]@{ + DryRun = $false + Dns = [pscustomobject]@{ + Name = 'Flush DNS cache' + Succeeded = $true + Skipped = $false + } + Arp = [pscustomobject]@{ + Name = 'Clear ARP cache' + Succeeded = $true + Skipped = $false + } + WiFi = [pscustomobject]@{ + Skipped = $false + Operations = @( + [pscustomobject]@{ + Name = 'ConferenceWiFi' + Succeeded = $true + Skipped = $false + } + ) + } + RegistryArtifacts = [pscustomobject]@{ + Results = @( + [pscustomobject]@{ + RegistryPath = 'HKLM\SOFTWARE\Microsoft\TestArtifact' + Removed = $true + Succeeded = $true + Skipped = $false + Reason = 'Removed' + } + ) + } + UserArtifacts = @( + [pscustomobject]@{ + Path = 'HKCU:\Software\Microsoft\TestArtifact' + Removed = $true + Succeeded = $true + Reason = $null + } + ) + EventLogs = @( + [pscustomobject]@{ + LogName = 'Microsoft-Windows-NetworkProfile/Operational' + Cleared = $true + Succeeded = $true + Skipped = $false + CompletedAt = [datetime]'2026-07-20T12:00:00' + Error = $null + } + ) + AdvancedRepair = @( + [pscustomobject]@{ + Name = 'Reset Winsock' + Succeeded = $true + Skipped = $false + } + ) + PerformanceTuning = @( + [pscustomobject]@{ + Name = 'Set TCP autotuning to normal' + Succeeded = $true + Skipped = $false + } + ) + }) + + Mock Test-RegistryPathExist { $false } + Mock Test-Path { $false } + Mock Get-WinEvent { @() } + Mock Get-NetAdapter { + [pscustomobject]@{ + Name = 'Wi-Fi' + InterfaceIndex = 12 + Status = 'Disconnected' + MediaType = 'Native 802.11' + PhysicalMediaType = 'Native 802.11' + NdisPhysicalMedium = 9 + } + } + Mock Get-DnsClientCache { @() } + Mock Get-NetNeighbor { + [pscustomobject]@{ + InterfaceIndex = 12 + IPAddress = '255.255.255.255' + State = 'Permanent' + } + } + } + + It 'is not applicable when no cleanup results were recorded' { + $result = Test-NetCleanCleanupPostState -Context ([pscustomobject]@{}) + + $result.Applicable | Should -BeFalse + $result.Passed | Should -BeTrue + $result.Reason | Should -Be 'NoCleanupResults' + $result.Checks.Count | Should -Be 0 + } + + It 'passes when persistent artifacts remain absent and volatile actions succeeded' { + $result = Test-NetCleanCleanupPostState -Context $script:context + + $result.Applicable | Should -BeTrue + @($result.Checks | Where-Object { -not $_.Passed }).Count | + Should -Be 0 -Because ($result.Checks | ConvertTo-Json -Depth 5 -Compress) + $result.Passed | Should -BeTrue + @($result.Checks.Category) | Should -Contain 'RegistryArtifact' + @($result.Checks.Category) | Should -Contain 'UserArtifact' + @($result.Checks.Category) | Should -Contain 'EventLog' + @($result.Checks.Category) | Should -Contain 'VolatileCacheAction' + @($result.Checks.Category) | Should -Contain 'WiFiConnection' + @($result.Checks.Category) | Should -Contain 'DnsCache' + @($result.Checks.Category) | Should -Contain 'ArpCache' + Should -Invoke Test-RegistryPathExist -Times 1 -ParameterFilter { + $RegistryPath -eq 'HKLM\SOFTWARE\Microsoft\TestArtifact' + } + Should -Invoke Get-WinEvent -Times 1 -ParameterFilter { + $FilterHashtable.LogName -eq 'Microsoft-Windows-NetworkProfile/Operational' -and + $FilterHashtable.EndTime -eq [datetime]'2026-07-20T12:00:00' + } + } + + It 'handles sparse mixed cleanup ledgers without assuming optional properties' { + $script:context.Clean.Dns = $null + $script:context.Clean.Arp = [pscustomobject]@{ + Name = 'Clear ARP cache'; Succeeded = $false + Skipped = $true; Reason = 'SkippedByOption' + } + $script:context.Clean.RegistryArtifacts.Results = @( + $null + [pscustomobject]@{ + RegistryPath = 'HKLM:\Protected'; Succeeded = $true + Skipped = $true; Removed = $false; Reason = 'Protected' + } + [pscustomobject]@{ + RegistryPath = 'HKLM:\Absent'; Succeeded = $true + Skipped = $false; Removed = $false; Reason = 'NotFound' + } + [pscustomobject]@{ + RegistryPath = 'HKLM:\Incomplete'; Succeeded = $true + Skipped = $false; Removed = $false + } + ) + $script:context.Clean.UserArtifacts = @( + $null + [pscustomobject]@{ + Path = 'HKCU:\Absent'; Succeeded = $true + Removed = $false; Reason = 'NotFound' + } + [pscustomobject]@{ + Path = 'HKCU:\Incomplete'; Succeeded = $true; Removed = $false + } + ) + $script:context.Clean.EventLogs = @( + $null + [pscustomobject]@{ + Name = 'Clear unnamed event log'; Succeeded = $false; Cleared = $false + } + ) + $script:context.Clean.AdvancedRepair = @( + $null + [pscustomobject]@{ Name = 'Reset Winsock'; Succeeded = $true } + ) + $script:context.Clean.PerformanceTuning = @( + [pscustomobject]@{ Name = 'Set autotuning'; Succeeded = $false; Error = 'command failed' } + ) + Mock Get-NetAdapter { + [pscustomobject]@{ + Name = 'Wi-Fi'; InterfaceIndex = 12; Status = 'Disconnected' + MediaType = 'Native 802.11'; PhysicalMediaType = 'Native 802.11' + } + } + + $result = Test-NetCleanCleanupPostState -Context $script:context + + $result.Passed | Should -BeFalse + ($result.Checks | Where-Object Target -EQ 'Clear ARP cache').Actual | + Should -Be 'SkippedByOption' + ($result.Checks | Where-Object Target -EQ 'HKLM:\Protected').Passed | + Should -BeTrue + ($result.Checks | Where-Object Target -EQ 'HKLM:\Incomplete').Actual | + Should -Be 'Failed' + ($result.Checks | Where-Object Target -EQ 'HKCU:\Incomplete').Error | + Should -BeNullOrEmpty + ($result.Checks | Where-Object Category -EQ 'EventLog').Target | + Should -Be 'Clear unnamed event log' + ($result.Checks | Where-Object Category -EQ 'AdvancedRepairAction').Passed | + Should -BeTrue + ($result.Checks | Where-Object Category -EQ 'PerformanceTuningAction').Error | + Should -Be 'command failed' + } + + It 'does not use DNS or ARP contents as evidence while a wired LAN is connected' { + Mock Get-NetAdapter { + [pscustomobject]@{ + Name = 'Ethernet' + InterfaceIndex = 4 + Status = 'Up' + MediaType = '802.3' + PhysicalMediaType = '802.3' + NdisPhysicalMedium = 14 + } + } + Mock Get-DnsClientCache { + [pscustomobject]@{ Entry = 'expected-lan-traffic.example' } + } + Mock Get-NetNeighbor { + [pscustomobject]@{ InterfaceIndex = 4; State = 'Reachable' } + } + + $result = Test-NetCleanCleanupPostState -Context $script:context + + $result.Passed | Should -BeTrue + @($result.Checks | Where-Object { + $_.Category -in @('DnsCache', 'ArpCache') -and $_.Applicable + }).Count | Should -Be 0 + Should -Invoke Get-DnsClientCache -Times 0 + Should -Invoke Get-NetNeighbor -Times 0 + } + + It 'fails when DNS cache entries remain without a connected wired LAN' { + Mock Get-DnsClientCache { + [pscustomobject]@{ Entry = 'home-network.example' } + } + + $result = Test-NetCleanCleanupPostState -Context $script:context + + $result.Passed | Should -BeFalse + @($result.Checks | Where-Object { + $_.Category -eq 'DnsCache' -and -not $_.Passed + }).Count | Should -Be 1 + } + + It 'fails when a dynamic ARP neighbor remains without a connected wired LAN' { + Mock Get-NetNeighbor { + [pscustomobject]@{ + InterfaceIndex = 12 + IPAddress = '192.0.2.1' + State = 'Stale' + } + } + + $result = Test-NetCleanCleanupPostState -Context $script:context + + $result.Passed | Should -BeFalse + @($result.Checks | Where-Object { + $_.Category -eq 'ArpCache' -and -not $_.Passed + }).Count | Should -Be 1 + } + + It 'fails when Wi-Fi remains connected after profile cleanup' { + Mock Get-NetAdapter { + [pscustomobject]@{ + Name = 'Wi-Fi' + InterfaceIndex = 12 + Status = 'Up' + MediaType = 'Native 802.11' + PhysicalMediaType = 'Native 802.11' + NdisPhysicalMedium = 9 + } + } + + $result = Test-NetCleanCleanupPostState -Context $script:context + + $result.Passed | Should -BeFalse + @($result.Checks | Where-Object { + $_.Category -eq 'WiFiConnection' -and -not $_.Passed + }).Count | Should -Be 1 + } + + It 'fails when a removed registry artifact is still present' { + Mock Test-RegistryPathExist { $true } + + $result = Test-NetCleanCleanupPostState -Context $script:context + + $result.Passed | Should -BeFalse + @($result.Checks | Where-Object { + $_.Category -eq 'RegistryArtifact' -and -not $_.Passed + }).Count | Should -Be 1 + } + + It 'fails closed when registry post-state cannot be read' { + Mock Test-RegistryPathExist { throw 'registry provider unavailable' } + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'RegistryArtifact' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.VerificationType | Should -Be 'IndependentState' + $check.Actual | Should -Be 'Unknown' + $check.Error | Should -Be 'registry provider unavailable' + Should -Invoke Test-RegistryPathExist -Times 1 -ParameterFilter { + $RegistryPath -eq 'HKLM\SOFTWARE\Microsoft\TestArtifact' -and + $ThrowOnError + } + } + + It 'reports a failed registry cleanup ledger' { + $operation = @($script:context.Clean.RegistryArtifacts.Results)[0] + $operation.Removed = $false + $operation.Succeeded = $false + $operation.Reason = 'AccessDenied' + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'RegistryArtifact' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.VerificationType | Should -Be 'CommandResult' + $check.Actual | Should -Be 'AccessDenied' + $check.Error | Should -Be 'AccessDenied' + Should -Invoke Test-RegistryPathExist -Times 0 + } + + It 'fails closed when user-artifact post-state cannot be read' { + Mock Test-Path { throw 'user registry provider unavailable' } + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'UserArtifact' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.VerificationType | Should -Be 'IndependentState' + $check.Actual | Should -Be 'Unknown' + $check.Error | Should -Be 'user registry provider unavailable' + } + + It 'reports a failed user-artifact cleanup ledger' { + $operation = @($script:context.Clean.UserArtifacts)[0] + $operation.Removed = $false + $operation.Succeeded = $false + $operation.Reason = 'AccessDenied' + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'UserArtifact' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.VerificationType | Should -Be 'CommandResult' + $check.Actual | Should -Be 'AccessDenied' + $check.Error | Should -Be 'AccessDenied' + Should -Invoke Test-Path -Times 0 + } + + It 'fails when an event from before the clear completion remains' { + Mock Get-WinEvent { + [pscustomobject]@{ Id = 10000; TimeCreated = [datetime]'2026-07-20T11:59:00' } + } + + $result = Test-NetCleanCleanupPostState -Context $script:context + + $result.Passed | Should -BeFalse + @($result.Checks | Where-Object { + $_.Category -eq 'EventLog' -and -not $_.Passed + }).Count | Should -Be 1 + } + + It 'treats a no-matching-events result as verified absence' { + Mock Get-WinEvent { + $exception = [System.Exception]::new('No matching events were found.') + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'NoMatchingEventsFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $null + ) + throw $errorRecord + } + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'EventLog' | + Select-Object -First 1 + + $result.Passed | Should -BeTrue + $check.Actual | Should -Be 'Absent' + $check.Passed | Should -BeTrue + $check.Error | Should -BeNullOrEmpty + } + + It 'fails closed when event-log post-state cannot be read' { + Mock Get-WinEvent { throw 'event log unavailable' } + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'EventLog' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.Actual | Should -Be 'Unknown' + $check.Passed | Should -BeFalse + $check.Error | Should -Be 'event log unavailable' + } + + It 'reports an event-log cleanup failure' { + $operation = @($script:context.Clean.EventLogs)[0] + $operation.Succeeded = $false + $operation.Error = 'clear failed' + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'EventLog' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.Actual | Should -Be 'Failed' + $check.Error | Should -Be 'clear failed' + Should -Invoke Get-WinEvent -Times 0 + } + + It 'reports an event log that was not cleared' { + $operation = @($script:context.Clean.EventLogs)[0] + $operation.Cleared = $false + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'EventLog' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.Actual | Should -Be 'NotCleared' + Should -Invoke Get-WinEvent -Times 0 + } + + It 'reports an event-log cleanup without a completion timestamp' { + $operation = @($script:context.Clean.EventLogs)[0] + $operation.CompletedAt = $null + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'EventLog' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.Actual | Should -Be 'MissingCompletionTime' + Should -Invoke Get-WinEvent -Times 0 + } + + It 'fails when a cleanup command reported failure' { + $script:context.Clean.Dns.Succeeded = $false + $script:context.Clean.Dns | Add-Member -NotePropertyName Error -NotePropertyValue 'flush failed' + + $result = Test-NetCleanCleanupPostState -Context $script:context + + $result.Passed | Should -BeFalse + @($result.Checks | Where-Object { + $_.Target -eq 'Flush DNS cache' -and -not $_.Passed + }).Count | Should -Be 1 + } + + It 'fails closed when the DNS cache cannot be queried' { + Mock Get-DnsClientCache { throw 'DNS cache unavailable' } + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'DnsCache' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.Actual | Should -Be 'Unknown' + $check.Error | Should -Be 'DNS cache unavailable' + } + + It 'passes ARP verification when physical adapters have no interface indexes' { + Mock Get-NetAdapter { + [pscustomobject]@{ + Name = 'Wi-Fi' + Status = 'Disconnected' + MediaType = 'Native 802.11' + PhysicalMediaType = 'Native 802.11' + NdisPhysicalMedium = 9 + } + } + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'ArpCache' | + Select-Object -First 1 + + $result.Passed | Should -BeTrue + $check.Actual.Count | Should -Be 0 + $check.Passed | Should -BeTrue + Should -Invoke Get-NetNeighbor -Times 0 + } + + It 'fails closed when the ARP cache cannot be queried' { + Mock Get-NetNeighbor { throw 'ARP cache unavailable' } + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'ArpCache' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.Actual | Should -Be 'Unknown' + $check.Error | Should -Be 'ARP cache unavailable' + } + + It 'fails closed when physical adapter state cannot be queried' { + Mock Get-NetAdapter { throw 'adapter state unavailable' } + + $result = Test-NetCleanCleanupPostState -Context $script:context + $check = $result.Checks | + Where-Object Category -EQ 'ConnectivityDetection' | + Select-Object -First 1 + + $result.Passed | Should -BeFalse + $check.Actual | Should -Be 'Unknown' + $check.Error | Should -Be 'adapter state unavailable' + Should -Invoke Get-DnsClientCache -Times 0 + Should -Invoke Get-NetNeighbor -Times 0 + } + + It 'marks cleanup post-state checks not applicable during a dry run' { + $script:context.Clean.DryRun = $true + + $result = Test-NetCleanCleanupPostState -Context $script:context + + $result.Applicable | Should -BeFalse + $result.Passed | Should -BeTrue + $result.Reason | Should -Be 'DryRun' + Should -Invoke Test-RegistryPathExist -Times 0 + Should -Invoke Test-Path -Times 0 + Should -Invoke Get-WinEvent -Times 0 + Should -Invoke Get-NetAdapter -Times 0 + Should -Invoke Get-DnsClientCache -Times 0 + Should -Invoke Get-NetNeighbor -Times 0 + } + } + + Context 'Test-NetCleanPostState' { + + It 'passes when protected vendors, GUIDs, and services remain present' { + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeTrue + @($result.VendorComparison.Missing).Count | Should -Be 0 + @($result.GuidComparison.Missing).Count | Should -Be 0 + @($result.ServiceComparison.Missing).Count | Should -Be 0 + @($result.RemainingWiFiProfiles).Count | Should -Be 0 + @($result.RemainingNetworkProfiles).Count | Should -Be 0 + } + + It 'fails when a protected vendor is missing' { + $script:postInventory = @() + + (Test-NetCleanPostState -Context $script:context).Passed | Should -BeFalse + } + + It 'fails when a protected interface GUID is missing' { + $script:postInventory[0].ProtectedInterfaceGuids = @() + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeFalse + @($result.GuidComparison.Missing) | Should -Contain 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + } + + It 'fails when a protected service is missing' { + $script:postInventory[0].Services = @() + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeFalse + @($result.ServiceComparison.Missing) | Should -Contain 'ContosoAgent' + } + + It 'does not fail only because new protected items were detected' { + $script:postInventory += [pscustomobject]@{ + Vendor = 'Fabrikam Security' + Services = @('FabrikamAgent') + ProtectedInterfaceGuids = @('11111111-2222-3333-4444-555555555555') + } + + (Test-NetCleanPostState -Context $script:context).Passed | Should -BeTrue + } + + It 'fails when a saved Wi-Fi profile remains' { + Mock Get-WiFiProfileName { @('HomeSSID') } + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeFalse + $result.RemainingWiFiProfiles | Should -Contain 'HomeSSID' + } + + It 'fails when a Windows NetworkList profile remains' { + Mock Get-NetworkListProfileName { @('Home network') } + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeFalse + $result.RemainingNetworkProfiles | Should -Contain 'Home network' + } + + It 'fails the overall verification when adapter post-state does not match' { + Mock Test-NetCleanAdapterPostState { + [pscustomobject]@{ + Applicable = $true + Passed = $false + Reason = 'Verified' + Checks = @( + [pscustomobject]@{ + Category = 'DnsServers' + Target = 'Wi-Fi' + Passed = $false + } + ) + } + } + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeFalse + $result.AdapterVerification.Passed | Should -BeFalse + Should -Invoke Test-NetCleanAdapterPostState -Times 1 + } + + It 'fails the overall verification when cleanup post-state does not match' { + Mock Test-NetCleanCleanupPostState { + [pscustomobject]@{ + Applicable = $true + Passed = $false + Reason = 'Verified' + Checks = @( + [pscustomobject]@{ + Category = 'DnsCache' + Target = 'DNS client cache' + Passed = $false + } + ) + } + } + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeFalse + $result.CleanupVerification.Passed | Should -BeFalse + Should -Invoke Test-NetCleanCleanupPostState -Times 1 + } + + It 'does not require cleanup post-state during a dry run' { + $script:context | Add-Member -NotePropertyName Clean -NotePropertyValue ([pscustomobject]@{ + DryRun = $true + }) + Mock Get-WiFiProfileName { @('HomeSSID') } + Mock Get-NetworkListProfileName { @('Home network') } + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeTrue + $result.VerificationMode | Should -Be 'Planned' + @($result.RemainingWiFiProfiles).Count | Should -Be 0 + @($result.RemainingNetworkProfiles).Count | Should -Be 0 + Should -Invoke Get-WiFiProfileName -Times 0 + Should -Invoke Get-NetworkListProfileName -Times 0 + } + } + + Context 'Export-NetCleanVerificationReport' { + + It 'returns a planned report path without writing during a dry run' { + Mock WriteAllText {} + + $result = Export-NetCleanVerificationReport ` + -Dest 'C:\backup' ` + -Verification ([pscustomobject]@{ Passed = $true }) ` + -DryRun + + $result | Should -Match 'VerificationReport' + Should -Invoke WriteAllText -Times 0 + } + + It 'writes the verification ledger as UTF-8 JSON' { + Mock New-DirectoryIfNotExist {} + Mock Set-NetCleanPrivateDirectoryAcl {} + Mock WriteAllText {} + + $verification = [pscustomobject]@{ + Passed = $false + VerificationMode = 'Observed' + VendorComparison = [pscustomobject]@{ Missing = @('Missing vendor') } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison = [pscustomobject]@{ Missing = @() } + RemainingWiFiProfiles = @('HomeSSID') + RemainingNetworkProfiles = @() + AdapterVerification = [pscustomobject]@{ + Applicable = $true + Passed = $false + Checks = @( + [pscustomobject]@{ + Category = 'DnsServers' + Target = 'Wi-Fi' + Passed = $false + } + ) + } + CleanupVerification = [pscustomobject]@{ + Applicable = $true + Passed = $false + Checks = @( + [pscustomobject]@{ + Category = 'UserArtifact' + Target = 'HKCU:\Software\Microsoft\TestArtifact' + Passed = $false + } + ) + } + } + + $result = Export-NetCleanVerificationReport ` + -Dest 'C:\backup' ` + -Verification $verification + + Should -Invoke Set-NetCleanPrivateDirectoryAcl -Times 1 -ParameterFilter { + $Path -eq 'C:\backup' + } + Should -Invoke WriteAllText -Times 1 -Exactly -ParameterFilter { + $Path -eq $result -and + $Contents -match 'DnsServers' -and + $Contents -match 'UserArtifact' -and + $Contents -match 'HomeSSID' -and + $Encoding.WebName -eq 'utf-8' + } + } + } + + Context 'Invoke-NetCleanPhase4Verify' { + + BeforeEach { + Mock Export-NetCleanVerificationReport { 'C:\backup\VerificationReport.json' } + } + + It 'preserves the incoming context and adds a passing verification summary' { + $result = Invoke-NetCleanPhase4Verify -Context $script:context + + $result.Phase | Should -Be 'Verify' + $result.BackupPath | Should -Be 'C:\backup' + $result.Verify.Passed | Should -BeTrue + $result.Verify.Summary.Passed | Should -BeTrue + $result.Verify.Summary.MissingVendorsCount | Should -Be 0 + $result.Verify.Summary.MissingGuidCount | Should -Be 0 + $result.Verify.Summary.MissingServiceCount | Should -Be 0 + $result.Verify.Summary.RemainingWiFiProfileCount | Should -Be 0 + $result.Verify.Summary.RemainingNetworkProfileCount | Should -Be 0 + $result.Verify.AdapterVerification.Passed | Should -BeTrue + $result.Verify.Summary.AdapterCheckFailureCount | Should -Be 0 + $result.Verify.CleanupVerification.Passed | Should -BeTrue + $result.Verify.Summary.CleanupCheckFailureCount | Should -Be 0 + $result.Verify.VerificationReport | Should -Be 'C:\backup\VerificationReport.json' + Should -Invoke Export-NetCleanVerificationReport -Times 1 -ParameterFilter { + $Dest -eq 'C:\backup' -and -not $DryRun + } + } + + It 'reports all missing protected categories in the summary' { + $script:postInventory = @() + + $result = Invoke-NetCleanPhase4Verify -Context $script:context + + $result.Verify.Passed | Should -BeFalse + $result.Verify.Summary.Passed | Should -BeFalse + $result.Verify.Summary.MissingVendorsCount | Should -Be 1 + $result.Verify.Summary.MissingGuidCount | Should -Be 1 + $result.Verify.Summary.MissingServiceCount | Should -Be 1 + } + + It 'reports cleanup check failures in the summary' { + Mock Test-NetCleanCleanupPostState { + [pscustomobject]@{ + Applicable = $true + Passed = $false + Reason = 'Verified' + Checks = @( + [pscustomobject]@{ + Category = 'ArpCache' + Target = 'Physical-adapter IPv4 neighbor cache' + Passed = $false + } + ) + } + } + + $result = Invoke-NetCleanPhase4Verify -Context $script:context + + $result.Verify.Passed | Should -BeFalse + $result.Verify.Summary.CleanupCheckFailureCount | Should -Be 1 + } + + It 'does not export a report when the context has no backup path' { + $contextWithoutBackup = [pscustomobject]@{} + foreach ($property in $script:context.PSObject.Properties) { + if ($property.Name -ne 'BackupPath') { + $contextWithoutBackup | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value + } + } + + $result = Invoke-NetCleanPhase4Verify -Context $contextWithoutBackup + + $result.Verify.VerificationReport | Should -BeNullOrEmpty + Should -Invoke Export-NetCleanVerificationReport -Times 0 + } + + It 'exports a planned verification report in dry-run mode' { + Mock Test-NetCleanPostState { + [pscustomobject]@{ + Passed = $true + VerificationMode = 'Planned' + VendorComparison = [pscustomobject]@{ Missing = @() } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison = [pscustomobject]@{ Missing = @() } + RemainingWiFiProfiles = @() + RemainingNetworkProfiles = @() + AdapterVerification = [pscustomobject]@{ Passed = $true; Checks = @() } + CleanupVerification = [pscustomobject]@{ Passed = $true; Checks = @() } + } + } + + $null = Invoke-NetCleanPhase4Verify -Context $script:context + + Should -Invoke Export-NetCleanVerificationReport -Times 1 -ParameterFilter { $DryRun } + } + + It 'logs missing privacy and protection evidence plus failed adapter and cleanup checks' { + Mock Test-NetCleanPostState { + [pscustomobject]@{ + Passed = $false + VerificationMode = 'Observed' + VendorComparison = [pscustomobject]@{ Missing = @('Contoso Security') } + GuidComparison = [pscustomobject]@{ Missing = @('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') } + ServiceComparison = [pscustomobject]@{ Missing = @('ContosoAgent') } + RemainingWiFiProfiles = @('ConferenceWiFi') + RemainingNetworkProfiles = @('Home network') + AdapterVerification = [pscustomobject]@{ + Passed = $false + Checks = @( + [pscustomobject]@{ + Category = 'DnsServers' + Target = 'Wi-Fi' + Expected = @('9.9.9.9') + Actual = @('192.0.2.53') + Passed = $false + Error = 'mismatch' + } + ) + } + CleanupVerification = [pscustomobject]@{ + Passed = $false + Checks = @( + [pscustomobject]@{ + Category = 'DnsCache' + Target = 'DNS client cache' + Applicable = $true + Expected = 'Empty' + Actual = 'Present' + Passed = $false + Error = 'entry remained' + } + ) + } + } + } + + $result = Invoke-NetCleanPhase4Verify -Context $script:context + + $result.Verify.Passed | Should -BeFalse + $result.Verify.Summary.AdapterCheckFailureCount | Should -Be 1 + $result.Verify.Summary.CleanupCheckFailureCount | Should -Be 1 + Should -Invoke Write-NetCleanLog -ParameterFilter { $Level -eq 'WARN' -and $Message -match 'Missing vendors: Contoso Security' } + Should -Invoke Write-NetCleanLog -ParameterFilter { $Level -eq 'WARN' -and $Message -match 'Remaining Wi-Fi profiles: ConferenceWiFi' } + Should -Invoke Write-NetCleanLog -ParameterFilter { $Level -eq 'WARN' -and $Message -match 'DnsServers.*Error=mismatch' } + Should -Invoke Write-NetCleanLog -ParameterFilter { $Level -eq 'WARN' -and $Message -match 'DnsCache.*Error=entry remained' } + } + } + } +} diff --git a/tests/Unit/NetClean.Safety.Unit.Tests.ps1 b/tests/Unit/NetClean.Safety.Unit.Tests.ps1 new file mode 100644 index 0000000..e18cfb9 --- /dev/null +++ b/tests/Unit/NetClean.Safety.Unit.Tests.ps1 @@ -0,0 +1,121 @@ +$manifestPath = Join-Path $PSScriptRoot '..\..\NetClean.psd1' + +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" +} + +Remove-Module NetClean -ErrorAction SilentlyContinue +Import-Module $manifestPath -Force + +Describe 'NetClean safety regression tests' { + + InModuleScope 'NetClean' { + + BeforeEach { + Mock Write-NetCleanLog {} + } + + Context 'protected registry path boundaries' { + + It 'protects the exact configured path and its descendants' { + $context = [pscustomobject]@{ + ProtectedRegistryPaths = @('HKLM\SOFTWARE\Contoso') + } + + Test-RegistryPathProtected -Path 'HKLM\SOFTWARE\Contoso' -Context $context | + Should -BeTrue + Test-RegistryPathProtected -Path 'HKLM\SOFTWARE\Contoso\Agent' -Context $context | + Should -BeTrue + } + + It 'protects a parent removal when it contains a protected descendant' { + $context = [pscustomobject]@{ + ProtectedRegistryPaths = @('HKLM\SOFTWARE\Contoso\Agent') + } + + Test-RegistryPathProtected -Path 'HKLM\SOFTWARE\Contoso' -Context $context | + Should -BeTrue + } + + It 'does not treat a sibling with the same text prefix as protected' { + $context = [pscustomobject]@{ + ProtectedRegistryPaths = @('HKLM\SOFTWARE\Contoso') + } + + Test-RegistryPathProtected -Path 'HKLM\SOFTWARE\ContosoTools' -Context $context | + Should -BeFalse + } + } + + Context 'network adapter configuration preservation' { + + It 'does not classify TCP/IP or adapter-control configuration roots as sanitizable' { + $interfaceGuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + + Mock Test-RegistryPathExist { $true } + Mock Get-RegistryChildKeyNamesSafe { @($interfaceGuid) } + + $artifacts = @(Get-SanitizableNetworkArtifact -Inventory @()) + + @($artifacts | Where-Object ArtifactType -In @('TcpipInterface', 'NetworkControl')).Count | + Should -Be 0 + @($artifacts | Where-Object ArtifactType -EQ 'NetworkList').Count | + Should -BeGreaterThan 0 + } + } + + Context 'dry-run backup behavior' { + + BeforeEach { + Mock New-DirectoryIfNotExist {} + Mock Set-NetCleanPrivateDirectoryAcl {} + Mock Invoke-RegExport { $FilePath } + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = $Name + ExitCode = 0 + Succeeded = $true + DryRun = $true + Error = $null + } + } + Mock Get-ProtectionRegistryMap { @() } + Mock Get-SanitizableNetworkArtifact { @() } + } + + It 'does not create directories for individual dry-run exports' { + $null = Export-ProtectedRegistryKey -Paths @('HKLM\SOFTWARE\Contoso') -Dest 'C:\backup' -DryRun + $null = Export-NetworkList -Dest 'C:\backup' -DryRun + $null = Export-FirewallPolicy -Dest 'C:\backup' -DryRun + $null = Export-ProtectionInventory -Dest 'C:\backup' -Inventory @() -DryRun + $null = Export-ProtectionRegistryMap -Dest 'C:\backup' -Inventory @() -DryRun + $null = Export-SanitizableNetworkArtifact -Dest 'C:\backup' -Inventory @() -DryRun + $null = Export-NetCleanManifest -Dest 'C:\backup' -Manifest @{ BackupPath = 'C:\backup' } -DryRun + + Should -Invoke New-DirectoryIfNotExist -Times 0 + } + + It 'does not create the backup directory for a dry-run protect phase' { + $context = [pscustomobject]@{ + Phase = 'Detect' + Inventory = @() + ProtectedRegistryPaths = @() + } + + Mock Export-ProtectionInventory { 'C:\backup\ProtectionInventory.json' } + Mock Export-ProtectionRegistryMap { 'C:\backup\ProtectionRegistryMap.json' } + Mock Export-SanitizableNetworkArtifact { 'C:\backup\SanitizableNetworkArtifact.json' } + Mock Export-NetworkList { 'C:\backup\NetworkList.reg' } + Mock Export-WiFiProfile { @() } + Mock Export-FirewallPolicy { 'C:\backup\FirewallPolicy.wfw' } + Mock Export-NetCleanManifest { 'C:\backup\RestoreManifest.json' } + + $result = Invoke-NetCleanPhase2Protect -Context $context -BackupPath 'C:\backup' -DryRun + + $result.Phase | Should -Be 'Protect' + Should -Invoke New-DirectoryIfNotExist -Times 0 + Should -Invoke Set-NetCleanPrivateDirectoryAcl -Times 0 + } + } + } +}