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..a08364a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,35 +2,245 @@ name: CI on: push: - branches: [ main ] + branches: + - main + - 'ci/**' + - 'feature/**' + - 'bugfix/**' + paths-ignore: + - '**.md' + - 'LICENSE' pull_request: - branches: [ main ] + paths-ignore: + - '**.md' + - 'LICENSE' + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Coverage floor (94) is duplicated in tests/Run-NetClean-Coverage.ps1's + # $MinimumCoverage default and in the comment-coverage job's + # min-coverage-overall below - update all three together when ratcheting. + COVERAGE_GATE: '94' jobs: - build-and-test: + test-and-analyze: + name: PSSA + Pester + Coverage runs-on: windows-latest + timeout-minutes: 15 + + 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: Resolve PowerShell 7 user module path + id: modpath + run: | + $path = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Modules' + "path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Cache PowerShell modules + uses: actions/cache@v4 + with: + path: ${{ steps.modpath.outputs.path }} + # Cache is keyed on the exact pinned versions below - bump this key's + # version numbers whenever RequiredVersion changes in the step below, + # so a version bump naturally forces a fresh install instead of + # reusing a stale cached copy. + key: ps7-modules-Pester6.0.1-PSScriptAnalyzer1.25.0-v1 + + - name: Install PowerShell test and analysis tools + run: | + if (-not (Get-Module -ListAvailable -Name Pester | Where-Object Version -eq ([version]'6.0.1'))) { + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -Scope CurrentUser -RequiredVersion 6.0.1 -Force -SkipPublisherCheck -ErrorAction Stop + } + if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer | Where-Object Version -eq ([version]'1.25.0'))) { + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + 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 $env:COVERAGE_GATE + + - name: Locate coverage output (if present) + id: find_coverage + run: | + $candidates = Get-ChildItem -Path tests/TestResults -Recurse -Filter 'coverage*.xml' -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if ($candidates) { + Write-Host "Found coverage file: $candidates" + "coverage_path=$candidates" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + } else { + Write-Host "No coverage file found." + "coverage_path=" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + } + + - name: Upload test 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 + + - name: Upload coverage artifact (for badge generation) + if: always() && steps.find_coverage.outputs.coverage_path != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage + path: ${{ steps.find_coverage.outputs.coverage_path }} + if-no-files-found: error + retention-days: 30 + + publish-coverage-badge: + name: Publish coverage badge + needs: test-and-analyze + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: windows-latest + timeout-minutes: 10 + + permissions: + contents: read + pages: write + id-token: write + + defaults: + run: + shell: pwsh + + steps: + - name: Download coverage artifact + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: coverage + path: coverage-artifact + + - name: Generate GitHub Pages coverage badge payload + run: | + $coverageXmlPath = Join-Path $env:GITHUB_WORKSPACE 'coverage-artifact/coverage.xml' + if (-not (Test-Path -LiteralPath $coverageXmlPath)) { + throw "Coverage XML not found at $coverageXmlPath" + } + + [xml]$doc = Get-Content -LiteralPath $coverageXmlPath + $lineCounter = $doc.SelectNodes('//counter') | Where-Object { $_.GetAttribute('type') -eq 'LINE' } | Select-Object -First 1 + if (-not $lineCounter) { + throw 'No LINE counter found in coverage XML.' + } + + $missed = [int]$lineCounter.GetAttribute('missed') + $covered = [int]$lineCounter.GetAttribute('covered') + $total = $missed + $covered + $percent = if ($total -gt 0) { [math]::Round(($covered / $total) * 100, 2) } else { 100 } + $message = '{0:N2}%' -f $percent + $color = if ($percent -ge 94) { 'brightgreen' } elseif ($percent -ge 90) { 'green' } elseif ($percent -ge 80) { 'yellow' } else { 'red' } + + $sitePath = Join-Path $env:GITHUB_WORKSPACE 'pages-site' + New-Item -ItemType Directory -Path $sitePath -Force | Out-Null + + $badge = [ordered]@{ + schemaVersion = 1 + label = 'coverage' + message = $message + color = $color + } + + $badge | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $sitePath 'coverage.json') -Encoding utf8 + @( + '', + '', + '', + ' ', + ' ', + ' NetClean coverage badge', + '', + '', + '

NetClean coverage badge JSON is published at coverage.json.

', + '', + '' + ) -join [Environment]::NewLine | Set-Content -LiteralPath (Join-Path $sitePath 'index.html') -Encoding utf8 + + - name: Upload GitHub Pages site artifact + uses: actions/upload-pages-artifact@v3 + with: + path: pages-site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + comment-coverage: + name: Comment coverage on pull request + # Only run for same-repo PRs to avoid token permission issues on forks + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + needs: test-and-analyze + runs-on: windows-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Download test and coverage artifacts (test results) + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: netclean-test-results + path: tests/TestResults + + - name: Download coverage artifact (for PR comment) + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: coverage + 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/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fbe724c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,145 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to (re-)release, e.g. v1.0.0' + required: true + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + # Kept in sync with ci.yml's COVERAGE_GATE - a release must clear the same + # bar as every merge to main. + COVERAGE_GATE: '94' + +jobs: + validate-and-release: + name: Validate and publish GitHub Release + runs-on: windows-latest + timeout-minutes: 20 + + permissions: + contents: write + + defaults: + run: + shell: pwsh + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + fetch-depth: 0 + + - name: Resolve release tag + id: tag + run: | + $tag = if ('${{ github.event_name }}' -eq 'workflow_dispatch') { '${{ inputs.tag }}' } else { '${{ github.ref_name }}' } + if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { + throw "Tag '$tag' does not match the required vX.Y.Z format." + } + $version = $Matches[1] + "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "Releasing tag '$tag' (version $version)." + + - name: Verify module manifest version matches the tag + run: | + $manifest = Test-ModuleManifest .\NetClean.psd1 + $expected = '${{ steps.tag.outputs.version }}' + if ($manifest.Version.ToString() -ne $expected) { + throw "NetClean.psd1 ModuleVersion ($($manifest.Version)) does not match tag version ($expected). Update the manifest and re-tag before releasing." + } + Write-Host 'Module manifest version matches the release tag.' + + - name: Resolve PowerShell 7 user module path + id: modpath + run: | + $path = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Modules' + "path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Cache PowerShell modules + uses: actions/cache@v4 + with: + path: ${{ steps.modpath.outputs.path }} + key: ps7-modules-Pester6.0.1-PSScriptAnalyzer1.25.0-v1 + + - name: Install PowerShell test and analysis tools + run: | + if (-not (Get-Module -ListAvailable -Name Pester | Where-Object Version -eq ([version]'6.0.1'))) { + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module Pester -Scope CurrentUser -RequiredVersion 6.0.1 -Force -SkipPublisherCheck -ErrorAction Stop + } + if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer | Where-Object Version -eq ([version]'1.25.0'))) { + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + 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 'Release requires Pester 6.0.1.' + } + + - 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 $env:COVERAGE_GATE + + - name: Extract release notes from CHANGELOG.md + id: notes + run: | + $version = '${{ steps.tag.outputs.version }}' + $lines = Get-Content -LiteralPath CHANGELOG.md + $startIdx = $null + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match "^## $([regex]::Escape($version))\b") { + $startIdx = $i + break + } + } + if ($null -eq $startIdx) { + throw "No '## $version' section found in CHANGELOG.md. Add one before tagging a release." + } + $endIdx = $lines.Count - 1 + for ($i = $startIdx + 1; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^## ') { + $endIdx = $i - 1 + break + } + } + $section = ($lines[($startIdx + 1)..$endIdx] -join "`n").Trim() + if ([string]::IsNullOrWhiteSpace($section)) { + throw "The '## $version' section in CHANGELOG.md is empty." + } + $notesPath = Join-Path $env:RUNNER_TEMP 'release-notes.md' + Set-Content -LiteralPath $notesPath -Value $section -Encoding utf8 + "notes_path=$notesPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + $tag = '${{ steps.tag.outputs.tag }}' + $version = '${{ steps.tag.outputs.version }}' + $notesPath = '${{ steps.notes.outputs.notes_path }}' + $existing = gh release view $tag --json name 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "Release $tag already exists; updating it in place." + gh release edit $tag --title "NetClean $version" --notes-file $notesPath + } + else { + gh release create $tag --title "NetClean $version" --notes-file $notesPath --verify-tag + } diff --git a/.gitignore b/.gitignore index 8447ebf..f9167f2 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,12 @@ 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 + +# Real-machine registry/system fixtures captured locally for higher-fidelity +# testing (see tests/LocalFixtures/). Contains real machine-specific data and +# must never be committed - only the capture/harness tooling is versioned. +tests/LocalFixtures/data/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..299cbfb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md + +## Purpose +This document defines mandatory standards for contributors and automation agents working in this repository. + +## Core Engineering Standards +- Favor small, focused changes with clear intent and minimal blast radius. +- Keep scripts idempotent where possible and safe by default (`-DryRun` first for destructive operations). +- Use approved PowerShell verbs, descriptive names, and explicit parameter contracts. +- Avoid global state; pass data through parameters and return values. +- Implement robust error handling with actionable messages and non-zero exits on failure paths. +- Update README/CHANGELOG when behavior, interface, or operational guidance changes. + +## Architecture Standards +- Enforce modularity: isolate responsibilities into reusable functions/modules (`Netclean.psm1` first, wrapper scripts second). +- Keep orchestration separate from side-effecting operations. +- Design for testability: pure logic should be callable without requiring machine-wide mutation. +- Preserve separation of concerns: + - detection/inspection + - decision logic + - mutation/remediation + - logging/reporting +- Prefer composable functions over monolithic scripts. + +## Security and DevSecOps Standards +- Follow least-privilege and explicit-elevation patterns; require Administrator only when necessary. +- Never commit secrets, credentials, tokens, or sensitive host artifacts. +- Validate all file/registry/network inputs before use. +- Use safe defaults: require explicit flags for destructive or forceful operations. +- Keep AV/EDR and virtual adapter safeguards intact unless a change explicitly requires otherwise. +- Treat CI as a security gate: lint, test, and review findings must be resolved before merge. +- Ensure supply-chain hygiene by pinning or constraining dependency/module versions where practical. + +## PowerShell Standards (Microsoft + Community Best Practices) +- Target PowerShell 7.4+ (`pwsh`) only. +- Follow existing `PSScriptAnalyzerSettings.psd1`; do not introduce new warnings/errors. +- Avoid `Invoke-Expression`, aliases, and `Write-Host` for control flow. +- Use `SupportsShouldProcess` for state-changing functions and respect `-WhatIf` semantics where applicable. +- Use structured output objects for automation; reserve console-only output for user guidance. +- Handle external command failures explicitly and preserve exit codes. + +## Testing Standards (Pester + TDD) +- Apply TDD for new behavior and bug fixes: + 1. Write or update a failing Pester test. + 2. Implement the minimal code change. + 3. Refactor while keeping tests green. +- Add regression tests for every fixed defect. +- Keep tests deterministic, isolated, and non-destructive. +- Prefer mocking/stubbing for destructive commands and system dependencies. +- Maintain and expand coverage of safety-critical paths (`-DryRun`, backup/restore, protections). + +## GitHub Standards +- Use feature branches from `main`; keep commits atomic and descriptive. +- Pull requests must include: + - problem summary + - change summary + - validation evidence (PSScriptAnalyzer + Pester) + - risk/rollback notes for operational changes +- Ensure GitHub Actions workflows pass before merge. +- Require review for changes affecting cleanup logic, security protections, CI, or release packaging. + +## Performance and Efficiency Standards +- Minimize repeated registry, file system, and process queries; cache where safe within a run. +- Prefer bulk operations over repeated shell-outs when equivalent behavior is available. +- Keep logging informative but bounded; avoid excessive noise in normal success paths. +- Maintain predictable runtime and fail fast on unrecoverable prerequisites. + +## Definition of Done +A change is complete only when all are true: +- Requirements are met with minimal, modular implementation. +- New/changed behavior is covered by Pester tests (unless documentation-only). +- `PSScriptAnalyzer` reports no blocking findings. +- Security/safety checks are preserved or improved. +- CI workflows pass. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a09055..2a651b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,64 @@ 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. +## 1.0.0 - 2026-07-26 + +- 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. +- Collect the service registry, Wi-Fi profiles, and NetworkList profiles once per run instead of re-querying per phase; cache per-binary file metadata lookups; run read-only supplemental evidence collectors (WFP, NDIS, INF, scheduled tasks, Appx, MSI registry) as two bounded parallel groups with a safe sequential fallback. +- Add `Decision`, `Reason`, and `ProtectionSource` to every network-privacy candidate and Wi-Fi/NetworkList profile decision so each of the ~60+ candidates in a real run states why it will or will not be touched. +- Fix `-Mode PerformanceTune` crashing outright (the profile-selection function was never exported from the module manifest); wire the `-PerformanceProfile` parameter through so an explicitly supplied profile is honored instead of always re-prompting, while still prompting when no profile was explicitly supplied even under `-Force`. +- Drop Windows PowerShell 5.1 support; require PowerShell 7.4+ (`CompatiblePSEditions = 'Core'`). The tool's target use (preparing a personally-owned machine for a conference or CTF) never depends on the locked-down managed machines that motivated dual-version support, and standardizing on the actively-maintained runtime removes an entire class of dual-implementation portability bugs and halves CI validation time. Remove the `windows-powershell-compatibility` CI job accordingly. +- Wrap every Phase 2 backup export (previously only the firewall-policy export was guarded) in the same try/catch-and-degrade pattern, so one failing backup no longer aborts the entire Protect phase with a raw stack trace. +- Guard `Reset-NetCleanAdapterConfigurationSafe`'s adapter-discovery call so a failure there returns a structured failed result instead of throwing uncaught after Wi-Fi removal, registry cleanup, and event-log/user-artifact clearing have already run for real; align its `-Confirm` wiring with its sibling cleanup steps. +- Redesign Phase 4 verification: fix a false-positive failure on Group-Policy-managed Wi-Fi profiles (previously any remaining profile failed verification regardless of whether it was supposed to be preserved); stop re-running Phase 1's full unoptimized detection a second time by reusing the Phase 1 collection snapshot; add per-artifact verification that joins Phase 1's `Decision` records against Phase 3's own per-item removal ledgers instead of re-detecting state. +- Fix the launcher's Wi-Fi "Found"/"Remaining After Cleanup" summary reporting nothing on real (non-dry-run) runs; it now reads the Phase 1 collection snapshot and Phase 4's own remaining-profile check instead of parsing dry-run-only manifest markers. +- Consolidate the four near-identical Phase 2 JSON-export functions into a shared helper; rename the always-0-or-1 `AdapterConfigurationBackupCount` metric to a boolean `HasAdapterConfigurationBackup`; enable `PSUseApprovedVerbs` (zero violations found). +- Add a `tests/Security` suite (injection-resistance, secret-redaction, and backup-directory-ACL regression tests) and fix a Wi-Fi profile name command-injection gap: a crafted profile name could reach a native `netsh.exe` call unsanitized. +- Fix a crash reachable on every real Detect run: `Get-RegistryValuesSafe` returned a zero-property object for a registry key that exists with no direct values (a normal shape for many real service keys), which throws under `Set-StrictMode -Version Latest` when later code checks for an optional property - the object now always carries at least one property. +- Fix `Get-AdapterRegistryCorrelation`, `Get-ProtectionEvidence`'s service-registry and Uninstall-registry evidence blocks, and `Get-NetworkListProfileSnapshot` reading unguarded optional registry properties, found by testing detection live against a real machine's registry; the service-registry block in particular was silently discarding most of a run's service evidence (confirmed ~50% of real services lack at least one of the properties it read unconditionally). +- Fail closed with a clear, actionable message instead of an uncaught stack trace when the backup directory or log directory cannot be created (e.g. the target path already exists as a file), and when NetClean is launched without Administrator privileges. +- Fix the `run-netclean.ps1`/`register-scheduledtask.ps1` examples: the scheduled-task wrapper had no way to select a non-interactive mode, so unattended/startup execution fell through to the interactive menu with no console attached; both examples also launched via Windows PowerShell 5.1, which can no longer load NetClean's manifest. +- 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. + +- Consolidate duplicated code across all four phase modules: a shared `New-NetCleanEvidenceRecord` constructor for Phase 1's evidence-record shape, a shared `Get-NetCleanSafeProperty` helper for the optional-property-read pattern used throughout the registry/CIM evidence code, a shared `Invoke-NetCleanSingleCommandSafe` helper for Phase 3's single-command wrappers, and three shared constructors (`New-NetCleanNotApplicableResult`, `New-NetCleanVerificationCheck`, `New-NetCleanArtifactDecisionCheck`) for Phase 4's verification-check objects. Remove the dead `$canLog` capability check (and an inline equivalent) from all four phase modules and `NetClean.psm1`, since `Write-NetCleanLog` is always resolvable by the time these functions run. +- Fix Phase 4 verification reporting a successful cleanup as failed with no indication why: deleting a parent registry key recursively removes its children too, but Phase 3's per-item ledger recorded the already-gone children as `Skipped`/`NotFound` rather than `Removed`, and Phase 4's artifact-verification check only trusted `Removed`. Also surface `ArtifactVerification` throughout `Invoke-NetCleanPhase4Verify`'s output (summary count, per-check logging, and the exported JSON report) - it was computed and silently factored into the overall pass/fail result, but never actually shown to the caller. Found via live testing against a real Hyper-V VM. +- Fix `Export-NetCleanJsonArtifact` (backing the sanitizable-artifact, protection-inventory, and protection-registry-map backups) throwing instead of writing a valid empty JSON array when its data collection has zero elements - `ConvertTo-Json` piping an empty array produces no pipeline output at all. Found via live testing on a system with nothing left to sanitize. ## 0.1.0 - 2026-03-07 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7494681..3a75aa7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,35 +1,46 @@ # 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 -``` +Install-Module Pester -Scope CurrentUser -RequiredVersion 6.0.1 -Force -SkipPublisherCheck +Install-Module PSScriptAnalyzer -Scope CurrentUser -RequiredVersion 1.25.0 -Force + +.\tests\Invoke-NetCleanAnalyzer.ps1 -Pull request checklist +Invoke-Pester -Path .\tests +.\tests\Run-NetClean-Coverage.ps1 +``` -- [ ] 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). +Run the complete Pester suite under PowerShell 7.4 or later before submitting changes. The authoritative coverage run is intentionally sequential. -Code style and tests +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 clear, descriptive names for functions and parameters. -- Keep scripts idempotent where possible and add checks for required privileges. +## Pull request checklist -Reporting issues +- [ ] 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. +- [ ] PSScriptAnalyzer reports no warnings or errors. +- [ ] Documentation and change notes match the implementation. +- [ ] No generated output, secrets, credentials, or host-specific inventory is included. -- Use the repository's Issues to report bugs or request features. Provide reproduction steps and environment details. +Contributions are licensed under the repository's GPLv3 license. -License +## Cutting a release -By contributing you agree that your contributions will be licensed under the project's license. +1. Bump `ModuleVersion` in `NetClean.psd1` to the new version. +2. Move `## Unreleased` entries in `CHANGELOG.md` into a new `## X.Y.Z - YYYY-MM-DD` section (create a fresh empty `## Unreleased` above it). +3. Merge that change to `main`, then tag the merge commit: `git tag vX.Y.Z && git push origin vX.Y.Z`. +4. Pushing the tag triggers `.github/workflows/release.yml`, which re-validates the manifest version against the tag, re-runs PSScriptAnalyzer and the full Pester suite as a release gate, and publishes a GitHub Release using the matching `CHANGELOG.md` section as its notes. +5. The release workflow does not publish to the PowerShell Gallery; that remains a manual/separate step until a maintainer decides to wire it up. diff --git a/Modules/NetClean.psm1 b/Modules/NetClean.psm1 new file mode 100644 index 0000000..2f112bb --- /dev/null +++ b/Modules/NetClean.psm1 @@ -0,0 +1,2307 @@ +<# +.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 for in-process multithreading. +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 +Maps a short registry hive root to its .NET RegistryKey handle. +.DESCRIPTION +Private helper for the raw-registry-API read path. Throws for any root not +present in $script:RegistryHiveMap, matching Get-NetCleanRegistryPathInfo's +own unsupported-root contract. +#> +function Get-NetCleanRegistryHiveRoot { + [CmdletBinding()] + [OutputType([Microsoft.Win32.RegistryKey])] + param( + [Parameter(Mandatory = $true)] + [string]$Root + ) + + switch ($Root) { + 'HKLM' { return [Microsoft.Win32.Registry]::LocalMachine } + 'HKCU' { return [Microsoft.Win32.Registry]::CurrentUser } + 'HKCR' { return [Microsoft.Win32.Registry]::ClassesRoot } + 'HKU' { return [Microsoft.Win32.Registry]::Users } + 'HKCC' { return [Microsoft.Win32.Registry]::CurrentConfig } + default { throw "Unsupported registry hive root: '$Root'" } + } +} + +<# +.SYNOPSIS +Opens a registry key via the raw .NET registry API, honoring the existing +path-resolution and TestRegistry-redirection contract. +.DESCRIPTION +Resolves the path exactly as the provider-cmdlet path did (Resolve- +NetCleanRegistryPath, then Get-NetCleanRegistryPathInfo), so redirection set +up by Set-NetCleanRegistryRootMap continues to work unchanged. Returns $null +when the key does not exist (OpenSubKey's own not-found contract) rather than +throwing; throws for an unsupported registry root or an access-denied key, to +be caught by each caller's existing fail-soft/fail-closed wrapper. The +returned key, if any, is a handle the caller owns and must Close(). +.PARAMETER RegistryPath +The registry path to open. +.OUTPUTS +Microsoft.Win32.RegistryKey or $null. +#> +function Open-NetCleanRegistryKey { + [CmdletBinding()] + [OutputType([Microsoft.Win32.RegistryKey])] + param( + [Parameter(Mandatory = $true)] + [string]$RegistryPath + ) + + $resolvedPath = Resolve-NetCleanRegistryPath -RegistryPath $RegistryPath + $info = Get-NetCleanRegistryPathInfo -RegistryPath $resolvedPath + $hiveRoot = Get-NetCleanRegistryHiveRoot -Root $info.Root + + return $hiveRoot.OpenSubKey($info.Suffix) +} + +<# +.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. +#> +function Test-RegistryPathExist { + [CmdletBinding()] + [OutputType([System.Boolean])] + param( + [Parameter(Mandatory = $true)] + [Alias('Path')] + [string]$RegistryPath, + + [switch]$ThrowOnError + ) + + $key = $null + try { + $key = Open-NetCleanRegistryKey -RegistryPath $RegistryPath + return ($null -ne $key) + } + catch { + if ($ThrowOnError) { + throw + } + return $false + } + finally { + if ($key) { $key.Close() } + } +} + +<# +.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. +#> +function Get-RegistryValuesSafe { + [CmdletBinding()] + [OutputType([System.Object])] + param( + [Parameter(Mandatory = $true)] + [Alias('Path')] + [string]$RegistryPath + ) + + $key = $null + try { + $key = Open-NetCleanRegistryKey -RegistryPath $RegistryPath + if ($null -eq $key) { + return $null + } + + $values = [ordered]@{} + foreach ($valueName in $key.GetValueNames()) { + $propertyName = if ([string]::IsNullOrEmpty($valueName)) { '(default)' } else { $valueName } + $values[$propertyName] = $key.GetValue($valueName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + } + + if ($values.Count -eq 0) { + # A zero-property PSCustomObject makes any later + # `.PSObject.Properties.Name -contains 'X'` check throw under + # Set-StrictMode -Version Latest (enumerating .Name over an + # empty PSMemberInfoCollection behaves differently than over a + # non-empty one). Every caller uses that idiom to test for a + # specific value name, so a key that exists with zero registry + # values must still carry at least one property to keep those + # checks safe - this mirrors the old Get-ItemProperty-based + # implementation, whose returned object always carried PSPath/ + # PSProvider metadata properties even for a value-less key. + return [pscustomobject]@{ NetCleanNoRegistryValues = $true } + } + + return [pscustomobject]$values + } + catch { + return $null + } + finally { + if ($key) { $key.Close() } + } +} + +<# +.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. +#> +function Get-RegistryChildKeyNamesSafe { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $true)] + [Alias('Path')] + [string]$RegistryPath + ) + + $key = $null + try { + $key = Open-NetCleanRegistryKey -RegistryPath $RegistryPath + if ($null -eq $key) { + return @() + } + + return @($key.GetSubKeyNames()) + } + catch { + return @() + } + finally { + if ($key) { $key.Close() } + } +} + +<# +.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 + Tests whether a string is safe to embed as a native-command argument value. +.DESCRIPTION + Rejects values containing characters that could alter argument-boundary + parsing when embedded in a single native command-line argument (e.g. an + embedded double quote closing the argument early). Used to validate + externally-sourced, attacker-influenceable identifiers - such as Wi-Fi + profile names - before they reach any external command. +.PARAMETER Value + The string to validate. +.OUTPUTS + System.Boolean +#> +function Test-NetCleanSafeIdentifier { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [AllowNull()] + [string]$Value + ) + + if ([string]::IsNullOrEmpty($Value)) { + return $false + } + + return $Value -notmatch '["`;&|<>\r\n\x00]' +} + +<# +.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-CachedFileMetadatum { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [AllowEmptyString()] + [string]$Path, + + [Parameter(Mandatory = $true)] + [hashtable]$Cache + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $null + } + + $cacheKey = Get-NormalizedFilePathFromCommandLine -CommandLine $Path + if ([string]::IsNullOrWhiteSpace($cacheKey)) { + return $null + } + + if (-not $Cache.ContainsKey($cacheKey)) { + $Cache[$cacheKey] = Get-FileMetadatum -Path $Path + } + + return $Cache[$cacheKey] +} + +<# +.SYNOPSIS +Reads one property from an object only if that property actually exists. +.DESCRIPTION +Registry-derived and other dynamically-shaped objects (Get-ItemProperty results, +CIM/WMI records) don't reliably carry every property real-world data might omit - +under Set-StrictMode, reading a missing property throws. This is the shared +"read Name from InputObject if present, else null" guard used throughout the +evidence-collection and snapshot code. +#> +function Get-NetCleanSafeProperty { + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [object]$InputObject, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ($null -ne $InputObject -and $InputObject.PSObject.Properties.Name -contains $Name) { + return $InputObject.$Name + } + return $null +} + +function Get-ServiceRegistrySnapshot { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $snapshot = [System.Collections.Generic.List[object]]::new() + $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 + } + } + + $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 } + $linkageValues = Get-RegistryValuesSafe -RegistryPath "$svcPath\Linkage" + + $snapshot.Add([pscustomobject][ordered]@{ + Name = $svcName + RegistryPath = $svcPath + Values = $props + LinkageValues = $linkageValues + ImagePath = $imagePath + DisplayName = $displayName + Type = $type + Start = $start + Group = $group + EnumPath = $enumPath + LinkagePath = $linkagePath + ParamsPath = $paramsPath + InstancesPath = $instancesPath + }) + } + + return $snapshot.ToArray() +} + +function Get-ServiceRegistryMap { + [CmdletBinding()] + [OutputType([System.Collections.Hashtable])] + param( + [Parameter()] + [AllowNull()] + [object[]]$Snapshot + ) + + if (-not $PSBoundParameters.ContainsKey('Snapshot') -or $null -eq $Snapshot) { + $Snapshot = @(Get-ServiceRegistrySnapshot) + } + + $map = @{} + + foreach ($entry in $Snapshot) { + $map[$entry.Name.ToLowerInvariant()] = $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 = Get-NetCleanSafeProperty -InputObject $props -Name 'ComponentId' + $driverDesc = Get-NetCleanSafeProperty -InputObject $props -Name 'DriverDesc' + $providerName = Get-NetCleanSafeProperty -InputObject $props -Name 'ProviderName' + $netCfgInstanceId = Get-NetCleanSafeProperty -InputObject $props -Name '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 ($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 + Write-NetCleanLog -Level INFO -Message ("Phase Detect start: {0}" -f $t0.ToString('s')) + + $ctx = Invoke-NetCleanPhase1Detect + + $t1 = Get-Date + 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 + 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 + 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 = @() + $netProfiles = @() + $hasCollectionSnapshot = $ctx.PSObject.Properties.Name -contains 'CollectionSnapshot' + + if ($hasCollectionSnapshot) { + $wifiFound = @($ctx.CollectionSnapshot.WiFiProfiles | ForEach-Object Name) + $netProfiles = @($ctx.CollectionSnapshot.NetworkListProfiles | ForEach-Object Name) + } + elseif ($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 (-not $hasCollectionSnapshot -and $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 + + if (-not $hasCollectionSnapshot) { + $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 + 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 + 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 + 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 + 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 + + Write-NetCleanLog -Level INFO -Message ('Workflow complete. Mode={0} DryRun={1}' -f $Mode, [bool]$DryRun) + + $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..9124137 --- /dev/null +++ b/Modules/NetCleanPhase1.ps1 @@ -0,0 +1,1741 @@ +# --------------------------------------------------------------------------- +# Phase 1 - Detect helpers +# --------------------------------------------------------------------------- + +<# +.SYNOPSIS +Builds a protection-evidence record with NetClean's standard 15-field shape. +.DESCRIPTION +Every evidence source (SecurityCenter2, Win32_Service, Win32_SystemDriver, +Uninstall registry, WFP, NDIS, MSI, INF, ScheduledTask, AppX, ServiceRegistry, +NetAdapter, PnpDevice) builds the same 15 fields, differing only in how each +field is sourced and which extra fields (if any) are appended. Centralizing +the shape here removes ~15 near-identical object literals. +.PARAMETER Publisher +Explicit publisher value. If omitted, defaults to -Meta's CompanyName (the +common case) rather than always being null. +.PARAMETER Extra +Additional fields specific to one evidence source (e.g. ComponentId, +RegistryPath, TaskPath), merged onto the standard 15 fields. +#> +function New-NetCleanEvidenceRecord { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Pure in-memory object constructor; the New- verb reflects object creation, not system state mutation, and is called dozens of times per detection pass.')] + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string]$Source, + + [Parameter(Mandatory = $true)] + [string]$ProductClass, + + [AllowNull()] + [AllowEmptyString()] + [string]$Name, + + [AllowNull()] + [AllowEmptyString()] + [string]$DisplayName, + + [AllowNull()] + [AllowEmptyString()] + [string]$Path, + + [AllowNull()] + [AllowEmptyString()] + [string]$Publisher, + + [AllowNull()] + [AllowEmptyString()] + [string]$InstallPath, + + [AllowNull()] + [AllowEmptyString()] + [string]$InterfaceDescription, + + [AllowNull()] + [AllowEmptyString()] + [string]$Manufacturer, + + [AllowNull()] + [object]$Meta, + + [AllowNull()] + [string[]]$VendorHintText, + + [AllowNull()] + [object]$Instance, + + [AllowNull()] + [AllowEmptyString()] + [string]$CompanyNameFallback, + + [AllowNull()] + [AllowEmptyString()] + [string]$ProductNameFallback, + + [AllowNull()] + [hashtable]$Extra + ) + + $resolvedPublisher = if ($PSBoundParameters.ContainsKey('Publisher')) { + $Publisher + } + elseif ($Meta) { + $Meta.CompanyName + } + else { + $null + } + + $record = [ordered]@{ + Source = $Source + ProductClass = $ProductClass + Name = $Name + DisplayName = $DisplayName + Path = $Path + Publisher = $resolvedPublisher + InstallPath = $InstallPath + InterfaceDescription = $InterfaceDescription + Manufacturer = $Manufacturer + CompanyName = if ($Meta) { $Meta.CompanyName } else { $CompanyNameFallback } + FileDescription = if ($Meta) { $Meta.FileDescription } else { $null } + ProductName = if ($Meta) { $Meta.ProductName } else { $ProductNameFallback } + SignerSubject = if ($Meta) { $Meta.SignerSubject } else { $null } + InferredVendor = if ($Meta -and $Meta.InferredVendor) { $Meta.InferredVendor } else { (Resolve-VendorFromText -Text $VendorHintText) } + Instance = $Instance + } + + if ($Extra) { + foreach ($key in $Extra.Keys) { + $record[$key] = $Extra[$key] + } + } + + return [pscustomobject]$record +} + +<# +.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 = Get-NetCleanSafeProperty -InputObject $props -Name 'DriverDesc' + $providerName = Get-NetCleanSafeProperty -InputObject $props -Name 'ProviderName' + $componentId = Get-NetCleanSafeProperty -InputObject $props -Name 'ComponentId' + + $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( + [Parameter()] + [AllowNull()] + [object[]]$ServiceRegistrySnapshot + ) + + $results = New-Object System.Collections.Generic.List[object] + $servicesRoot = 'HKLM\SYSTEM\CurrentControlSet\Services' + + if (-not $PSBoundParameters.ContainsKey('ServiceRegistrySnapshot') -or $null -eq $ServiceRegistrySnapshot) { + $ServiceRegistrySnapshot = @( + foreach ($serviceName in @(Get-RegistryChildKeyNamesSafe -RegistryPath $servicesRoot)) { + $servicePath = "$servicesRoot\$serviceName" + [pscustomobject]@{ + Name = $serviceName + RegistryPath = $servicePath + Values = Get-RegistryValuesSafe -RegistryPath $servicePath + LinkageValues = Get-RegistryValuesSafe -RegistryPath "$servicePath\Linkage" + } + } + ) + } + + foreach ($serviceEntry in $ServiceRegistrySnapshot) { + $svcName = $serviceEntry.Name + $svcPath = $serviceEntry.RegistryPath + # A caller-supplied snapshot (e.g. Get-ProtectionEvidence's own internal + # snapshot construction) may not include LinkageValues at all - it's + # specific to this function's own default-collection shape. + $linkage = Get-NetCleanSafeProperty -InputObject $serviceEntry -Name 'LinkageValues' + $props = $serviceEntry.Values + + $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 = Get-NetCleanSafeProperty -InputObject $props -Name 'DisplayName' + + if ([string]::IsNullOrWhiteSpace([string]$displayName)) { continue } + + $publisher = Get-NetCleanSafeProperty -InputObject $props -Name 'Publisher' + $installLocation = Get-NetCleanSafeProperty -InputObject $props -Name 'InstallLocation' + $uninstallString = Get-NetCleanSafeProperty -InputObject $props -Name 'UninstallString' + + $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() +} + +function Get-SupplementalProtectionEvidence { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [ValidateRange(1, 2)] + [int]$ThrottleLimit = 2 + ) + + $manifestPath = [System.IO.Path]::GetFullPath((Join-Path $script:ModuleRoot '..\NetClean.psd1')) + $workItems = @( + 'MsiRegistry', + 'OtherReadOnly' + ) | ForEach-Object { + [pscustomobject]@{ + Collector = $_ + ManifestPath = $manifestPath + } + } + + return @( + Invoke-InParallel ` + -InputObjects $workItems ` + -ThrottleLimit $ThrottleLimit ` + -ScriptBlock { + param($workItem) + + Import-Module -Name $workItem.ManifestPath -Force -ErrorAction Stop + $module = Get-Module -Name NetClean | Select-Object -First 1 + + & $module { + param($collectorName) + + switch ($collectorName) { + 'MsiRegistry' { Get-MsiRegistryEvidence; break } + 'OtherReadOnly' { + Get-WfpStateEvidence + Get-NdisFilterClassEvidence + Get-InfFileEvidence + Get-ScheduledTaskEvidence + Get-AppxPackageEvidence + break + } + default { throw "Unsupported supplemental collector: $collectorName" } + } + } $workItem.Collector + } + ) +} + +<# +.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( + [Parameter()] + [hashtable]$FileMetadataCache = @{}, + + [Parameter()] + [AllowNull()] + [object[]]$ServiceRegistrySnapshot, + + [switch]$ParallelSupplementalEvidence + ) + + $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-CachedFileMetadatum -Path $item.pathToSignedProductExe -Cache $FileMetadataCache + $evidence.Add((New-NetCleanEvidenceRecord -Source 'SecurityCenter2' -ProductClass 'AntivirusProduct' ` + -Name $item.displayName -DisplayName $item.displayName -Path $item.pathToSignedProductExe ` + -Meta $meta -VendorHintText @($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-CachedFileMetadatum -Path $item.pathToSignedProductExe -Cache $FileMetadataCache + $evidence.Add((New-NetCleanEvidenceRecord -Source 'SecurityCenter2' -ProductClass 'FirewallProduct' ` + -Name $item.displayName -DisplayName $item.displayName -Path $item.pathToSignedProductExe ` + -Meta $meta -VendorHintText @($item.displayName) -Instance $item)) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + try { + $services = Get-CimInstance Win32_Service -ErrorAction Stop + foreach ($svc in $services) { + $meta = Get-CachedFileMetadatum -Path $svc.PathName -Cache $FileMetadataCache + $record = New-NetCleanEvidenceRecord -Source 'Service' -ProductClass 'Service' ` + -Name $svc.Name -DisplayName $svc.DisplayName -Path $svc.PathName ` + -Meta $meta -VendorHintText @($svc.Name, $svc.DisplayName, $svc.PathName) -Instance $svc ` + -Extra @{ State = $svc.State; StartMode = $svc.StartMode; ServiceType = $svc.ServiceType } + $evidence.Add($record) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + try { + $drivers = Get-CimInstance Win32_SystemDriver -ErrorAction Stop + foreach ($drv in $drivers) { + $meta = Get-CachedFileMetadatum -Path $drv.PathName -Cache $FileMetadataCache + $record = New-NetCleanEvidenceRecord -Source 'Driver' -ProductClass 'Driver' ` + -Name $drv.Name -DisplayName $drv.DisplayName -Path $drv.PathName ` + -Meta $meta -VendorHintText @($drv.Name, $drv.DisplayName, $drv.PathName) -Instance $drv ` + -Extra @{ State = $drv.State; StartMode = $drv.StartMode; ServiceType = $drv.ServiceType } + $evidence.Add($record) + } + } + 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 { + $displayName = Get-NetCleanSafeProperty -InputObject $_ -Name 'DisplayName' + if ($displayName) { + $displayIcon = Get-NetCleanSafeProperty -InputObject $_ -Name 'DisplayIcon' + $publisher = Get-NetCleanSafeProperty -InputObject $_ -Name 'Publisher' + $installLocation = Get-NetCleanSafeProperty -InputObject $_ -Name 'InstallLocation' + $uninstallString = Get-NetCleanSafeProperty -InputObject $_ -Name 'UninstallString' + + $meta = $null + if ($displayIcon) { + $meta = Get-CachedFileMetadatum -Path $displayIcon -Cache $FileMetadataCache + } + + $evidence.Add((New-NetCleanEvidenceRecord -Source 'Uninstall' -ProductClass 'InstalledProduct' ` + -Name $displayName -DisplayName $displayName -Path $displayIcon ` + -Publisher $publisher -InstallPath $installLocation -Manufacturer $publisher ` + -Meta $meta -VendorHintText @($displayName, $publisher, $installLocation, $uninstallString) ` + -CompanyNameFallback $publisher -ProductNameFallback $displayName ` + -Instance $_ -Extra @{ UninstallString = $uninstallString })) + } + } + } + catch { + Write-Verbose "Ignored error: $_" + } + } + + try { + $adapters = Get-NetAdapter -IncludeHidden -ErrorAction Stop + foreach ($adapter in $adapters) { + $guidValue = $null + try { + $guidValue = $adapter.InterfaceGuid.Guid.ToString().ToLowerInvariant() + } + catch { + try { + $guidValue = $adapter.InterfaceGuid.ToString().Trim('{}').ToLowerInvariant() + } + catch { + $guidValue = $null + } + } + + $record = New-NetCleanEvidenceRecord -Source 'NetAdapter' -ProductClass 'Adapter' ` + -Name $adapter.Name -DisplayName $adapter.Name -InterfaceDescription $adapter.InterfaceDescription ` + -ProductNameFallback $adapter.InterfaceDescription -Instance $adapter ` + -VendorHintText @($adapter.Name, $adapter.InterfaceDescription, $adapter.DriverDescription, $adapter.DriverFileName) ` + -Extra @{ InterfaceGuid = $guidValue; MacAddress = $adapter.MacAddress; Status = $adapter.Status } + $evidence.Add($record) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + try { + $pnpNet = Get-PnpDevice -Class Net -ErrorAction Stop + foreach ($dev in $pnpNet) { + $record = New-NetCleanEvidenceRecord -Source 'PnpDevice' -ProductClass 'NetDevice' ` + -Name $dev.FriendlyName -DisplayName $dev.FriendlyName -InterfaceDescription $dev.FriendlyName ` + -Manufacturer $dev.Manufacturer -CompanyNameFallback $dev.Manufacturer -ProductNameFallback $dev.FriendlyName ` + -VendorHintText @($dev.FriendlyName, $dev.Manufacturer, $dev.InstanceId) -Instance $dev ` + -Extra @{ InstanceId = $dev.InstanceId; Status = $dev.Status; Class = $dev.Class } + $evidence.Add($record) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + $servicesRoot = 'HKLM\SYSTEM\CurrentControlSet\Services' + try { + if (-not $PSBoundParameters.ContainsKey('ServiceRegistrySnapshot') -or $null -eq $ServiceRegistrySnapshot) { + $ServiceRegistrySnapshot = @( + foreach ($serviceName in @(Get-RegistryChildKeyNamesSafe -RegistryPath $servicesRoot)) { + $servicePath = "$servicesRoot\$serviceName" + [pscustomobject]@{ + Name = $serviceName + RegistryPath = $servicePath + Values = Get-RegistryValuesSafe -RegistryPath $servicePath + } + } + ) + } + + foreach ($serviceEntry in $ServiceRegistrySnapshot) { + $svcName = $serviceEntry.Name + $svcRegPath = $serviceEntry.RegistryPath + $svcProps = $serviceEntry.Values + if ($null -eq $svcProps) { continue } + + $imagePath = Get-NetCleanSafeProperty -InputObject $svcProps -Name 'ImagePath' + $displayName = Get-NetCleanSafeProperty -InputObject $svcProps -Name 'DisplayName' + $meta = Get-CachedFileMetadatum -Path $imagePath -Cache $FileMetadataCache + + $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 = Get-NetCleanSafeProperty -InputObject $svcProps -Name 'Start' + Type = Get-NetCleanSafeProperty -InputObject $svcProps -Name 'Type' + Group = Get-NetCleanSafeProperty -InputObject $svcProps -Name 'Group' + Instance = $svcProps + }) + } + } + catch { + Write-Verbose "Ignored error: $_" + } + + $useSequentialSupplementalCollection = -not $ParallelSupplementalEvidence + + if ($ParallelSupplementalEvidence) { + try { + foreach ($item in @(Get-SupplementalProtectionEvidence)) { $evidence.Add($item) } + } + catch { + Write-Verbose "Parallel supplemental evidence collection failed, falling back to sequential collection: $($_.Exception.Message)" + $useSequentialSupplementalCollection = $true + } + } + + if ($useSequentialSupplementalCollection) { + foreach ($item in @(Get-WfpStateEvidence)) { $evidence.Add($item) } + foreach ($item in @(Get-NdisFilterClassEvidence)) { $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) } + } + + foreach ($item in @(Get-NdisServiceBindingEvidence -ServiceRegistrySnapshot $ServiceRegistrySnapshot)) { $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( + [Parameter()] + [AllowNull()] + [object[]]$ServiceRegistrySnapshot, + + [switch]$ParallelSupplementalEvidence + ) + + $fileMetadataCache = @{} + if (-not $PSBoundParameters.ContainsKey('ServiceRegistrySnapshot') -or $null -eq $ServiceRegistrySnapshot) { + $ServiceRegistrySnapshot = @(Get-ServiceRegistrySnapshot) + } + $evidence = @( + Get-ProtectionEvidence ` + -FileMetadataCache $fileMetadataCache ` + -ServiceRegistrySnapshot $ServiceRegistrySnapshot ` + -ParallelSupplementalEvidence:$ParallelSupplementalEvidence + ) + $signatures = Get-VendorSignature + $serviceMap = Get-ServiceRegistryMap -Snapshot $ServiceRegistrySnapshot + $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-CachedFileMetadatum -Path $svcInfo.ImagePath -Cache $fileMetadataCache + 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 + $protectedByGuid = @{} + + foreach ($inventoryEntry in @($Inventory)) { + foreach ($protectedGuid in @($inventoryEntry.ProtectedInterfaceGuids)) { + if ([string]::IsNullOrWhiteSpace($protectedGuid)) { continue } + + $normalizedGuid = $protectedGuid.Trim('{}').ToLowerInvariant() + if (-not $protectedByGuid.ContainsKey($normalizedGuid)) { + $protectedByGuid[$normalizedGuid] = [System.Collections.Generic.HashSet[string]]::new() + } + + if ($inventoryEntry.Vendor) { + [void]$protectedByGuid[$normalizedGuid].Add([string]$inventoryEntry.Vendor) + } + } + } + + $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 + Decision = 'Remove' + Reason = 'Network profile/signature history' + ProtectionSource = $null + }) + } + } + + $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) + $protectingVendors = @(if ($protectedByGuid.ContainsKey($guid)) { $protectedByGuid[$guid] | Sort-Object }) + $protectionSource = if (@($protectingVendors).Count -gt 0) { $protectingVendors -join ', ' } else { 'Adapter safety boundary' } + + $candidates.Add([pscustomobject]@{ + ArtifactType = 'TcpipInterface' + RegistryPath = $path + InterfaceGuid = $guid + IsProtected = $isProtected + CanSanitize = $false + Decision = 'Preserve' + Reason = if ($isProtected) { "Protected interface used by $protectionSource" } else { 'Preserved adapter configuration; recursive deletion could impair networking' } + ProtectionSource = $protectionSource + }) + } + + $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) + $protectingVendors = @(if ($protectedByGuid.ContainsKey($guid)) { $protectedByGuid[$guid] | Sort-Object }) + $protectionSource = if (@($protectingVendors).Count -gt 0) { $protectingVendors -join ', ' } else { 'Adapter safety boundary' } + $candidates.Add([pscustomobject]@{ + ArtifactType = 'NetworkControl' + RegistryPath = $path + InterfaceGuid = $guid + IsProtected = $isProtected + CanSanitize = $false + Decision = 'Preserve' + Reason = if ($isProtected) { "Protected interface used by $protectionSource" } else { 'Preserved adapter configuration; recursive deletion could impair networking' } + ProtectionSource = $protectionSource + }) + } + } + } + + return $candidates.ToArray() +} + +function Get-NetworkProfileDecision { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter()] + [AllowNull()] + [object[]]$WiFiProfiles, + + [Parameter()] + [AllowNull()] + [object[]]$NetworkListProfiles + ) + + $decisions = [System.Collections.Generic.List[object]]::new() + $wifiNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $wifiProfileRecords = if ($null -eq $WiFiProfiles) { @() } else { @($WiFiProfiles) } + $networkListProfileRecords = if ($null -eq $NetworkListProfiles) { @() } else { @($NetworkListProfiles) } + + foreach ($wifiProfileRecord in $wifiProfileRecords) { + if (-not $wifiProfileRecord.Name) { continue } + + [void]$wifiNames.Add([string]$wifiProfileRecord.Name) + $isPolicyManaged = [bool]$wifiProfileRecord.IsPolicyManaged + $decisions.Add([pscustomobject]@{ + ArtifactType = 'WiFiProfile' + NetworkType = 'Wi-Fi' + Name = [string]$wifiProfileRecord.Name + RegistryPath = $null + Decision = if ($isPolicyManaged) { 'Preserve' } else { 'Remove' } + IsProtected = $isPolicyManaged + CanSanitize = -not $isPolicyManaged + Reason = if ($isPolicyManaged) { 'Read-only Wi-Fi profile delivered by Windows Group Policy' } else { 'Saved user Wi-Fi profile; removal prevents disclosure of connection history' } + ProtectionSource = if ($isPolicyManaged) { 'Windows Group Policy' } else { $null } + }) + } + + foreach ($networkProfileRecord in $networkListProfileRecords) { + if (-not $networkProfileRecord.Name) { continue } + + $networkType = if ($wifiNames.Contains([string]$networkProfileRecord.Name)) { 'Wi-Fi history' } else { 'LAN/other history' } + $decisions.Add([pscustomobject]@{ + ArtifactType = 'NetworkListProfile' + NetworkType = $networkType + Name = [string]$networkProfileRecord.Name + RegistryPath = $networkProfileRecord.RegistryPath + ProfileGuid = $networkProfileRecord.ProfileGuid + Decision = 'Remove' + IsProtected = $false + CanSanitize = $true + Reason = 'Windows NetworkList history; removal does not alter adapters, drivers, or network policy' + ProtectionSource = $null + }) + } + + return $decisions.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() + + Write-NetCleanLog -Level INFO -Message 'Phase 1 detection started.' + + $managementState = Get-NetCleanDeviceManagementState + $serviceRegistrySnapshot = @(Get-ServiceRegistrySnapshot) + $wifiProfiles = @(Get-WiFiProfileSnapshot) + $networkListProfiles = @(Get-NetworkListProfileSnapshot) + $inventory = @( + Get-ProtectionInventory ` + -ServiceRegistrySnapshot $serviceRegistrySnapshot ` + -ParallelSupplementalEvidence + ) + $protectionMap = @(Get-ProtectionRegistryMap -Inventory $inventory) + $protectedGuids = @(Get-ProtectedInterfaceGuidSet -Inventory $inventory) + $candidateArtifacts = @(Get-NetworkPrivacyArtifactCandidate -Inventory $inventory) + $networkProfileDecisions = @( + Get-NetworkProfileDecision ` + -WiFiProfiles $wifiProfiles ` + -NetworkListProfiles $networkListProfiles + ) + $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 + CollectionSnapshot = [pscustomobject]@{ + CollectedAt = Get-Date + ServiceRegistry = $serviceRegistrySnapshot + WiFiProfiles = $wifiProfiles + NetworkListProfiles = $networkListProfiles + } + Inventory = $inventory + ProtectionRegistryMap = $protectionMap + ProtectedInterfaceGuids = $protectedGuids + CandidateArtifacts = $candidateArtifacts + SanitizableArtifacts = $sanitizableArtifacts + NetworkProfileDecisions = $networkProfileDecisions + ProtectedRegistryPaths = $protectedRegistryPaths + Summary = [pscustomobject]@{ + ProtectedVendorsCount = @($inventory).Count + ProtectedInterfaceGuidCount = @($protectedGuids).Count + CandidateArtifactCount = @($candidateArtifacts).Count + SanitizableArtifactCount = @($sanitizableArtifacts).Count + NetworkProfileDecisionCount = @($networkProfileDecisions).Count + RemovableNetworkProfileCount = @($networkProfileDecisions | Where-Object Decision -EQ 'Remove').Count + ProtectedNetworkProfileCount = @($networkProfileDecisions | Where-Object Decision -EQ 'Preserve').Count + ManagedDevice = [bool]$managementState.IsManaged + } + } + + 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..b04f6d7 --- /dev/null +++ b/Modules/NetCleanPhase2.ps1 @@ -0,0 +1,1078 @@ +# --------------------------------------------------------------------------- +# 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-NetworkListProfileSnapshot { + [CmdletBinding()] + [OutputType([System.Object[]])] + param() + + $root = Convert-RegToProviderPath -RegistryPath 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + $profiles = [System.Collections.Generic.List[object]]::new() + try { + if (Test-Path -LiteralPath $root) { + $children = Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue + foreach ($c in $children) { + try { + $properties = Get-ItemProperty -LiteralPath $c.PSPath -ErrorAction SilentlyContinue + $profileName = Get-NetCleanSafeProperty -InputObject $properties -Name 'ProfileName' + if ($profileName) { + $profiles.Add([pscustomobject]@{ + Name = [string]$profileName + ProfileGuid = [string]$c.PSChildName + Category = Get-NetCleanSafeProperty -InputObject $properties -Name 'Category' + Description = Get-NetCleanSafeProperty -InputObject $properties -Name 'Description' + Managed = Get-NetCleanSafeProperty -InputObject $properties -Name 'Managed' + RegistryPath = "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles\$($c.PSChildName)" + }) + } + } + catch { Write-Verbose "Get-NetworkListProfileSnapshot child: $($_.Exception.Message)" } + } + } + } + catch { Write-Verbose "Get-NetworkListProfileSnapshot: $($_.Exception.Message)" } + + return @($profiles.ToArray() | Sort-Object Name, ProfileGuid) +} + +function Get-NetworkListProfileName { + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter()] + [AllowNull()] + [object[]]$Snapshot + ) + + if (-not $PSBoundParameters.ContainsKey('Snapshot') -or $null -eq $Snapshot) { + $Snapshot = @(Get-NetworkListProfileSnapshot) + } + + $names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($networkProfileRecord in $Snapshot) { + if ($networkProfileRecord.Name) { + [void]$names.Add([string]$networkProfileRecord.Name) + } + } + + return [string[]]@($names | Sort-Object) +} + +<# +.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-WiFiProfileSnapshot { + [CmdletBinding()] + [OutputType([System.Object[]])] + 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 @() + } + + $profiles = [System.Collections.Generic.List[object]]::new() + $profileKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $currentSource = 'Unknown' + $recognizedLocalizedHeader = $false + + foreach ($line in $result.Output) { + if ($null -eq $line) { + continue + } + + $text = [string]$line + + if ($text -match '^\s*Group policy profiles') { + $currentSource = 'GroupPolicy' + $recognizedLocalizedHeader = $true + continue + } + + if ($text -match '^\s*User profiles') { + $currentSource = 'User' + $recognizedLocalizedHeader = $true + continue + } + + if ($text -match '^\s*[^:]+:\s*(.+?)\s*$') { + $name = $matches[1].Trim() + + if (-not [string]::IsNullOrWhiteSpace($name)) { + # Locale-independent: any "key: value" line in this specific + # netsh command's output is a profile entry - netsh's labels + # (and the "Group policy profiles"/"User profiles" section + # headers above) are localized, so matching on the English + # word "Profile" here previously detected zero profiles on + # non-English Windows installs. + $isPolicyManaged = if ($recognizedLocalizedHeader) { + $currentSource -eq 'GroupPolicy' + } + else { + # Fail closed: no localized section header was recognized + # (non-English Windows), so Group-Policy-managed profiles + # can't be distinguished from user profiles - treat as + # policy-managed (excluded from removal) rather than risk + # removing a profile that should have been protected. + $true + } + $source = if ($recognizedLocalizedHeader) { $currentSource } else { 'Unknown' } + $profileKey = "$source`0$name" + + if ($profileKeys.Add($profileKey)) { + $profiles.Add([pscustomobject]@{ + Name = $name + Source = $source + IsPolicyManaged = $isPolicyManaged + }) + } + } + } + } + + $finalProfiles = @($profiles.ToArray() | Sort-Object Name, Source) + + Write-NetCleanLog -Level DEBUG -Message ("Detected Wi-Fi profiles: {0}" -f (($finalProfiles | ForEach-Object Name) -join ', ')) + + return $finalProfiles +} + +<# +.SYNOPSIS +Returns exact Wi-Fi profile names from a supplied or newly collected snapshot. +.DESCRIPTION +Projects Wi-Fi profile names while preserving names that differ only by case. +.OUTPUTS +System.String[] +#> +function Get-WiFiProfileName { + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter()] + [AllowNull()] + [object[]]$Snapshot + ) + + if (-not $PSBoundParameters.ContainsKey('Snapshot') -or $null -eq $Snapshot) { + $Snapshot = @(Get-WiFiProfileSnapshot) + } + + $names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($wifiProfileRecord in $Snapshot) { + if ($wifiProfileRecord.Name) { + [void]$names.Add([string]$wifiProfileRecord.Name) + } + } + + return [string[]]@($names | Sort-Object) +} + +<# +.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, + + [Parameter()] + [AllowEmptyCollection()] + [string[]]$Profiles, + + [switch]$DryRun + ) + + $exported = [System.Collections.Generic.List[string]]::new() + + $listFile = Join-Path $Dest ("WiFiProfiles_{0}.txt" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + if ($PSBoundParameters.ContainsKey('Profiles')) { + $profiles = @($Profiles) + } + else { + $profiles = @(Get-WiFiProfileName) + } + + if ($profiles.Count -eq 0) { + Write-NetCleanLog -Level INFO -Message 'No Wi-Fi profiles detected for backup.' + return @() + } + + if ($DryRun) { + [void]$exported.Add($listFile) + + Write-NetCleanLog -Level INFO -Message ("Would write Wi-Fi profile list file: {0}" -f $listFile) + + foreach ($wifiProfile in $profiles) { + [void]$exported.Add("PROFILE:$wifiProfile") + + 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) + + 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 + + foreach ($newFile in $newFiles) { + Write-NetCleanLog -Level INFO -Message ("Exported Wi-Fi profile to '{0}'" -f $newFile) + } + } + else { + Write-NetCleanLog -Level DEBUG -Message ("Bulk Wi-Fi export returned no new XML files. ExitCode={0}" -f $bulkResult.ExitCode) + } + } + catch { + $bulkSucceeded = $false + + Write-NetCleanLog -Level WARN -Message ("Bulk Wi-Fi export failed: {0}" -f $_.Exception.Message) + } + + if (-not $bulkSucceeded) { + foreach ($wifiProfile in $profiles) { + if (-not (Test-NetCleanSafeIdentifier -Value $wifiProfile)) { + Write-NetCleanLog -Level WARN -Message ("Skipping Wi-Fi profile with unsafe characters in its name: {0}" -f $wifiProfile) + continue + } + + 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) { + 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) + + Write-NetCleanLog -Level INFO -Message ("Exported Wi-Fi profile '{0}' to '{1}'" -f $wifiProfile, $newFile) + } + } + catch { + 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 + ) + + + if (-not $DryRun) { + New-DirectoryIfNotExist -Path $Dest + } + $file = Join-Path $Dest ("FirewallPolicy_{0}.wfw" -f (Get-Date -Format 'yyyyMMdd_HHmmss')) + + 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) { + Write-NetCleanLog -Level ERROR -Message ("Firewall policy export failed: {0}" -f $result.Error) + throw $result.Error + } + + if (-not $DryRun) { + Write-NetCleanLog -Level INFO -Message ("Exported firewall policy: {0}" -f $file) + } + + return $file +} + +<# +.SYNOPSIS +Shared JSON-export skeleton for Phase 2 backup artifacts. +.DESCRIPTION +Builds the destination file path and either logs the planned path +(`-DryRun`) or writes the supplied data as UTF-8 JSON to disk. Callers +compute `Data` themselves before calling this, so derivation happens +unconditionally in both dry-run and real runs, matching prior behavior. +Consolidates the identical build-path/log/write skeleton previously +duplicated across Export-ProtectionInventory, Export-ProtectionRegistryMap, +Export-SanitizableNetworkArtifact, and Export-NetCleanManifest. +.PARAMETER Dest +Destination directory for the JSON file. +.PARAMETER FileNamePrefix +Prefix used to build the timestamped output file name. +.PARAMETER LogNoun +Human-readable noun phrase used in the "would export"/"exported" log lines. +.PARAMETER Data +The object graph to serialize. +.PARAMETER DryRun +Return the intended output path without writing a file. +.OUTPUTS +System.String +#> +function Export-NetCleanJsonArtifact { + [CmdletBinding()] + [OutputType([System.String])] + param( + [Parameter(Mandatory = $true)] + [string]$Dest, + + [Parameter(Mandatory = $true)] + [string]$FileNamePrefix, + + [Parameter(Mandatory = $true)] + [string]$LogNoun, + + [Parameter()] + [AllowNull()] + [object]$Data, + + [switch]$DryRun + ) + + + if (-not $DryRun) { + New-DirectoryIfNotExist -Path $Dest + } + + $file = Join-Path $Dest ("{0}_{1}.json" -f $FileNamePrefix, (Get-Date -Format 'yyyyMMdd_HHmmss')) + + if ($DryRun) { + Write-NetCleanLog -Level INFO -Message ("Would export {0} to: {1}" -f $LogNoun, $file) + return $file + } + + $json = ConvertTo-Json -InputObject $Data -Depth 8 + WriteAllText -Path $file -Contents $json -Encoding $script:Utf8NoBom + + Write-NetCleanLog -Level INFO -Message ("Exported {0} to: {1}" -f $LogNoun, $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 + ) + + 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) + } + + Export-NetCleanJsonArtifact ` + -Dest $Dest ` + -FileNamePrefix 'ProtectionInventory' ` + -LogNoun 'protection inventory' ` + -Data $Inventory ` + -DryRun:$DryRun +} + +<# +.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 + ) + + $map = @(Get-ProtectionRegistryMap -Inventory $Inventory) + + Export-NetCleanJsonArtifact ` + -Dest $Dest ` + -FileNamePrefix 'ProtectionRegistryMap' ` + -LogNoun 'protection registry map' ` + -Data $map ` + -DryRun:$DryRun +} + +<# +.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 + ) + + $artifacts = @(Get-SanitizableNetworkArtifact -Inventory $Inventory) + + Export-NetCleanJsonArtifact ` + -Dest $Dest ` + -FileNamePrefix 'SanitizableNetworkArtifact' ` + -LogNoun 'sanitizable artifact inventory' ` + -Data $artifacts ` + -DryRun:$DryRun +} + +<# +.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 + ) + + Export-NetCleanJsonArtifact ` + -Dest $Dest ` + -FileNamePrefix 'RestoreManifest' ` + -LogNoun 'restore manifest' ` + -Data $Manifest ` + -DryRun:$DryRun +} + +<# +.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 + ) + + + Write-NetCleanLog -Level INFO -Message ("Phase 2 protect started. BackupPath={0} DryRun={1}" -f $BackupPath, [bool]$DryRun) + + if (-not $DryRun) { + try { + New-DirectoryIfNotExist -Path $BackupPath + Set-NetCleanPrivateDirectoryAcl -Path $BackupPath + } + catch { + throw "Unable to create or secure the backup directory '$BackupPath': $($_.Exception.Message). Check that the path does not already exist as a file, and that you have permission to create directories there." + } + } + + $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 = @() + } + + try { + $manifest.ProtectionInventoryJson = Export-ProtectionInventory -Dest $BackupPath -Inventory $inventory -DryRun:$DryRun + } + catch { + $manifest.ProtectionInventoryJson = $null + Write-NetCleanLog -Level WARN -Message ("Protection inventory backup failed or was skipped due to error: {0}" -f $_.Exception.Message) + } + + try { + $manifest.ProtectionRegistryMapJson = Export-ProtectionRegistryMap -Dest $BackupPath -Inventory $inventory -DryRun:$DryRun + } + catch { + $manifest.ProtectionRegistryMapJson = $null + Write-NetCleanLog -Level WARN -Message ("Protection registry map backup failed or was skipped due to error: {0}" -f $_.Exception.Message) + } + + try { + $manifest.SanitizableArtifactsJson = Export-SanitizableNetworkArtifact -Dest $BackupPath -Inventory $inventory -DryRun:$DryRun + } + catch { + $manifest.SanitizableArtifactsJson = $null + Write-NetCleanLog -Level WARN -Message ("Sanitizable artifact backup failed or was skipped due to error: {0}" -f $_.Exception.Message) + } + + try { + $manifest.AdapterConfigurationJson = Export-NetCleanAdapterConfiguration -Dest $BackupPath -DryRun:$DryRun + } + catch { + $manifest.AdapterConfigurationJson = $null + Write-NetCleanLog -Level WARN -Message ("Adapter configuration backup failed or was skipped due to error: {0}" -f $_.Exception.Message) + } + + try { + $manifest.NetworkListBackup = Export-NetworkList -Dest $BackupPath -DryRun:$DryRun + 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) + } + } + catch { + $manifest.NetworkListBackup = $null + Write-NetCleanLog -Level WARN -Message ("NetworkList backup failed or was skipped due to error: {0}" -f $_.Exception.Message) + } + + $wifiExportParameters = @{ + Dest = $BackupPath + DryRun = [bool]$DryRun + } + if ($Context.PSObject.Properties.Name -contains 'CollectionSnapshot' -and + $Context.CollectionSnapshot.PSObject.Properties.Name -contains 'WiFiProfiles') { + $wifiExportParameters.Profiles = @($Context.CollectionSnapshot.WiFiProfiles | ForEach-Object Name) + } + + try { + $manifest.WiFiExports = @(Export-WiFiProfile @wifiExportParameters) + } + catch { + $manifest.WiFiExports = @() + Write-NetCleanLog -Level WARN -Message ("Wi-Fi profile backup failed or was skipped due to error: {0}" -f $_.Exception.Message) + } + + if (-not $SkipFirewallBackup) { + try { + $manifest.FirewallPolicyBackup = Export-FirewallPolicy -Dest $BackupPath -DryRun:$DryRun + } + catch { + $manifest.FirewallPolicyBackup = $null + Write-NetCleanLog -Level WARN -Message ("Firewall policy backup failed or was skipped due to error: {0}" -f $_.Exception.Message) + } + } + else { + Write-NetCleanLog -Level INFO -Message 'Skipping firewall policy backup by option.' + } + + if ($protectedPaths.Count -gt 0) { + try { + $manifest.ProtectedRegistryBackups = @(Export-ProtectedRegistryKey -Paths $protectedPaths -Dest $BackupPath -DryRun:$DryRun) + + 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) + } + } + catch { + $manifest.ProtectedRegistryBackups = @() + Write-NetCleanLog -Level WARN -Message ("Protected registry key backup failed or was skipped due to error: {0}" -f $_.Exception.Message) + } + } + else { + 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 + 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) { + 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 + HasAdapterConfigurationBackup = [bool]$manifest.AdapterConfigurationJson + WiFiBackupCount = @($manifest.WiFiExports).Count + ProtectedRegistryBackupCount = @($manifest.ProtectedRegistryBackups).Count + } + }) -Force + + 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..9afc794 --- /dev/null +++ b/Modules/NetCleanPhase3.ps1 @@ -0,0 +1,1897 @@ +# --------------------------------------------------------------------------- +# 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 + ) + + + 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) { + Write-NetCleanLog -Level INFO -Message 'No Wi-Fi profiles found to remove.' + + return [pscustomobject]@{ + Removed = 0 + Profiles = @() + Operations = @() + } + } + + if ($DryRun) { + foreach ($wifiProfile in $profiles) { + 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 (Test-NetCleanSafeIdentifier -Value $wifiProfile)) { + Write-NetCleanLog -Level WARN -Message ("Skipping Wi-Fi profile with unsafe characters in its name: {0}" -f $wifiProfile) + + $operations.Add([pscustomobject]@{ + Name = $wifiProfile + Succeeded = $false + Skipped = $true + Reason = 'UnsafeName' + }) + + continue + } + + if (-not $PSCmdlet.ShouldProcess("Wi-Fi profile '$wifiProfile'", 'Delete')) { + 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 { + 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 ($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')) { + try { + $Adapters = @(Get-NetAdapter -ErrorAction Stop) + } + catch { + return [pscustomobject]@{ + Provider = 'Quad9 Secure' + DnsServers = $dnsServers + PreferIPv4 = $false + IPv4Preference = [pscustomobject]@{ + Succeeded = $false + Skipped = $true + Reason = 'AdapterDiscoveryFailed' + Error = $_.Exception.Message + } + DnsOverHttps = [pscustomobject]@{ + Supported = $true + ConfiguredCount = 0 + FailedCount = 0 + Reason = 'AdapterDiscoveryFailed' + Operations = @() + } + RequiresRestart = $false + ConfiguredCount = 0 + SkippedCount = 0 + FailedCount = 0 + Succeeded = $false + Operations = @() + } + } + } + + $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 +Runs a single native command for a Phase3 cache-clearing wrapper and logs the outcome. +.DESCRIPTION +Shared by Clear-DnsCacheSafe and Clear-ArpCacheSafe once each has already handled its own +-DryRun short-circuit and $PSCmdlet.ShouldProcess check; this only performs the actual +command invocation and success/failure logging. +#> +function Invoke-NetCleanSingleCommandSafe { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Private helper; each caller already performs its own $PSCmdlet.ShouldProcess check before calling in.')] + [CmdletBinding()] + [OutputType([System.Object])] + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList, + + [Parameter(Mandatory = $true)] + [string]$PastTenseVerb, + + [Parameter(Mandatory = $true)] + [string]$LowercaseAction, + + [switch]$IgnoreExitCode + ) + + $result = Invoke-ExternalCommandSafe -Name $Name -FilePath $FilePath -ArgumentList $ArgumentList -DryRun:$false -IgnoreExitCode:$IgnoreExitCode + + if ($result.Succeeded) { + Write-NetCleanLog -Level INFO -Message ("{0}." -f $PastTenseVerb) + } + else { + Write-NetCleanLog -Level WARN -Message ("Failed to {0}: {1}" -f $LowercaseAction, $result.Error) + } + + return $result +} + +<# +.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 + ) + + + if ($DryRun) { + 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')) { + Write-NetCleanLog -Level INFO -Message 'WhatIf/ShouldProcess prevented DNS cache flush.' + + return [pscustomobject]@{ + Name = 'Flush DNS cache' + Succeeded = $false + Skipped = $true + Reason = 'WhatIf' + } + } + + return Invoke-NetCleanSingleCommandSafe -Name 'Flush DNS cache' -FilePath 'ipconfig.exe' -ArgumentList @('/flushdns') ` + -PastTenseVerb 'Flushed DNS cache' -LowercaseAction 'flush DNS cache' +} + +<# +.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 + ) + + + if ($DryRun) { + 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')) { + Write-NetCleanLog -Level INFO -Message 'WhatIf/ShouldProcess prevented ARP cache clear.' + + return [pscustomobject]@{ + Name = 'Clear ARP cache' + Succeeded = $false + Skipped = $true + Reason = 'WhatIf' + } + } + + return Invoke-NetCleanSingleCommandSafe -Name 'Clear ARP cache' -FilePath 'arp.exe' -ArgumentList @('-d', '*') -IgnoreExitCode ` + -PastTenseVerb 'Cleared ARP cache' -LowercaseAction 'clear ARP cache' +} + +<# +.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 + ) + + + if (Test-RegistryPathProtected -Path $RegistryPath -Context $Context) { + 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 { + 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)) { + 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) { + 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')) { + 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 + + 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 { + 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 + ) + + + $artifacts = @($Context.SanitizableArtifacts) + $results = New-Object System.Collections.Generic.List[object] + + 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 ($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 + ) + + + $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) { + 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')) { + 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 ($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 { + 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 + ) + + + $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) { + + 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')) { + + 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 + + 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 { + + 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 + ) + + + $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) { + 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')) { + 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 ($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 { + 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 + ) + + + 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) { + 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'")) { + 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 ($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 { + 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 + ) + + + if ($Mode -eq 'PerformanceTune' -and [string]::IsNullOrWhiteSpace($PerformanceProfile)) { + throw "PerformanceProfile is required when Mode is 'PerformanceTune'." + } + + 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' + } + + Write-NetCleanLog -Level INFO -Message 'Skipping Wi-Fi profile cleanup by option.' + } + else { + $profilesToRemove = @() + $hasFreshSnapshot = $false + + if ($Context.PSObject.Properties.Name -contains 'NetworkProfileDecisions') { + $profilesToRemove += @( + $Context.NetworkProfileDecisions | + Where-Object { $_.ArtifactType -eq 'WiFiProfile' -and $_.Decision -eq 'Remove' } | + ForEach-Object Name + ) + $hasFreshSnapshot = $true + } + elseif ($Context.PSObject.Properties.Name -contains 'CollectionSnapshot') { + $profilesToRemove += @( + $Context.CollectionSnapshot.WiFiProfiles | + Where-Object { -not $_.IsPolicyManaged } | + ForEach-Object Name + ) + $hasFreshSnapshot = $true + } + + if ($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) + } + + if (-not $hasFreshSnapshot) { + $profilesToRemove += @(Get-WiFiProfileName) + } + + $uniqueProfiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($profileName in $profilesToRemove) { + if (-not [string]::IsNullOrWhiteSpace($profileName)) { + [void]$uniqueProfiles.Add($profileName.Trim()) + } + } + $profilesToRemove = @($uniqueProfiles | Sort-Object) + $wifiResult = Remove-WiFiProfilesSafe -DryRun:$DryRun -WifiProfiles $profilesToRemove + } + + if ($SkipDnsFlush) { + $dnsResult = [pscustomobject]@{ + Name = 'Flush DNS cache' + Succeeded = $true + DryRun = [bool]$DryRun + Skipped = $true + Reason = 'SkippedByOption' + } + + 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 = @() + Write-NetCleanLog -Level INFO -Message 'Skipping network event log cleanup by option.' + } + else { + $logResults = @(Clear-NetworkEventLogsSafe -DryRun:$DryRun) + } + + if ($SkipUserArtifacts) { + $userResults = @() + 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 + + $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 ($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 + # 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', + 'Read-NetCleanPerformanceProfileSelection' +) diff --git a/Modules/NetCleanPhase4.ps1 b/Modules/NetCleanPhase4.ps1 new file mode 100644 index 0000000..72fdd4e --- /dev/null +++ b/Modules/NetCleanPhase4.ps1 @@ -0,0 +1,1207 @@ +# --------------------------------------------------------------------------- +# Phase 4 - Verify helpers +# --------------------------------------------------------------------------- + +<# +.SYNOPSIS +Builds the standard "not applicable" verification result shared by every +Phase 4 Test-NetClean*PostState function's early-return paths. +#> +function New-NetCleanNotApplicableResult { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Pure in-memory object constructor; the New- verb reflects object creation, not system state mutation.')] + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string]$Reason + ) + + return [pscustomobject]@{ + Applicable = $false + Passed = $true + Reason = $Reason + Checks = @() + } +} + +<# +.SYNOPSIS +Builds one Phase 4 verification-check entry with NetClean's standard shape. +.DESCRIPTION +Category/Target/Expected/Actual/Passed/Error are always present. VerificationType +and Applicable are only included when the caller supplies them, since some check +families (e.g. adapter checks) never had those fields and some (e.g. WiFiConnection/ +DnsCache/ArpCache checks) always do - this preserves each family's original shape. +#> +function New-NetCleanVerificationCheck { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Pure in-memory object constructor; the New- verb reflects object creation, not system state mutation.')] + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string]$Category, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [object]$Target, + + [string]$VerificationType, + + [bool]$Applicable, + + [AllowNull()] + [object]$Expected, + + [AllowNull()] + [object]$Actual, + + [Parameter(Mandatory = $true)] + [bool]$Passed, + + [AllowNull()] + [object]$ErrorMessage + ) + + $record = [ordered]@{ + Category = $Category + Target = $Target + } + if ($PSBoundParameters.ContainsKey('VerificationType')) { + $record.VerificationType = $VerificationType + } + if ($PSBoundParameters.ContainsKey('Applicable')) { + $record.Applicable = $Applicable + } + $record.Expected = $Expected + $record.Actual = $Actual + $record.Passed = $Passed + $record.Error = $ErrorMessage + + return [pscustomobject]$record +} + +<# +.SYNOPSIS +Builds one artifact-decision verification entry for Test-NetCleanArtifactRemovalPostState. +.DESCRIPTION +Decides the Detail message from whether the decision was verified, using the +caller-supplied wording for the two distinct "not verified" cases (still present +when it should have been removed, vs. missing when it should have been preserved) - +those messages differ between the NetworkProfileDecisions and CandidateArtifacts +call sites. +#> +function New-NetCleanArtifactDecisionCheck { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Pure in-memory object constructor; the New- verb reflects object creation, not system state mutation.')] + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [AllowNull()] + [string]$ArtifactType, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [string]$Name, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [string]$Decision, + + [Parameter(Mandatory = $true)] + [bool]$Verified, + + [Parameter(Mandatory = $true)] + [bool]$ExpectedRemoved, + + [Parameter(Mandatory = $true)] + [string]$StillPresentDetail, + + [Parameter(Mandatory = $true)] + [string]$MissingDetail + ) + + $detail = if ($Verified) { + 'Matches expected decision' + } + elseif ($ExpectedRemoved) { + $StillPresentDetail + } + else { + $MissingDetail + } + + return [pscustomobject]@{ + ArtifactType = $ArtifactType + Name = $Name + Decision = $Decision + Verified = $Verified + Detail = $detail + } +} + +<# +.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 New-NetCleanNotApplicableResult -Reason 'NoAdapterChanges' + } + + if ( + $Context.Clean.PSObject.Properties.Name -contains 'DryRun' -and + $Context.Clean.DryRun + ) { + return New-NetCleanNotApplicableResult -Reason 'DryRun' + } + + $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((New-NetCleanVerificationCheck -Category 'AdapterCommand' -Target $operation.Name ` + -Expected 'Succeeded' -Actual $operation.Reason -Passed $false -ErrorMessage $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((New-NetCleanVerificationCheck -Category 'IPv4Dhcp' -Target $operation.Name ` + -Expected 'Enabled' -Actual ($dhcpStates -join ', ') -Passed $dhcpPassed -ErrorMessage $null)) + } + catch { + $checks.Add((New-NetCleanVerificationCheck -Category 'IPv4Dhcp' -Target $operation.Name ` + -Expected 'Enabled' -Actual $null -Passed $false -ErrorMessage $_.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((New-NetCleanVerificationCheck -Category 'DnsServers' -Target $operation.Name ` + -Expected @($expectedDns | Sort-Object) -Actual $actualDns -Passed $dnsPassed -ErrorMessage $null)) + } + catch { + $checks.Add((New-NetCleanVerificationCheck -Category 'DnsServers' -Target $operation.Name ` + -Expected $expectedDns -Actual @() -Passed $false -ErrorMessage $_.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((New-NetCleanVerificationCheck -Category 'IPv4Preference' -Target 'Windows IP stack' ` + -Expected 32 -Actual $actualPreference -Passed ($actualPreference -eq 32) -ErrorMessage $null)) + } + catch { + $checks.Add((New-NetCleanVerificationCheck -Category 'IPv4Preference' -Target 'Windows IP stack' ` + -Expected 32 -Actual $null -Passed $false -ErrorMessage $_.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((New-NetCleanVerificationCheck -Category 'DnsOverHttps' -Target $serverAddress ` + -Expected 'AutoUpgrade=True; AllowFallbackToUdp=False' -Actual $actualDoh -Passed $dohPassed -ErrorMessage $null)) + } + catch { + $checks.Add((New-NetCleanVerificationCheck -Category 'DnsOverHttps' -Target $serverAddress ` + -Expected 'AutoUpgrade=True; AllowFallbackToUdp=False' -Actual $null -Passed $false -ErrorMessage $_.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 New-NetCleanNotApplicableResult -Reason 'NoCleanupResults' + } + + if ( + $Context.Clean.PSObject.Properties.Name -contains 'DryRun' -and + $Context.Clean.DryRun + ) { + return New-NetCleanNotApplicableResult -Reason 'DryRun' + } + + $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 = Get-NetCleanSafeProperty -InputObject $operation -Name 'Error' + + $actualCacheState = if ($skippedByOption) { 'SkippedByOption' } elseif ($succeeded) { 'Succeeded' } else { 'Failed' } + $checks.Add((New-NetCleanVerificationCheck -Category 'VolatileCacheAction' -Target $operation.Name -VerificationType 'CommandResult' ` + -Expected 'Succeeded or explicitly skipped' -Actual $actualCacheState -Passed ($succeeded -or $skippedByOption) -ErrorMessage $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 = Get-NetCleanSafeProperty -InputObject $operation -Name 'Reason' + $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((New-NetCleanVerificationCheck -Category 'RegistryArtifact' -Target $target -VerificationType 'ProtectionBoundary' ` + -Expected 'Preserved' -Actual 'Protected' -Passed $true -ErrorMessage $null)) + continue + } + + if ($shouldBeAbsent) { + try { + $exists = Test-RegistryPathExist -RegistryPath $target -ThrowOnError + $checks.Add((New-NetCleanVerificationCheck -Category 'RegistryArtifact' -Target $target -VerificationType 'IndependentState' ` + -Expected 'Absent' -Actual $(if ($exists) { 'Present' } else { 'Absent' }) -Passed (-not $exists) -ErrorMessage $null)) + } + catch { + $checks.Add((New-NetCleanVerificationCheck -Category 'RegistryArtifact' -Target $target -VerificationType 'IndependentState' ` + -Expected 'Absent' -Actual 'Unknown' -Passed $false -ErrorMessage $_.Exception.Message)) + } + continue + } + + $registryActual = if ($reason) { $reason } else { 'Failed' } + $registryError = if ($succeeded) { $null } else { $reason } + $checks.Add((New-NetCleanVerificationCheck -Category 'RegistryArtifact' -Target $target -VerificationType 'CommandResult' ` + -Expected 'Removed, absent, or protected' -Actual $registryActual -Passed $false -ErrorMessage $registryError)) + } + } + + if ($Context.Clean.PSObject.Properties.Name -contains 'UserArtifacts') { + foreach ($operation in @($Context.Clean.UserArtifacts)) { + if (-not $operation) { + continue + } + + $reason = Get-NetCleanSafeProperty -InputObject $operation -Name 'Reason' + $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((New-NetCleanVerificationCheck -Category 'UserArtifact' -Target $operation.Path -VerificationType 'IndependentState' ` + -Expected 'Absent' -Actual $(if ($exists) { 'Present' } else { 'Absent' }) -Passed (-not $exists) -ErrorMessage $null)) + } + catch { + $checks.Add((New-NetCleanVerificationCheck -Category 'UserArtifact' -Target $operation.Path -VerificationType 'IndependentState' ` + -Expected 'Absent' -Actual 'Unknown' -Passed $false -ErrorMessage $_.Exception.Message)) + } + continue + } + + $userArtifactActual = if ($reason) { $reason } else { 'Failed' } + $userArtifactError = if ($succeeded) { $null } else { $reason } + $checks.Add((New-NetCleanVerificationCheck -Category 'UserArtifact' -Target $operation.Path -VerificationType 'CommandResult' ` + -Expected 'Removed or absent' -Actual $userArtifactActual -Passed $false -ErrorMessage $userArtifactError)) + } + } + + 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 = Get-NetCleanSafeProperty -InputObject $operation -Name 'CompletedAt' + + if ($cleared -and $succeeded -and $completedAt) { + try { + $priorEvents = @( + Get-WinEvent ` + -FilterHashtable @{ + LogName = $operation.LogName + EndTime = $completedAt + } ` + -MaxEvents 1 ` + -ErrorAction Stop + ) + $checks.Add((New-NetCleanVerificationCheck -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) -ErrorMessage $null)) + } + catch { + $noEventsFound = $_.FullyQualifiedErrorId -like 'NoMatchingEventsFound*' + $eventLogError = if ($noEventsFound) { $null } else { $_.Exception.Message } + $checks.Add((New-NetCleanVerificationCheck -Category 'EventLog' -Target $operation.LogName -VerificationType 'IndependentState' ` + -Expected 'No events at or before cleanup completion' -Actual $(if ($noEventsFound) { 'Absent' } else { 'Unknown' }) ` + -Passed $noEventsFound -ErrorMessage $eventLogError)) + } + continue + } + + $errorMessage = Get-NetCleanSafeProperty -InputObject $operation -Name 'Error' + $eventLogTarget = if ($operation.PSObject.Properties.Name -contains 'LogName') { $operation.LogName } else { $operation.Name } + $eventLogActual = if (-not $succeeded) { 'Failed' } elseif (-not $cleared) { 'NotCleared' } else { 'MissingCompletionTime' } + $checks.Add((New-NetCleanVerificationCheck -Category 'EventLog' -Target $eventLogTarget -VerificationType 'CommandResult' ` + -Expected 'Cleared with completion timestamp' -Actual $eventLogActual -Passed $false -ErrorMessage $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 = Get-NetCleanSafeProperty -InputObject $operation -Name 'Error' + $checks.Add((New-NetCleanVerificationCheck -Category $operationGroup.Category -Target $operation.Name -VerificationType 'CommandResult' ` + -Expected 'Succeeded' -Actual $(if ($succeeded) { 'Succeeded' } else { 'Failed' }) -Passed $succeeded -ErrorMessage $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' }) + $wifiConnectionActual = if ($connectedWifi.Count -eq 0) { 'Disconnected' } else { @($connectedWifi.Name) } + $checks.Add((New-NetCleanVerificationCheck -Category 'WiFiConnection' -Target 'Physical Wi-Fi adapters' -VerificationType 'IndependentState' -Applicable $true ` + -Expected 'Disconnected' -Actual $wifiConnectionActual -Passed ($connectedWifi.Count -eq 0) -ErrorMessage $null)) + + if ($connectedWired.Count -gt 0) { + foreach ($category in @('DnsCache', 'ArpCache')) { + $checks.Add((New-NetCleanVerificationCheck -Category $category -Target 'Local cache' -VerificationType 'ConditionalState' -Applicable $false ` + -Expected 'Not evaluated while wired LAN is connected' -Actual 'WiredLanConnected' -Passed $true -ErrorMessage $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((New-NetCleanVerificationCheck -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) -ErrorMessage $null)) + } + catch { + $checks.Add((New-NetCleanVerificationCheck -Category 'DnsCache' -Target 'DNS client cache' -VerificationType 'ConditionalState' -Applicable $true ` + -Expected 'Empty when no wired LAN is connected' -Actual 'Unknown' -Passed $false -ErrorMessage $_.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((New-NetCleanVerificationCheck -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) -ErrorMessage $null)) + } + catch { + $checks.Add((New-NetCleanVerificationCheck -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 -ErrorMessage $_.Exception.Message)) + } + } + } + } + catch { + $checks.Add((New-NetCleanVerificationCheck -Category 'ConnectivityDetection' -Target 'Physical network adapters' -VerificationType 'IndependentState' -Applicable $true ` + -Expected 'Adapter state available' -Actual 'Unknown' -Passed $false -ErrorMessage $_.Exception.Message)) + } + } + + return [pscustomobject]@{ + Applicable = $true + Passed = (@($checks | Where-Object { -not $_.Passed }).Count -eq 0) + Reason = 'Verified' + Checks = $checks.ToArray() + } +} + +<# +.SYNOPSIS +Verifies each Phase 1 artifact decision against what Phase 3 actually recorded. +.DESCRIPTION +Joins Context.NetworkProfileDecisions (Wi-Fi/NetworkList profiles) and +Context.CandidateArtifacts (registry paths) against the per-item ledgers Phase 3 +already produced (Clean.RegistryArtifacts.Results, plus the live remaining- +profile lists computed by the caller), rather than re-detecting state. Every +Decision=Remove item must be confirmed gone; every Decision=Preserve item must +be confirmed still present/untouched. Not applicable during a dry run or when +no cleanup results exist. +.PARAMETER Context +The context object containing NetworkProfileDecisions, CandidateArtifacts, and +the Clean-phase result ledgers. +.PARAMETER RemainingWiFiProfiles +Wi-Fi profile names still present after cleanup, as already collected by the caller. +.PARAMETER RemainingNetworkProfiles +NetworkList profile names still present after cleanup, as already collected by the caller. +.OUTPUTS +System.Management.Automation.PSCustomObject +#> +function Test-NetCleanArtifactRemovalPostState { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context, + + [Parameter()] + [AllowNull()] + [string[]]$RemainingWiFiProfiles, + + [Parameter()] + [AllowNull()] + [string[]]$RemainingNetworkProfiles + ) + + if ( + $Context.PSObject.Properties.Name -notcontains 'Clean' -or + -not $Context.Clean + ) { + return New-NetCleanNotApplicableResult -Reason 'NoCleanupResults' + } + + if ( + $Context.Clean.PSObject.Properties.Name -contains 'DryRun' -and + $Context.Clean.DryRun + ) { + return New-NetCleanNotApplicableResult -Reason 'DryRun' + } + + $checks = [System.Collections.Generic.List[object]]::new() + + $remainingWiFiSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + Add-HashSetValue -Set $remainingWiFiSet -Values $RemainingWiFiProfiles + + $remainingNetworkSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + Add-HashSetValue -Set $remainingNetworkSet -Values $RemainingNetworkProfiles + + if ($Context.PSObject.Properties.Name -contains 'NetworkProfileDecisions') { + foreach ($decision in @($Context.NetworkProfileDecisions)) { + if ([string]::IsNullOrWhiteSpace($decision.Name)) { continue } + + $stillPresent = if ($decision.ArtifactType -eq 'WiFiProfile') { + $remainingWiFiSet.Contains($decision.Name) + } + elseif ($decision.ArtifactType -eq 'NetworkListProfile') { + $remainingNetworkSet.Contains($decision.Name) + } + else { + $null + } + + if ($null -eq $stillPresent) { continue } + + $expectedRemoved = $decision.Decision -eq 'Remove' + $verified = if ($expectedRemoved) { -not $stillPresent } else { $stillPresent } + + $checks.Add((New-NetCleanArtifactDecisionCheck -ArtifactType $decision.ArtifactType -Name $decision.Name -Decision $decision.Decision ` + -Verified $verified -ExpectedRemoved $expectedRemoved ` + -StillPresentDetail 'Still present after cleanup' -MissingDetail 'Missing after cleanup; expected to be preserved')) + } + } + + if ($Context.PSObject.Properties.Name -contains 'CandidateArtifacts') { + $removedPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + if ($Context.Clean.PSObject.Properties.Name -contains 'RegistryArtifacts' -and $Context.Clean.RegistryArtifacts) { + foreach ($regResult in @($Context.Clean.RegistryArtifacts.Results)) { + if ([string]::IsNullOrWhiteSpace($regResult.RegistryPath)) { + continue + } + + # Removing a parent key recursively removes its children too, so a child candidate's + # own removal attempt can find it already gone and record Skipped/NotFound rather than + # Removed - that still confirms the path is absent, so it counts the same as Removed. + $confirmedAbsent = ( + [bool]$regResult.Removed -or + (Get-NetCleanSafeProperty -InputObject $regResult -Name 'Reason') -eq 'NotFound' + ) + if ($confirmedAbsent) { + [void]$removedPaths.Add([string]$regResult.RegistryPath) + } + } + } + + foreach ($candidate in @($Context.CandidateArtifacts)) { + if ([string]::IsNullOrWhiteSpace($candidate.RegistryPath)) { continue } + + $expectedRemoved = $candidate.Decision -eq 'Remove' + $wasRemoved = $removedPaths.Contains($candidate.RegistryPath) + $verified = if ($expectedRemoved) { $wasRemoved } else { -not $wasRemoved } + + $checks.Add((New-NetCleanArtifactDecisionCheck -ArtifactType $candidate.ArtifactType -Name $candidate.RegistryPath -Decision $candidate.Decision ` + -Verified $verified -ExpectedRemoved $expectedRemoved ` + -StillPresentDetail 'Not confirmed removed by Phase 3' -MissingDetail 'Unexpectedly removed despite Preserve decision')) + } + } + + return [pscustomobject]@{ + Applicable = $true + Passed = (@($checks | Where-Object { -not $_.Verified }).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 + ) + + + Write-NetCleanLog -Level INFO -Message 'Phase 4 verify started.' + + $preInventory = @($Context.Inventory) + if ($Context.PSObject.Properties.Name -contains 'CollectionSnapshot' -and + $Context.CollectionSnapshot.PSObject.Properties.Name -contains 'ServiceRegistry') { + $postInventory = @( + Get-ProtectionInventory ` + -ServiceRegistrySnapshot $Context.CollectionSnapshot.ServiceRegistry ` + -ParallelSupplementalEvidence + ) + } + else { + $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) { + 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) { + Write-NetCleanLog -Level INFO -Message ("Post-cleaning protected service: {0}" -f $svc) + } + + 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) + } + + $preservedWiFiNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $preservedNetworkProfileNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + if ($Context.PSObject.Properties.Name -contains 'NetworkProfileDecisions') { + foreach ($decision in @($Context.NetworkProfileDecisions)) { + if ($decision.Decision -ne 'Preserve' -or [string]::IsNullOrWhiteSpace($decision.Name)) { + continue + } + + if ($decision.ArtifactType -eq 'WiFiProfile') { + [void]$preservedWiFiNames.Add($decision.Name) + } + elseif ($decision.ArtifactType -eq 'NetworkListProfile') { + [void]$preservedNetworkProfileNames.Add($decision.Name) + } + } + } + + $unexpectedRemainingWiFiProfiles = @( + $remainingWiFiProfiles | Where-Object { -not $preservedWiFiNames.Contains($_) } + ) + $unexpectedRemainingNetworkProfiles = @( + $remainingNetworkProfiles | Where-Object { -not $preservedNetworkProfileNames.Contains($_) } + ) + + $adapterVerification = Test-NetCleanAdapterPostState -Context $Context + $cleanupVerification = Test-NetCleanCleanupPostState -Context $Context + $artifactVerification = Test-NetCleanArtifactRemovalPostState ` + -Context $Context ` + -RemainingWiFiProfiles $remainingWiFiProfiles ` + -RemainingNetworkProfiles $remainingNetworkProfiles + + return [pscustomobject]@{ + VerificationMode = if ($isDryRun) { 'Planned' } else { 'Observed' } + PreInventory = $preInventory + PostInventory = $postInventory + VendorComparison = $vendorComparison + GuidComparison = $guidComparison + ServiceComparison = $serviceComparison + RemainingWiFiProfiles = $remainingWiFiProfiles + RemainingNetworkProfiles = $remainingNetworkProfiles + UnexpectedRemainingWiFiProfiles = $unexpectedRemainingWiFiProfiles + UnexpectedRemainingNetworkProfiles = $unexpectedRemainingNetworkProfiles + AdapterVerification = $adapterVerification + CleanupVerification = $cleanupVerification + ArtifactVerification = $artifactVerification + Passed = ( + @($vendorComparison.Missing).Count -eq 0 -and + @($guidComparison.Missing).Count -eq 0 -and + @($serviceComparison.Missing).Count -eq 0 -and + $unexpectedRemainingWiFiProfiles.Count -eq 0 -and + $unexpectedRemainingNetworkProfiles.Count -eq 0 -and + $adapterVerification.Passed -and + $cleanupVerification.Passed -and + $artifactVerification.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 + ArtifactVerification = Get-NetCleanSafeProperty -InputObject $Verification -Name 'ArtifactVerification' + } + + $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 + ) + + Write-NetCleanLog -Level INFO -Message 'Invoke-NetCleanPhase4Verify: starting verification.' + + $verification = Test-NetCleanPostState -Context $Context + $artifactVerification = Get-NetCleanSafeProperty -InputObject $verification -Name 'ArtifactVerification' + $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 + ArtifactVerification = $artifactVerification + 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 + ArtifactCheckFailureCount = @( + $(if ($artifactVerification) { @($artifactVerification.Checks) } else { @() }) | + Where-Object { -not $_.Verified } + ).Count + Passed = $verification.Passed + } + }) -Force + + Write-NetCleanLog -Level INFO -Message ('Invoke-NetCleanPhase4Verify: verification complete. Passed={0}' -f $verification.Passed) + + # Detailed verification logging + 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 = Get-NetCleanSafeProperty -InputObject $check -Name 'Error' + $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 + } + + $artifactChecks = if ($artifactVerification) { @($artifactVerification.Checks) } else { @() } + foreach ($check in $artifactChecks) { + $level = if ($check.Verified) { 'INFO' } else { 'WARN' } + $message = 'Verification: {0} Name={1} Decision={2} Verified={3} Detail={4}' -f ` + $check.ArtifactType, + $check.Name, + $check.Decision, + $check.Verified, + $check.Detail + 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} ArtifactCheckFailures={8}" -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, + @($artifactChecks | Where-Object { -not $_.Verified }).Count)) -InformationAction Continue + + return $newContext +} + +Export-ModuleMember -Function @( + 'Invoke-NetCleanPhase4Verify', + 'Test-NetCleanPostState' +) diff --git a/Netclean.psd1 b/Netclean.psd1 index f8603bb..0950ee0 100644 --- a/Netclean.psd1 +++ b/Netclean.psd1 @@ -1,17 +1,37 @@ @{ - 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 = '7.4' + CompatiblePSEditions = @('Core') + + FunctionsToExport = @( + 'Invoke-NetCleanPhase1Detect', + 'Invoke-NetCleanPhase2Protect', + 'Invoke-NetCleanPhase3Clean', + 'Invoke-NetCleanPhase4Verify', + 'Invoke-NetCleanWorkflow', + 'Get-NetCleanLogFile', + 'Start-NetCleanLog', + 'Write-NetCleanLog', + 'Read-NetCleanPerformanceProfileSelection' + ) + + 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/main/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..f77dc58 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -3,9 +3,14 @@ # Keep defaults but explicitly enable common best-practice rules PSAvoidUsingCmdletAliases = @{ Enable = $true; Severity = 'Error' } PSAvoidUsingWriteHost = @{ Enable = $true; Severity = 'Warning' } - PSUseApprovedVerbs = @{ Enable = $false; Severity = 'Warning' } + PSUseApprovedVerbs = @{ Enable = $true; 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..47f3ffb 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,171 @@ -# 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) +## Badges -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. +[![CI](https://github.com/scweeks/netclean/actions/workflows/ci.yml/badge.svg)](https://github.com/scweeks/netclean/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/endpoint?url=https://scweeks.github.io/netclean/coverage.json)](https://scweeks.github.io/netclean/coverage.json) +[![Analyzer](https://img.shields.io/badge/style-PSScriptAnalyzer-00aaff)](https://github.com/scweeks/netclean/actions/workflows/ci.yml) +[![Coverage target](https://img.shields.io/badge/coverage_target-94%25-green)](https://github.com/scweeks/netclean/actions/workflows/ci.yml) +[![PowerShell 7.4+](https://img.shields.io/badge/PowerShell-7.4%2B-blue)](https://learn.microsoft.com/powershell/) +[![Windows](https://img.shields.io/badge/platform-Windows-blue)](https://github.com/scweeks/netclean) +[![License](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE) -> 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. +The coverage badge is served from GitHub Pages at `https://scweeks.github.io/netclean/coverage.json`. It updates from the CI pipeline after a successful push to `main` and requires GitHub Pages to be enabled for the repository. -## Features +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. -- 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. +> [!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. -## Summary of actions +## Overview -- 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. +NetClean helps prepare Windows systems for use in semi-public or untrusted environments by: -## What it does NOT do +- Detecting security products, virtual adapters, and protected registry paths. +- Exporting an access-restricted backup of evidence and configuration. +- Performing targeted cleanup of network-history artifacts according to a chosen mode. +- Preserving protected inventory and avoiding modification of protected artifacts. +- Producing a JSON verification ledger and detailed logs for auditability. -- Modify Windows Firewall rules. -- Uninstall or remove AV/EDR products. -- Intentionally edit protections for Bitdefender, VMware, or other commonly detected vendor drivers/services. +The tool is designed to be safe by default, auditable, and reversible when used with the provided backup artifacts. ## Requirements -- Windows 10 / Windows 11 with PowerShell. -- Must be run as Administrator. The script will prompt to relaunch elevated if needed. +- Windows 10 or Windows 11. +- PowerShell 7.4 or later. +- Administrator privileges are required for full detection, backup, cleanup, and verification. -## Quick start +## 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: +## Modes -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File netclean.ps1 -Force -CreateLog -``` +| Mode | Behavior | +|---|---| +| `Menu` | Interactive mode selection. | +| `Preview` | Detects and simulates protection and 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; applies a performance profile to the standard workflow. | -Run and reboot automatically: +Performance tuning is intentionally omitted from the interactive menu while that work is tabled; use `-Mode PerformanceTune` explicitly. -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File netclean.ps1 -RebootNow -CreateLog -``` +## Launcher Parameters -## Command-line switches +| 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. | -| 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. | +## Workflow and Safety Model -## Backups & restore +1. Detect security products, services, drivers, adapters, protected registry paths, and candidate network artifacts. +2. Export the protection inventory, adapter IP and 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 where supported, 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 and NetworkList state, adapter DHCP and DNS state, the IPv4-preference registry value, encrypted-DNS configuration, removed registry and user artifacts, and cleared event logs. A private JSON verification ledger and detailed log record each applicable check. -- Backups are saved to the configured `BackupPath`. +Protection detection is best effort and cannot guarantee recognition of every security or virtual-network product. Always review the preview and inventory before cleanup. -Restore the exported `NetworkList` registry key (as Administrator): +## Backup Confidentiality -```powershell -reg import "/NetworkList_YYYYMMDD_HHMMSS.reg" -``` +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 and 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 backups when they are no longer needed. -Restore Wi‑Fi profiles (for each exported XML): +## Verification Limits -```powershell -netsh wlan add profile filename="/WiFiProfile_.xml" -``` +Wi-Fi verification requires physical Wi-Fi adapters to be disconnected. -Restore protected registry exports: +DNS and dynamic IPv4 neighbor or ARP caches require an empty network context for reliable verification; when a wired LAN is connected these checks may be recorded as not applicable. -```powershell -reg import "/reg_backup_... .reg" -``` +Stack repair and performance-tuning commands can require a restart or lack an immediate read-back signal; their outcomes are recorded in the evidence ledger. -## Tables & outputs +## Restore Examples -Key directories (defaults): +Restore an exported registry file from an elevated shell: -| Purpose | Default path | -|---------|--------------| -| Backups | `%ProgramData%\NetworkCleaner\Backups` | -| Logs | `%ProgramData%\NetworkCleaner\Logs` | +```powershell +reg.exe import "C:\path\to\backup.reg" +``` -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. +Restore a Wi-Fi profile: -## CI / Scheduled task examples +```powershell +netsh.exe wlan add profile filename="C:\path\to\Wi-Fi-profile.xml" +``` -This repository includes a lightweight GitHub Actions workflow that lints PowerShell with `PSScriptAnalyzer` and runs the wrapper in `-DryRun` mode. The workflow file is: +Restore a firewall policy: -- `.github/workflows/powershell-check.yml` +```powershell +netsh.exe advfirewall import "C:\path\to\FirewallPolicy.wfw" +``` -Example scheduled-task registration script is under `examples/register-scheduledtask.ps1` (creates an idempotent task to run the wrapper at startup). +The `examples` directory contains reusable launcher, Wi-Fi restore, and scheduled-task examples. -## Examples: PowerShell wrapper scripts +## Development and CI -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. +Test framework: Pester 6.0.1. -1) Simple runner that creates backups, logs, and performs the cleanup non-interactively: +Static analysis: PSScriptAnalyzer 1.25.0. -```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 -``` +The authoritative coverage run is intentionally sequential. Pester 6's file-level parallel execution remains experimental, so CI keeps coverage collection on the sequential path. The CI job validates the module manifest, treats analyzer findings as failures, runs Pester with JaCoCo coverage, publishes a GitHub Pages coverage badge payload, and uploads test artifacts. A dedicated `tests/Security` suite exercises command-injection resistance and backup-directory access-control guarantees for the tool's high-privilege operations. -2) Restore Wi‑Fi profiles from a backup folder (imports each XML): +Run tests locally: ```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 } +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 +``` -$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 } +Coverage and gates: -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): $_" } -} +CI enforces an overall coverage gate of 94 percent. Pull-request reporting includes a changed-file target of 95 percent. -Write-Host "Wi‑Fi restore complete." -ForegroundColor Green -``` +The coverage runner rejects source paths beneath `tests/` so test code cannot inflate results. Coverage output is written to `tests/TestResults` or the configured output path and is ignored by Git. -## Recommended workflow +Module cache refresh: -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. +The CI workflow exposes a `workflow_dispatch` input named `force-module-refresh` so maintainers can clear the module cache and force fresh installs from the Actions UI. Use this after merging dependency updates, before a release, or when debugging CI module issues. -## Troubleshooting & logs +## Contributing -- 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. +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines, testing expectations, and the preferred PR workflow. -## Contributing & license +In brief: -- 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. +- Follow red-green-refactor: add focused regression coverage for any behavior you change. +- Keep changes small and reviewable. +- Ensure PSScriptAnalyzer passes and Pester tests, including the coverage gate, succeed locally before opening a PR. +- When opening a PR, include a short description of the change, the tests added or updated, and any manual verification steps required. ---- +## License -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. +NetClean is released under the GPLv3. See [LICENSE](LICENSE) for full terms. \ No newline at end of file diff --git a/examples/register-scheduledtask.ps1 b/examples/register-scheduledtask.ps1 index 49bc8c5..b6e4388 100644 --- a/examples/register-scheduledtask.ps1 +++ b/examples/register-scheduledtask.ps1 @@ -1,11 +1,31 @@ param( [string]$WrapperPath = "$PSScriptRoot\run-netclean.ps1", - [string]$TaskName = 'NetClean-AutoRun' + [string]$TaskName = 'NetClean-AutoRun', + [Parameter(Mandatory = $true)] + [ValidateSet('Preview', 'SafeConferencePrep', 'AdvancedRepair', 'PerformanceTune')] + [string]$Mode, + [ValidateSet('Conservative', 'Optimal', 'Gaming', 'Default')] + [string]$PerformanceProfile ) if (-not (Test-Path $WrapperPath)) { Write-Error "Wrapper not found at $WrapperPath"; exit 1 } -$action = New-ScheduledTaskAction -Execute 'PowerShell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$WrapperPath`" -CreateLog" +# NetClean requires PowerShell 7.4+ (pwsh); Windows PowerShell 5.1 cannot load +# the module manifest. +$pwshCommand = Get-Command pwsh.exe -ErrorAction SilentlyContinue +if (-not $pwshCommand) { + Write-Error 'Could not find pwsh.exe (PowerShell 7.4+) on this system. Install PowerShell 7.4 or later before registering this scheduled task.' + exit 1 +} + +# -Force is required here: a SYSTEM/startup task has no console to answer the +# interactive "Proceed?" confirmation, so unattended execution must bypass it. +$wrapperArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$WrapperPath`" -Mode $Mode -Force -CreateLog" +if ($Mode -eq 'PerformanceTune' -and $PerformanceProfile) { + $wrapperArgs += " -PerformanceProfile $PerformanceProfile" +} + +$action = New-ScheduledTaskAction -Execute $pwshCommand.Source -Argument $wrapperArgs $trigger = New-ScheduledTaskTrigger -AtStartup # Register or update existing task @@ -13,8 +33,8 @@ try { if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { 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 + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -RunLevel Highest -User 'SYSTEM' -Description "Run netclean wrapper at startup (Mode=$Mode)" -Force + Write-Output "Scheduled task '$TaskName' registered to run $WrapperPath -Mode $Mode 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..2925912 100644 --- a/examples/restore-wifi-profiles.ps1 +++ b/examples/restore-wifi-profiles.ps1 @@ -1,15 +1,29 @@ 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 } + +$failed = [System.Collections.Generic.List[string]]::new() 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)" + $output = netsh wlan add profile filename="$($f.FullName)" 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Output "Imported: $($f.Name)" + } + else { + Write-Warning "Failed to import $($f.Name): $output" + [void]$failed.Add($f.Name) + } +} + +if ($failed.Count -gt 0) { + Write-Output "Wi-Fi restore complete with $($failed.Count) failure(s): $($failed -join ', ')" + exit 1 } -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..2dc1402 100644 --- a/examples/run-netclean.ps1 +++ b/examples/run-netclean.ps1 @@ -1,18 +1,38 @@ param( + [Parameter(Mandatory = $true)] + [ValidateSet('Preview', 'SafeConferencePrep', 'AdvancedRepair', 'PerformanceTune')] + [string]$Mode, [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", + [ValidateSet('Conservative', 'Optimal', 'Gaming', 'Default')] + [string]$PerformanceProfile ) $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 +# NetClean requires PowerShell 7.4+ (pwsh); Windows PowerShell 5.1 cannot load +# the module manifest. +$pwshCommand = Get-Command pwsh.exe -ErrorAction SilentlyContinue +if (-not $pwshCommand) { + Write-Error 'Could not find pwsh.exe (PowerShell 7.4+) on this system. Install PowerShell 7.4 or later before running this wrapper.' + exit 1 +} -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 +$procArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $script, '-Mode', $Mode) +if ($DryRun) { $procArgs += '-DryRun' } +if ($Force) { $procArgs += '-Force' } +if ($Mode -eq 'PerformanceTune' -and $PerformanceProfile) { $procArgs += '-PerformanceProfile', $PerformanceProfile } +$procArgs += '-CreateLog', '-BackupPath', $BackupPath, '-LogPath', $LogPath + +Write-Output "Launching netclean with args: $($procArgs -join ' ')" +$proc = Start-Process -FilePath $pwshCommand.Source -ArgumentList $procArgs -NoNewWindow -Wait -PassThru + +if ($proc.ExitCode -ne 0) { + Write-Error "netclean exited with code $($proc.ExitCode). Check logs under $LogPath" + exit $proc.ExitCode +} + +Write-Output "netclean run completed successfully. Check logs under $LogPath" diff --git a/netclean.ps1 b/netclean.ps1 index 08fb9a0..78f756a 100644 --- a/netclean.ps1 +++ b/netclean.ps1 @@ -1,628 +1,943 @@ -<# - 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' +} + +# Tracks whether the caller explicitly chose a tuning profile, as opposed to +# receiving the ValidateSet default - PerformanceTune must still ask which +# profile is wanted even under -Force when no explicit choice was made. +$script:PerformanceProfileExplicitlySet = $PSBoundParameters.ContainsKey('PerformanceProfile') + +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() + + $principal = [Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent() + $isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + if (-not $isAdmin) { + throw 'NetClean must be run as Administrator.' + } +} + +<# +.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 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: " + $_) + if ($answer -match '^[Yy]') { return $true } + if ($answer -match '^[Nn]') { return $false } + + Write-Verbose 'Please enter Y or N.' + } +} + +<# +.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 } - Write-NetcleanLog 'INFO' "Backup folder prepared: $path" } + } +} - 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 - } +<# +.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 +} - function Normalize-Guid($g) { - if (-not $g) { return $null } - return ($g -replace '[{}]','').ToLower() - } - - 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 {} +<# +.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 + } +} + +<# +.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 + } + + $isWorkplaceOnly = $ManagementState.JoinType -eq 'WorkplaceRegistered' + if ($isWorkplaceOnly) { + foreach ($propertyName in @('DomainJoined', 'EntraJoined', 'EnterpriseJoined', 'MdmEnrolled')) { + if ($ManagementState.PSObject.Properties.Name -contains $propertyName -and $ManagementState.$propertyName) { + $isWorkplaceOnly = $false + break } - return $found | Sort-Object -Unique } + } - 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) - } - - 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] } - } - } - 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') } - } + $isOrganizationManaged = [bool]$ManagementState.IsManaged -and -not $isWorkplaceOnly - 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 - } - } - } + if ($isOrganizationManaged) { + 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 + } +} - # Always protect common hypervisor adapters/services - $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 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 - } - - 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" - } - } - } +<# +.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 + } + 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 found: sourced from the Phase 1 collection snapshot, which is + # populated the same way for dry-run and real runs - unlike + # Protect.Manifest.WiFiExports, whose "PROFILE:" markers are only + # emitted in the dry-run export path. + if ($Result.PSObject.Properties.Name -contains 'CollectionSnapshot' -and + $Result.CollectionSnapshot.PSObject.Properties.Name -contains 'WiFiProfiles') { + $found = @($Result.CollectionSnapshot.WiFiProfiles | ForEach-Object Name | Where-Object { $_ }) + if ($found.Count -gt 0) { + Show-TruncatedList -Items $found -Heading 'Wi-Fi Profiles - Found' + } + } + + if ($Result.PSObject.Properties.Name -contains 'Protect' -and + $Result.Protect.Manifest -and + $Result.Protect.Manifest.NetworkListBackup) { + Write-Information '' -InformationAction Continue + Write-Information "Network list backup: $($Result.Protect.Manifest.NetworkListBackup)" -InformationAction Continue + } + + # 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' + + # Remaining comes from Phase 4's independent post-cleanup + # verification rather than being re-derived from the found list. + $remaining = @() + if ($Result.PSObject.Properties.Name -contains 'Verify' -and + $Result.Verify.PSObject.Properties.Name -contains 'RemainingWiFiProfiles') { + $remaining = @($Result.Verify.RemainingWiFiProfiles) } - return @{ Registry=$regPaths|Sort-Object -Unique; Drivers=$driverFiles|Sort-Object -Unique } + 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 } } - 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 } + # 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' } - 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 } - } - 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, sourced from the Phase 1 collection snapshot + # (populated for both dry-run and real runs, unlike the dry-run-only + # "PROFILE:" markers in Protect.Manifest.WiFiExports) + if ($Result.PSObject.Properties.Name -contains 'CollectionSnapshot' -and + $Result.CollectionSnapshot.PSObject.Properties.Name -contains 'WiFiProfiles') { + $found = @($Result.CollectionSnapshot.WiFiProfiles | ForEach-Object Name | Where-Object { $_ }) + 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 } + } + } + + if ($Result.PSObject.Properties.Name -contains 'Protect' -and + $Result.Protect.Manifest -and + $Result.Protect.Manifest.NetworkListBackup) { + Write-Information '' -InformationAction Continue + Write-Information "Network list backup: $($Result.Protect.Manifest.NetworkListBackup)" -InformationAction Continue + } + + if ($Result.PSObject.Properties.Name -contains 'NetworkProfileDecisions' -and + @($Result.NetworkProfileDecisions).Count -gt 0) { + Write-Information '' -InformationAction Continue + Write-Information 'Wi-Fi and LAN/history decisions' -InformationAction Continue + foreach ($decision in @($Result.NetworkProfileDecisions)) { + Write-Information (" - {0} [{1}] {2} | {3}" -f $decision.Decision, $decision.NetworkType, $decision.Name, $decision.Reason) -InformationAction Continue + } + } + + if ($Result.PSObject.Properties.Name -contains 'CandidateArtifacts' -and + @($Result.CandidateArtifacts).Count -gt 0) { + Write-Information '' -InformationAction Continue + Write-Information 'Registry and adapter-path decisions' -InformationAction Continue + foreach ($artifact in @($Result.CandidateArtifacts)) { + $target = if ($artifact.RegistryPath) { $artifact.RegistryPath } elseif ($artifact.Name) { $artifact.Name } else { '(unnamed)' } + Write-Information (" - {0} [{1}] {2} | {3}" -f $artifact.Decision, $artifact.ArtifactType, $target, $artifact.Reason) -InformationAction Continue + } + } + + # 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 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: " + $_) } } - } + } + 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 } } - - 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} + ": " + $_) } } + } +} + +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 } } + } +} - 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 } +<# +.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' } - - # 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 } + else { + Restart-Computer -Force } - - # 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 } + } + 'Shutdown' { + if ($DryRunMode) { + Write-NetCleanLog -Level INFO -Message 'DRYRUN: Stop-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 { + Stop-Computer -Force } + } + 'None' { + Write-NetCleanLog -Level INFO -Message 'No post-run power action selected.' + } + } +} - # Wi-Fi profiles - Remove-WiFiProfilesSafe - - # Reset networking - Reset-Networking - - # NLA probing - Clear-NLAProbing - - # NetworkList cleaning - Safe-RemoveNetworkListProfiles $vmwareGuids $BackupPath +# --------------------------------------------------------------------------- +# Main orchestration +# --------------------------------------------------------------------------- - # 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: " + $_) } } - } - } - } +<# +.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() + + try { + Test-NetCleanAdministrator + } + catch { + Write-Information $_.Exception.Message -InformationAction Continue + return + } + + $selectedMode = $script:Mode + if ($selectedMode -eq 'Menu') { + $selectedMode = Read-NetCleanMenuSelection + if ($selectedMode -eq 'Exit') { + return + } + } - # Event logs - Clear-EventLogs + $selectedPerformanceProfile = $null - # 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 + ": " + $_) } } - } + if ($selectedMode -eq 'PerformanceTune') { + if ($script:PerformanceProfileExplicitlySet) { + $selectedPerformanceProfile = $script:PerformanceProfile + } + else { + $selectedPerformanceProfile = Read-NetCleanPerformanceProfileSelection + } - 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 - } - } + 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 + } + } - Main + if ($script:CreateLog -or $selectedMode -ne 'Menu') { + try { + Start-NetCleanLog -Directory $script:LogPath + } + catch { + Write-Information ("Unable to initialize logging at '{0}': {1}" -f $script:LogPath, $_.Exception.Message) -InformationAction Continue + Write-Information 'Aborting: check that the log path does not already exist as a file, and that you have permission to create directories there.' -InformationAction Continue + return + } + } + + 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)) { + try { + New-Item -Path $script:BackupPath -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + catch { + Write-Information ("Unable to create backup directory '{0}': {1}" -f $script:BackupPath, $_.Exception.Message) -InformationAction Continue + Write-Information 'Aborting: a usable backup directory is required before making any changes.' -InformationAction Continue + return + } + } + + 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..9e2176e --- /dev/null +++ b/tests/Functional/NetClean.Launcher.Functional.Tests.ps1 @@ -0,0 +1,1044 @@ +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 { + $script:Mode = 'SafeConferencePrep' + $script:DryRun = $false + $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 = $null + $script:PerformanceProfileExplicitlySet = $false + $script:RebootNow = $false + + Mock Test-NetCleanAdministrator {} + Mock Show-ModeExplanation {} + Mock Start-NetCleanLog {} + Mock Write-NetCleanLog {} + Mock Invoke-NetCleanWorkflow { + [pscustomobject]@{ + Phase = 'Verify' + Verify = [pscustomobject]@{ + Passed = $true + } + } + } + Mock Show-NetCleanSummary {} + Mock Read-PostRunAction { 'None' } + Mock Invoke-PostRunAction {} + } + + It 'passes the selected mode through to Invoke-NetCleanWorkflow' { + + Invoke-NetCleanLauncher + + Should -Invoke Invoke-NetCleanWorkflow -Times 1 -ParameterFilter { + $Mode -eq 'SafeConferencePrep' + } + + } + + It 'passes Skip switches into workflow' { + $script:SkipWifi = $true + $script:SkipDnsFlush = $true + + Invoke-NetCleanLauncher + + Should -Invoke Invoke-NetCleanWorkflow -Times 1 -ParameterFilter { + $SkipWifi -and $SkipDnsFlush + } + + } + + } + + Context 'Logging initialization' { + + 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 = $null + $script:PerformanceProfileExplicitlySet = $false + $script:RebootNow = $false + + Mock Test-NetCleanAdministrator {} + Mock Show-ModeExplanation {} + Mock Write-NetCleanLog {} + Mock Show-NetCleanSummary {} + Mock Read-PostRunAction { 'None' } + Mock Invoke-PostRunAction {} + + $script:callOrder = [System.Collections.Generic.List[string]]::new() + Mock Start-NetCleanLog { [void]$script:callOrder.Add('Start-NetCleanLog') } + Mock Invoke-NetCleanWorkflow { + [void]$script:callOrder.Add('Invoke-NetCleanWorkflow') + [pscustomobject]@{ + Phase = 'Verify' + Verify = [pscustomobject]@{ + Passed = $true + } + } + } + } + + It 'initializes logging before workflow execution' { + + Invoke-NetCleanLauncher + + $script:callOrder | Should -Be @('Start-NetCleanLog', 'Invoke-NetCleanWorkflow') + } + + } + + 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]@{ + CollectionSnapshot = [pscustomobject]@{ + WiFiProfiles = @([pscustomobject]@{ Name = 'Home'; IsPolicyManaged = $false }) + } + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{ + ProtectedRegistryPathCount = 0 + WiFiBackupCount = 1 + ProtectedRegistryBackupCount = 0 + } + Manifest = [pscustomobject]@{ + WiFiExports = @('C:\backup\Home.xml') + 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 = @() + } + Verify = [pscustomobject]@{ + Summary = [pscustomobject]@{ + Passed = $true + MissingVendorsCount = 0 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ Missing = @() } + RemainingWiFiProfiles = @() + } + } + + Show-NetCleanSummary -Result $result -SelectedMode SafeConferencePrep + + $script:messages | Should -Contain 'Wi-Fi Profiles - Found' + $script:messages | Should -Contain ' - Home' + $script:messages | Should -Contain 'Wi-Fi Profiles - Remaining After Cleanup' + $script:messages | Should -Contain ' - (none)' + } + + It 'reports Wi-Fi profiles found and still remaining on a real (non-dry-run) run, not just dry-run marker strings' { + $result = [pscustomobject]@{ + CollectionSnapshot = [pscustomobject]@{ + WiFiProfiles = @( + [pscustomobject]@{ Name = 'Home'; IsPolicyManaged = $false } + [pscustomobject]@{ Name = 'StubbornSSID'; IsPolicyManaged = $false } + ) + } + Protect = [pscustomobject]@{ + Summary = [pscustomobject]@{ + ProtectedRegistryPathCount = 0 + WiFiBackupCount = 2 + ProtectedRegistryBackupCount = 0 + } + Manifest = [pscustomobject]@{ + WiFiExports = @('C:\backup\Home.xml', 'C:\backup\StubbornSSID.xml') + 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 = @() + } + Verify = [pscustomobject]@{ + Summary = [pscustomobject]@{ + Passed = $false + MissingVendorsCount = 0 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ Missing = @() } + RemainingWiFiProfiles = @('StubbornSSID') + } + } + + Show-NetCleanSummary -Result $result -SelectedMode SafeConferencePrep + + $script:messages | Should -Contain 'Wi-Fi Profiles - Found' + $script:messages | Should -Contain ' - Home' + $script:messages | Should -Contain ' - StubbornSSID' + $script:messages | Should -Contain 'Wi-Fi Profiles - Remaining After Cleanup' + $script:messages | Should -Not -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' + CollectionSnapshot = [pscustomobject]@{ + WiFiProfiles = @( + [pscustomobject]@{ Name = 'Home'; IsPolicyManaged = $false } + [pscustomobject]@{ Name = 'Office'; IsPolicyManaged = $false } + ) + } + Protect = [pscustomobject]@{ + Manifest = [pscustomobject]@{ + WiFiExports = @('C:\backup\Home.xml', 'C:\backup\Office.xml') + NetworkListBackup = 'C:\backup\NetworkList.reg' + } + } + SanitizableArtifacts = @( + [pscustomobject]@{ RegistryPath = 'HKLM\SOFTWARE\History' } + [pscustomobject]@{ RegistryPath = $null } + ) + CandidateArtifacts = @( + [pscustomobject]@{ + ArtifactType = 'TcpipInterface' + RegistryPath = 'HKLM\SYSTEM\Tcpip\Interfaces\{GUID}' + Decision = 'Preserve' + Reason = 'Protected interface used by CrowdStrike' + ProtectionSource = 'CrowdStrike' + } + ) + NetworkProfileDecisions = @( + [pscustomobject]@{ + NetworkType = 'Wi-Fi' + Name = 'Home' + Decision = 'Remove' + Reason = 'Saved user Wi-Fi profile' + } + ) + 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 ' - Remove [Wi-Fi] Home | Saved user Wi-Fi profile' + $script:messages | Should -Contain ' - Preserve [TcpipInterface] HKLM\SYSTEM\Tcpip\Interfaces\{GUID} | Protected interface used by CrowdStrike' + $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 'aborts cleanly instead of an uncaught exception when not running as Administrator' { + # The most common first-run mistake: launching non-elevated. This + # is the very first line of the launcher, before any mode/option + # handling, so it must not crash with a raw stack trace. + Mock Test-NetCleanAdministrator { throw 'NetClean must be run as Administrator.' } + + { Invoke-NetCleanLauncher } | Should -Not -Throw + + Should -Invoke Show-ModeExplanation -Times 0 + Should -Invoke Invoke-NetCleanWorkflow -Times 0 + Should -Invoke Invoke-PostRunAction -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 } + } + + It 'aborts cleanly instead of an uncaught exception when the backup directory cannot be created' { + # Real trigger: -BackupPath resolves to something that already + # exists as a file rather than a directory. + $script:DryRun = $false + $script:BackupPath = Join-Path $TestDrive 'blocked-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 { throw 'New-Item : Cannot create because a file with that name already exists.' } + + { Invoke-NetCleanLauncher } | Should -Not -Throw + + Should -Invoke Invoke-NetCleanWorkflow -Times 0 + Should -Invoke Invoke-PostRunAction -Times 0 + } + + It 'aborts cleanly instead of an uncaught exception when logging cannot be initialized' { + # Real trigger: -LogPath resolves to something that already + # exists as a file rather than a directory. + Mock Start-NetCleanLog { throw 'Unable to create private log directory: C:\blocked-log' } + + { Invoke-NetCleanLauncher } | Should -Not -Throw + + Should -Invoke Invoke-NetCleanWorkflow -Times 0 + Should -Invoke Invoke-PostRunAction -Times 0 + } + + } + + Context 'PerformanceTune direct invocation' { + + BeforeEach { + $script:Mode = 'PerformanceTune' + $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 = 'Optimal' + $script:PerformanceProfileExplicitlySet = $true + $script:RebootNow = $false + + Mock Test-NetCleanAdministrator {} + Mock Start-NetCleanLog {} + Mock Write-NetCleanLog {} + Mock Invoke-NetCleanWorkflow { [pscustomobject]@{ Phase = 'Verify' } } + Mock Show-NetCleanSummary {} + Mock Read-PostRunAction { 'None' } + Mock Invoke-PostRunAction {} + } + + It 'exposes Read-NetCleanPerformanceProfileSelection as a reachable module command' { + Get-Command -Name Read-NetCleanPerformanceProfileSelection -Module NetClean -ErrorAction SilentlyContinue | + Should -Not -BeNullOrEmpty + } + + It 'skips the interactive profile prompt when PerformanceProfile was explicitly supplied, even under Force' { + Mock Read-Host { throw 'Read-Host should not be called when PerformanceProfile was explicitly supplied' } -ModuleName NetClean + + Invoke-NetCleanLauncher + + Should -Invoke Invoke-NetCleanWorkflow -Times 1 -ParameterFilter { + $Mode -eq 'PerformanceTune' -and $PerformanceProfile -eq 'Optimal' + } + } + + It 'still prompts interactively under Force when PerformanceProfile was not explicitly supplied' { + $script:PerformanceProfileExplicitlySet = $false + Mock Read-Host { '2' } -ModuleName NetClean + + Invoke-NetCleanLauncher + + Should -Invoke Invoke-NetCleanWorkflow -Times 1 -ParameterFilter { + $Mode -eq 'PerformanceTune' -and $PerformanceProfile -eq 'Optimal' + } + } + + It 'prompts interactively without Force regardless of whether PerformanceProfile was supplied, and passes the selection through' { + $script:Force = $false + $script:PerformanceProfileExplicitlySet = $false + Mock Read-YesNo { $true } + Mock Read-Host { '3' } -ModuleName NetClean + + Invoke-NetCleanLauncher + + Should -Invoke Invoke-NetCleanWorkflow -Times 1 -ParameterFilter { + $Mode -eq 'PerformanceTune' -and $PerformanceProfile -eq 'Gaming' + } + } + + It 'cancels without invoking the workflow when the interactive prompt is cancelled' { + $script:PerformanceProfileExplicitlySet = $false + Mock Read-Host { '5' } -ModuleName NetClean + + Invoke-NetCleanLauncher + + Should -Invoke Invoke-NetCleanWorkflow -Times 0 + } + + } + + 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 'does not report management for inconsistent Workplace-only evidence' { + $script:messages = [System.Collections.Generic.List[string]]::new() + Mock Write-Information { + if ($null -ne $MessageData) { + [void]$script:messages.Add([string]$MessageData) + } + } + + Show-NetCleanManagementStatus -ManagementState ([pscustomobject]@{ + IsManaged = $true + JoinType = 'WorkplaceRegistered' + WorkplaceJoined = $true + DomainJoined = $false + EntraJoined = $false + EnterpriseJoined = $false + MdmEnrolled = $false + }) + + $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' { + $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]@{ + Verify = [pscustomobject]@{ + Summary = [pscustomobject]@{ + Passed = $true + MissingVendorsCount = 0 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ Missing = @() } + } + }) + + $script:messages | Should -Contain ' Verification passed: True' + } + + It 'prints verification failure message and missing vendor names when workflow fails' { + $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]@{ + Verify = [pscustomobject]@{ + Summary = [pscustomobject]@{ + Passed = $false + MissingVendorsCount = 1 + MissingGuidCount = 0 + MissingServiceCount = 0 + } + VendorComparison = [pscustomobject]@{ Missing = @('CrowdStrike') } + } + }) + + $script:messages | Should -Contain ' Verification passed: False' + $script:messages | Should -Contain ' Missing vendor names: CrowdStrike' + } + + } + +} diff --git a/tests/Functional/NetClean.Workflow.Functional.Tests.ps1 b/tests/Functional/NetClean.Workflow.Functional.Tests.ps1 new file mode 100644 index 0000000..63a2a8f --- /dev/null +++ b/tests/Functional/NetClean.Workflow.Functional.Tests.ps1 @@ -0,0 +1,814 @@ +$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' + } + } + } + } + + # Only returns a normal Clean result when it actually received every + # skip switch; otherwise it throws, so a real end-to-end failure - + # not just a call-argument inspection - proves the forwarding works. + Mock Invoke-NetCleanPhase3Clean { + if (-not ( + $Mode -eq 'SafeConferencePrep' -and + $DryRun -and + $SkipWifi -and + $SkipDnsFlush -and + $SkipEventLogs -and + $SkipUserArtifacts + )) { + throw 'Expected skip switches were not forwarded to 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 ` + -SkipWifi ` + -SkipDnsFlush ` + -SkipEventLogs ` + -SkipUserArtifacts + + $result.Phase | Should -Be 'Verify' + } + } + + 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' + } + } + } + } + + # Only returns a result with AdvancedRepairActions populated when it + # actually received Mode=AdvancedRepair, so the assertion below is a + # real-output check, not just a call-argument inspection. + Mock Invoke-NetCleanPhase3Clean { + if ($Mode -ne 'AdvancedRepair') { + throw 'Expected Mode=AdvancedRepair was not forwarded to 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' + } + } + + 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' + } + } + } + } + + # Only returns PerformanceTuningActions=1 when it actually received + # Mode=PerformanceTune and PerformanceProfile=Optimal, so the + # assertion below is a real-output check, not just a call-argument + # inspection. + Mock Invoke-NetCleanPhase3Clean { + if (-not ($Mode -eq 'PerformanceTune' -and $PerformanceProfile -eq 'Optimal')) { + throw 'Expected Mode/PerformanceProfile were not forwarded to 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..51cf99e --- /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 a trusted PowerShell package source." +} + +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/LocalFixtures/Export-NetCleanRealFixture.ps1 b/tests/LocalFixtures/Export-NetCleanRealFixture.ps1 new file mode 100644 index 0000000..8b4346b --- /dev/null +++ b/tests/LocalFixtures/Export-NetCleanRealFixture.ps1 @@ -0,0 +1,86 @@ +<# +.SYNOPSIS +Captures real registry-derived shapes from this machine into a local, git- +ignored fixture for higher-fidelity regression testing. +.DESCRIPTION +Exports the exact output of NetClean's own snapshot-producing functions +(Get-ServiceRegistrySnapshot, Get-NetworkListProfileSnapshot, +Get-AdapterRegistryCorrelation) plus raw Uninstall-registry entries, to JSON +files under tests/LocalFixtures/data/. That directory is git-ignored - the +captured data never leaves this machine or gets committed. + +Run this, then run NetClean.RealFixture.Tests.ps1 to replay the captured +shapes through Get-ServiceRegistrySnapshot/Get-NetworkListProfileSnapshot/ +Get-AdapterRegistryCorrelation inside an isolated TestRegistry: hive - the +same class of real-world property-shape bug found and fixed this session +(missing DisplayName/Group/LinkageValues/etc.) is exactly what this is meant +to catch going forward, using this machine's actual data instead of hand- +crafted synthetic fixtures. Intentionally scoped to the registry-reading +functions only, not Get-ProtectionEvidence's full evidence collection (that +also queries CIM/WFP/INF/ScheduledTasks/Appx directly against the real +system regardless of any registry fixture, and takes 20-30+ seconds by +design - already covered by this session's live verification separately). +.EXAMPLE +.\tests\LocalFixtures\Export-NetCleanRealFixture.ps1 +#> +[CmdletBinding()] +param( + [string]$OutputPath = (Join-Path $PSScriptRoot 'data') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$manifestPath = Join-Path $PSScriptRoot '..\..\NetClean.psd1' +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "NetClean.psd1 not found at path: $manifestPath" +} + +if (-not (Test-Path -LiteralPath $OutputPath)) { + New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null +} + +Remove-Module NetClean -ErrorAction SilentlyContinue +Import-Module $manifestPath -Force + +Write-Information 'Capturing service registry snapshot...' -InformationAction Continue +$serviceSnapshot = @(& (Get-Module NetClean) { Get-ServiceRegistrySnapshot }) +ConvertTo-Json -InputObject $serviceSnapshot -Depth 6 -AsArray | + Set-Content -LiteralPath (Join-Path $OutputPath 'ServiceRegistrySnapshot.json') -Encoding utf8 +Write-Information " $($serviceSnapshot.Count) services captured." -InformationAction Continue + +Write-Information 'Capturing NetworkList profile snapshot...' -InformationAction Continue +$networkListSnapshot = @(& (Get-Module NetClean) { Get-NetworkListProfileSnapshot }) +ConvertTo-Json -InputObject $networkListSnapshot -Depth 6 -AsArray | + Set-Content -LiteralPath (Join-Path $OutputPath 'NetworkListProfileSnapshot.json') -Encoding utf8 +Write-Information " $($networkListSnapshot.Count) NetworkList profiles captured." -InformationAction Continue + +Write-Information 'Capturing adapter registry correlation...' -InformationAction Continue +$adapterCorrelation = @(& (Get-Module NetClean) { Get-AdapterRegistryCorrelation }) +ConvertTo-Json -InputObject $adapterCorrelation -Depth 6 -AsArray | + Set-Content -LiteralPath (Join-Path $OutputPath 'AdapterRegistryCorrelation.json') -Encoding utf8 +Write-Information " $($adapterCorrelation.Count) adapter class entries captured." -InformationAction Continue + +Write-Information 'Capturing Uninstall registry entries...' -InformationAction Continue +$uninstallEntries = @( + @( + Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue + Get-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue + ) | ForEach-Object { + [pscustomobject]@{ + DisplayName = if ($_.PSObject.Properties.Name -contains 'DisplayName') { $_.DisplayName } else { $null } + DisplayIcon = if ($_.PSObject.Properties.Name -contains 'DisplayIcon') { $_.DisplayIcon } else { $null } + Publisher = if ($_.PSObject.Properties.Name -contains 'Publisher') { $_.Publisher } else { $null } + InstallLocation = if ($_.PSObject.Properties.Name -contains 'InstallLocation') { $_.InstallLocation } else { $null } + UninstallString = if ($_.PSObject.Properties.Name -contains 'UninstallString') { $_.UninstallString } else { $null } + } + } +) +ConvertTo-Json -InputObject $uninstallEntries -Depth 6 -AsArray | + Set-Content -LiteralPath (Join-Path $OutputPath 'UninstallEntries.json') -Encoding utf8 +Write-Information " $($uninstallEntries.Count) Uninstall entries captured." -InformationAction Continue + +Write-Information '' -InformationAction Continue +Write-Information "Fixture captured to: $OutputPath" -InformationAction Continue +Write-Information 'This directory is git-ignored (tests/LocalFixtures/data/) and stays local to this machine.' -InformationAction Continue +Write-Information 'Run tests/LocalFixtures/NetClean.RealFixture.Tests.ps1 to replay it.' -InformationAction Continue diff --git a/tests/LocalFixtures/NetClean.RealFixture.Tests.ps1 b/tests/LocalFixtures/NetClean.RealFixture.Tests.ps1 new file mode 100644 index 0000000..bf0e4de --- /dev/null +++ b/tests/LocalFixtures/NetClean.RealFixture.Tests.ps1 @@ -0,0 +1,182 @@ +<# +.SYNOPSIS +Replays a locally-captured real-machine fixture (see Export-NetCleanRealFixture.ps1) +through NetClean's detection/evidence functions inside an isolated TestRegistry: +hive. +.DESCRIPTION +Never touches the real registry - everything is written to Pester's ephemeral +TestRegistry: drive and redirected there via Set-NetCleanRegistryRootMap, the +same isolation mechanism used by tests/System/NetClean.Registry.System.Tests.ps1. +Skips entirely (not a failure) when no local fixture has been captured yet, +so this file is safe to run on any machine, including CI, without ever +requiring real machine data to exist. + +Runtime note: the TestRegistry: provider has real per-write overhead +(measured ~0.9s per New-ItemProperty call on this machine), so even the +capped sample below (30 services + 30 Uninstall entries + all adapter +entries) takes several minutes end to end - the actual test assertions +run in seconds, the time is all in populating the fixture. This is meant +to be run occasionally as a diagnostic, not as part of the fast day-to-day +test loop; it is intentionally excluded from Run-NetClean-Coverage.ps1's +test discovery for that reason. +#> +$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 real-machine fixture replay' -Tag 'LocalFixture' { + + BeforeAll { + # Pester v5+ separates Discovery from Run; variables and functions + # defined at file top level (Discovery) are not visible in Run-phase + # blocks, so both are (re)established fresh here instead. + # $PSScriptRoot itself is one of the few things Pester preserves + # across that boundary. + $script:FixtureDataPath = Join-Path $PSScriptRoot 'data' + $script:HasFixture = Test-Path -LiteralPath (Join-Path $script:FixtureDataPath 'ServiceRegistrySnapshot.json') + + function Set-FixtureRegistryValue { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)] + [string]$Path, + [Parameter()] + [AllowNull()] + [object]$Values + ) + + if ($null -eq $Values) { return } + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -Force | Out-Null + } + + if (-not $PSCmdlet.ShouldProcess($Path, 'Set fixture registry values')) { return } + + foreach ($prop in $Values.PSObject.Properties) { + $name = $prop.Name + $value = $prop.Value + if ($null -eq $value -or $name -eq 'NetCleanNoRegistryValues') { continue } + + if ($value -is [System.Array]) { + New-ItemProperty -LiteralPath $Path -Name $name -Value ([string[]]$value) -PropertyType MultiString -Force | Out-Null + } + elseif ($value -is [int] -or $value -is [long] -or $value -is [double]) { + $longValue = [long]$value + $propType = if ($longValue -gt [int]::MaxValue -or $longValue -lt [int]::MinValue) { 'QWord' } else { 'DWord' } + New-ItemProperty -LiteralPath $Path -Name $name -Value $longValue -PropertyType $propType -Force | Out-Null + } + else { + New-ItemProperty -LiteralPath $Path -Name $name -Value ([string]$value) -PropertyType String -Force | Out-Null + } + } + } + + # Populated once here (not per-test): rebuilding ~900+ real services' + # worth of registry entries is expensive, and every test below only + # reads this fixture, never mutates it, so one shared build is safe. + if (-not $script:HasFixture) { return } + + Remove-Item -LiteralPath 'TestRegistry:\Machine' -Recurse -Force -ErrorAction SilentlyContinue + New-Item -Path 'TestRegistry:\Machine' -Force | Out-Null + + $isolatedRoot = (Get-PSDrive -Name TestRegistry -ErrorAction Stop).Root + & (Get-Module NetClean) { + param($RootMap) + Set-NetCleanRegistryRootMap -RootMap $RootMap + } @{ HKLM = "$isolatedRoot\Machine" } + + # Capped: the TestRegistry: provider has real per-call overhead, and + # replaying all ~900+ real services (thousands of individual + # New-ItemProperty calls) takes several minutes. A capped sample + # still carries plenty of real-world property-shape diversity - the + # goal is catching "some real services are missing X" bugs, which + # doesn't need every single service, just a representative slice. + $servicesRoot = 'TestRegistry:\Machine\SYSTEM\CurrentControlSet\Services' + $serviceSampleLimit = 30 + foreach ($svc in (Get-Content -LiteralPath (Join-Path $script:FixtureDataPath 'ServiceRegistrySnapshot.json') -Raw | ConvertFrom-Json | Select-Object -First $serviceSampleLimit)) { + $svcPath = Join-Path $servicesRoot $svc.Name + Set-FixtureRegistryValue -Path $svcPath -Values $svc.Values + if ($svc.LinkageValues) { + Set-FixtureRegistryValue -Path (Join-Path $svcPath 'Linkage') -Values $svc.LinkageValues + } + } + + $networkListRoot = 'TestRegistry:\Machine\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles' + foreach ($networkListProfile in (Get-Content -LiteralPath (Join-Path $script:FixtureDataPath 'NetworkListProfileSnapshot.json') -Raw | ConvertFrom-Json)) { + $profilePath = Join-Path $networkListRoot $networkListProfile.ProfileGuid + New-Item -Path $profilePath -Force | Out-Null + New-ItemProperty -LiteralPath $profilePath -Name 'ProfileName' -Value $networkListProfile.Name -PropertyType String -Force | Out-Null + } + + $classRoot = 'TestRegistry:\Machine\SYSTEM\CurrentControlSet\Control\Class\{4d36e972-e325-11ce-bfc1-08002be10318}' + $i = 0 + foreach ($adapter in (Get-Content -LiteralPath (Join-Path $script:FixtureDataPath 'AdapterRegistryCorrelation.json') -Raw | ConvertFrom-Json)) { + $subKey = '{0:D4}' -f $i + $classPath = Join-Path $classRoot $subKey + $values = [pscustomobject]@{ + ComponentId = $adapter.ComponentId + DriverDesc = $adapter.DriverDesc + ProviderName = $adapter.ProviderName + NetCfgInstanceId = "{$($adapter.InterfaceGuid)}" + } + Set-FixtureRegistryValue -Path $classPath -Values $values + $i++ + } + + $uninstallRoot = 'TestRegistry:\Machine\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $j = 0 + foreach ($entry in (Get-Content -LiteralPath (Join-Path $script:FixtureDataPath 'UninstallEntries.json') -Raw | ConvertFrom-Json | Select-Object -First $serviceSampleLimit)) { + $entryPath = Join-Path $uninstallRoot "FixtureEntry$j" + Set-FixtureRegistryValue -Path $entryPath -Values $entry + $j++ + } + } + + AfterAll { + if ($script:HasFixture) { + & (Get-Module NetClean) { Clear-NetCleanRegistryRootMap } + } + } + + It 'has a captured fixture to replay (run Export-NetCleanRealFixture.ps1 first if this is skipped)' { + if (-not $script:HasFixture) { + Set-ItResult -Skipped -Because 'No local fixture captured yet - run tests/LocalFixtures/Export-NetCleanRealFixture.ps1 on a real machine first.' + return + } + + $true | Should -BeTrue + } + + It 'replays the captured service registry snapshot through Get-ServiceRegistrySnapshot without throwing' { + if (-not $script:HasFixture) { + Set-ItResult -Skipped -Because 'No local fixture captured yet.' + return + } + + { $script:Result = @(& (Get-Module NetClean) { Get-ServiceRegistrySnapshot }) } | Should -Not -Throw + $script:Result.Count | Should -BeGreaterThan 0 + } + + It 'replays the captured NetworkList profiles through Get-NetworkListProfileSnapshot without throwing' { + if (-not $script:HasFixture) { + Set-ItResult -Skipped -Because 'No local fixture captured yet.' + return + } + + { $script:Profiles = @(& (Get-Module NetClean) { Get-NetworkListProfileSnapshot }) } | Should -Not -Throw + } + + It 'replays the captured adapter correlation through Get-AdapterRegistryCorrelation without throwing' { + if (-not $script:HasFixture) { + Set-ItResult -Skipped -Because 'No local fixture captured yet.' + return + } + + { $script:Correlation = @(& (Get-Module NetClean) { Get-AdapterRegistryCorrelation }) } | Should -Not -Throw + } +} 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..7447048 --- /dev/null +++ b/tests/Run-NetClean-Coverage.ps1 @@ -0,0 +1,173 @@ +[CmdletBinding()] +param( + [string]$RepoRoot = (Split-Path -Parent $PSScriptRoot), + [string]$OutputPath = (Join-Path $PSScriptRoot 'TestResults'), + [version]$PesterVersion = [version]'6.0.1', + [string[]]$CoveragePath, + # Keep this default in sync with the -MinimumCoverage passed in + # .github/workflows/ci.yml and that workflow's min-coverage-overall. + [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 + Get-ChildItem -Path (Join-Path $testsPath 'Security') -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, tests\System, or tests\Security." +} + +$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/Security/NetClean.Security.System.Tests.ps1 b/tests/Security/NetClean.Security.System.Tests.ps1 new file mode 100644 index 0000000..da435de --- /dev/null +++ b/tests/Security/NetClean.Security.System.Tests.ps1 @@ -0,0 +1,55 @@ +$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 security system-component tests' -Tag 'System', 'Security' { + + InModuleScope 'NetClean' { + + BeforeEach { + $script:LogFile = $null + } + + Context 'Set-NetCleanPrivateDirectoryAcl' { + + It 'restricts a real directory to only current user, Administrators, and SYSTEM with inheritance disabled' { + $dir = Join-Path $TestDrive 'private-backup' + New-Item -Path $dir -ItemType Directory -Force | Out-Null + + Set-NetCleanPrivateDirectoryAcl -Path $dir + + $acl = Get-Acl -LiteralPath $dir + + $acl.AreAccessRulesProtected | Should -BeTrue + + $currentUserSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $expectedSids = @($currentUserSid, 'S-1-5-32-544', 'S-1-5-18') | Select-Object -Unique + + $actualSids = @( + $acl.Access | ForEach-Object { + $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value + } + ) | Select-Object -Unique + + foreach ($sid in $expectedSids) { + $actualSids | Should -Contain $sid + } + + # No principal beyond the expected set was granted any access. + foreach ($sid in $actualSids) { + $expectedSids | Should -Contain $sid + } + + foreach ($rule in $acl.Access) { + $rule.AccessControlType | Should -Be ([System.Security.AccessControl.AccessControlType]::Allow) + $rule.FileSystemRights.ToString() | Should -Match 'FullControl' + } + } + } + } +} diff --git a/tests/Security/NetClean.Security.Unit.Tests.ps1 b/tests/Security/NetClean.Security.Unit.Tests.ps1 new file mode 100644 index 0000000..66cdff5 --- /dev/null +++ b/tests/Security/NetClean.Security.Unit.Tests.ps1 @@ -0,0 +1,197 @@ +$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 security unit tests' -Tag 'Security' { + + InModuleScope 'NetClean' { + + BeforeEach { + $script:LogFile = $null + } + + Context 'Test-NetCleanSafeIdentifier' { + + It 'rejects a value containing an embedded double quote' { + Test-NetCleanSafeIdentifier -Value 'Evil" & calc.exe & "' | Should -BeFalse + } + + It 'rejects a value containing a backtick' { + Test-NetCleanSafeIdentifier -Value 'Evil`nName' | Should -BeFalse + } + + It 'rejects a value containing a semicolon' { + Test-NetCleanSafeIdentifier -Value 'Evil; rd /s /q C:\' | Should -BeFalse + } + + It 'rejects a value containing an ampersand' { + Test-NetCleanSafeIdentifier -Value 'Evil & calc.exe' | Should -BeFalse + } + + It 'rejects a value containing a pipe' { + Test-NetCleanSafeIdentifier -Value 'Evil | calc.exe' | Should -BeFalse + } + + It 'rejects a value containing angle brackets' { + Test-NetCleanSafeIdentifier -Value 'Evil < C:\secrets.txt' | Should -BeFalse + Test-NetCleanSafeIdentifier -Value 'Evil > C:\out.txt' | Should -BeFalse + } + + It 'rejects a value containing an embedded newline or carriage return' { + Test-NetCleanSafeIdentifier -Value "Evil`nName" | Should -BeFalse + Test-NetCleanSafeIdentifier -Value "Evil`rName" | Should -BeFalse + } + + It 'rejects a null or empty value' { + Test-NetCleanSafeIdentifier -Value $null | Should -BeFalse + Test-NetCleanSafeIdentifier -Value '' | Should -BeFalse + } + + It 'accepts ordinary profile names containing spaces, hyphens, and unicode characters' { + Test-NetCleanSafeIdentifier -Value 'Coffee Shop - Guest WiFi' | Should -BeTrue + Test-NetCleanSafeIdentifier -Value 'Café Réseau' | Should -BeTrue + Test-NetCleanSafeIdentifier -Value 'HomeSSID_5G' | Should -BeTrue + } + } + + Context 'Remove-WiFiProfilesSafe command-injection resistance' { + + It 'rejects a malicious Wi-Fi profile name before it reaches any native command' { + Mock Invoke-InParallel {} + Mock Write-NetCleanLog {} + + $malicious = 'Evil" & calc.exe & "' + + $result = Remove-WiFiProfilesSafe -WifiProfiles @($malicious) + + $result.Removed | Should -Be 0 + @($result.Profiles) | Should -Not -Contain $malicious + $result.Operations[0].Skipped | Should -BeTrue + $result.Operations[0].Reason | Should -Be 'UnsafeName' + Should -Invoke Invoke-InParallel -Times 0 + Should -Invoke Write-NetCleanLog -ParameterFilter { + $Level -eq 'WARN' -and $Message -match 'unsafe characters' + } + } + + It 'still removes a legitimately-named sibling profile when a malicious name is present in the same batch' { + Mock Invoke-InParallel { + @($InputObjects | ForEach-Object { + [pscustomobject]@{ Name = $_; Succeeded = $true; Skipped = $false; Reason = 'Removed' } + }) + } + Mock Write-NetCleanLog {} + + $malicious = 'Evil"; rd /s /q C:\ & "' + + $result = Remove-WiFiProfilesSafe -WifiProfiles @($malicious, 'HomeSSID') + + $result.Removed | Should -Be 1 + @($result.Profiles) | Should -Contain 'HomeSSID' + @($result.Profiles) | Should -Not -Contain $malicious + Should -Invoke Invoke-InParallel -Times 1 -Exactly -ParameterFilter { + @($InputObjects).Count -eq 1 -and $InputObjects -contains 'HomeSSID' + } + } + + It 'does not over-block legitimate profile names containing spaces or unicode characters' { + Mock Invoke-InParallel { + @($InputObjects | ForEach-Object { + [pscustomobject]@{ Name = $_; Succeeded = $true; Skipped = $false; Reason = 'Removed' } + }) + } + Mock Write-NetCleanLog {} + + $result = Remove-WiFiProfilesSafe -WifiProfiles @('Coffee Shop - Guest WiFi', 'Café Réseau') + + $result.Removed | Should -Be 2 + @($result.Profiles) | Should -Contain 'Coffee Shop - Guest WiFi' + @($result.Profiles) | Should -Contain 'Café Réseau' + } + } + + Context 'Export-WiFiProfile command-injection resistance' { + + BeforeEach { + Mock New-DirectoryIfNotExist {} + Mock WriteAllLines {} + } + + It 'rejects a malicious Wi-Fi profile name before invoking the per-profile netsh export' { + Mock Write-NetCleanLog {} + Mock Invoke-ExternalCommandSafe { + [pscustomobject]@{ + Name = 'Export Wi-Fi profiles (bulk)' + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + # Bulk export never produces a new file, forcing the per-profile fallback. + Mock Get-ChildItem { @() } + + $malicious = 'Evil" & calc.exe & "' + + $result = @(Export-WiFiProfile -Dest 'C:\backup' -Profiles @($malicious)) + + # Only the list file is produced; no XML export was attempted for the malicious name. + $result.Count | Should -Be 1 + Should -Invoke Invoke-ExternalCommandSafe -Times 1 -Exactly + Should -Invoke Write-NetCleanLog -ParameterFilter { + $Level -eq 'WARN' -and $Message -match 'unsafe characters' + } + } + } + + Context 'Wi-Fi credential logging' { + + BeforeEach { + Mock New-DirectoryIfNotExist {} + Mock WriteAllLines {} + } + + It 'never logs Wi-Fi PSK/key material from an exported profile XML' { + Mock Get-WiFiProfileName { @('HomeSSID') } + + $exportedXmlPath = Join-Path $TestDrive 'Wi-Fi-HomeSSID.xml' + Set-Content -LiteralPath $exportedXmlPath -Value ( + '' + + 'SUPERSECRETPSK' + + '' + ) + + 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 = $exportedXmlPath }) + } + } + + $script:LoggedMessages = [System.Collections.Generic.List[string]]::new() + Mock Write-NetCleanLog { $script:LoggedMessages.Add($Message) } + + $null = Export-WiFiProfile -Dest $TestDrive + + ($script:LoggedMessages -join "`n") | Should -Not -Match 'SUPERSECRETPSK' + } + } + } +} diff --git a/tests/System/NetClean.Registry.System.Tests.ps1 b/tests/System/NetClean.Registry.System.Tests.ps1 new file mode 100644 index 0000000..3e5f2b1 --- /dev/null +++ b/tests/System/NetClean.Registry.System.Tests.ps1 @@ -0,0 +1,291 @@ +$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 + } + + Context 'Test-RegistryPathExist against real registry state' { + + It 'returns true for an existing key' { + Test-RegistryPathExist -RegistryPath "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles\$script:ProfileGuid" | + Should -BeTrue + } + + It 'returns false for a missing key' { + Test-RegistryPathExist -RegistryPath 'HKLM\SOFTWARE\Contoso\DoesNotExist12345' | + Should -BeFalse + } + + It 'returns false for an unsupported registry root without ThrowOnError' { + Test-RegistryPathExist -RegistryPath 'NOTAHIVE\SOFTWARE\Test' | Should -BeFalse + } + + It 'throws for an unsupported registry root with ThrowOnError' { + { Test-RegistryPathExist -RegistryPath 'NOTAHIVE\SOFTWARE\Test' -ThrowOnError } | + Should -Throw + } + } + + Context 'Get-RegistryValuesSafe against real registry state' { + + It 'returns the value bag for an existing key' { + $result = Get-RegistryValuesSafe -RegistryPath "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles\$script:ProfileGuid" + + $result.ProfileName | Should -Be 'ConferenceSSID' + } + + It 'returns a multi-string value with the correct array shape' { + $tcpipPath = "HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{$script:InterfaceGuid}" + New-ItemProperty -LiteralPath (Convert-RegToProviderPath -RegistryPath $tcpipPath) -Name 'NameServer' -Value @('9.9.9.9', '149.112.112.112') -PropertyType MultiString -Force | Out-Null + + $result = Get-RegistryValuesSafe -RegistryPath $tcpipPath + + @($result.NameServer) | Should -Be @('9.9.9.9', '149.112.112.112') + } + + It 'returns null for a missing key' { + Get-RegistryValuesSafe -RegistryPath 'HKLM\SOFTWARE\Contoso\DoesNotExist12345' | Should -BeNullOrEmpty + } + + It 'returns null for an unsupported registry root' { + Get-RegistryValuesSafe -RegistryPath 'NOTAHIVE\SOFTWARE\Test' | Should -BeNullOrEmpty + } + + It 'returns a safely-checkable result for a key that exists but has zero direct values' { + # A container-only key (subkeys but no direct values) is the + # normal shape for many real Windows service registry keys + # (e.g. a service's own key with only a Parameters subkey). + # A raw zero-property PSCustomObject makes any later + # `.PSObject.Properties.Name -contains 'X'` check throw under + # Set-StrictMode -Version Latest, which every caller of this + # function uses to test for a specific value name. + $containerPath = 'HKLM\SOFTWARE\Contoso\ContainerOnlyKey' + New-Item -Path (Convert-RegToProviderPath -RegistryPath $containerPath) -Force | Out-Null + + $result = Get-RegistryValuesSafe -RegistryPath $containerPath + + $null -ne $result | Should -BeTrue + { $result.PSObject.Properties.Name -contains 'ImagePath' } | Should -Not -Throw + $result.PSObject.Properties.Name -contains 'ImagePath' | Should -BeFalse + } + } + + Context 'Get-RegistryChildKeyNamesSafe against real registry state' { + + It 'returns child key names for a key with subkeys' { + $result = @(Get-RegistryChildKeyNamesSafe -RegistryPath 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles') + + $result | Should -Contain $script:ProfileGuid + } + + It 'returns an empty collection for a leaf key with no subkeys' { + $result = @(Get-RegistryChildKeyNamesSafe -RegistryPath "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles\$script:ProfileGuid") + + $result.Count | Should -Be 0 + } + + It 'returns an empty collection for a missing key' { + $result = @(Get-RegistryChildKeyNamesSafe -RegistryPath 'HKLM\SOFTWARE\Contoso\DoesNotExist12345') + + $result.Count | Should -Be 0 + } + + It 'returns an empty collection for an unsupported registry root' { + $result = @(Get-RegistryChildKeyNamesSafe -RegistryPath 'NOTAHIVE\SOFTWARE\Test') + + $result.Count | Should -Be 0 + } + } + + Context 'Get-AdapterRegistryCorrelation against real registry state' { + + It 'correlates an adapter class subkey that has NetCfgInstanceId but lacks ComponentId, DriverDesc, and ProviderName' { + # A real adapter class subkey commonly has only a subset of these + # values populated; ComponentId/DriverDesc/ProviderName being + # absent must not throw. + $classSubPath = "TestRegistry:\Machine\SYSTEM\CurrentControlSet\Control\Class\{4d36e972-e325-11ce-bfc1-08002be10318}\0001" + New-Item -Path $classSubPath -Force | Out-Null + New-ItemProperty -LiteralPath $classSubPath -Name 'NetCfgInstanceId' -Value "{$script:InterfaceGuid}" -PropertyType String -Force | Out-Null + + { $script:AdapterCorrelation = @(Get-AdapterRegistryCorrelation) } | Should -Not -Throw + + $entry = $script:AdapterCorrelation | Where-Object InterfaceGuid -EQ $script:InterfaceGuid + $entry | Should -Not -BeNullOrEmpty + $entry.ComponentId | Should -BeNullOrEmpty + $entry.DriverDesc | Should -BeNullOrEmpty + $entry.ProviderName | Should -BeNullOrEmpty + } + } + + Context 'Get-NetworkListProfileSnapshot against real registry state' { + + It 'skips a NetworkList profile subkey with no ProfileName value, without throwing' { + # A NetworkList profile subkey can exist without a ProfileName + # value (e.g. a transient/incompletely-initialized profile). + $noNamePath = 'TestRegistry:\Machine\SOFTWARE\Microsoft\Windows NT\CurrentVersion\NetworkList\Profiles\{22222222-3333-4444-5555-666666666666}' + New-Item -Path $noNamePath -Force | Out-Null + New-ItemProperty -LiteralPath $noNamePath -Name 'Category' -Value 0 -PropertyType DWord -Force | Out-Null + + { $script:NetworkListSnapshot = @(Get-NetworkListProfileSnapshot) } | Should -Not -Throw + + @($script:NetworkListSnapshot | Where-Object ProfileGuid -EQ '{22222222-3333-4444-5555-666666666666}').Count | Should -Be 0 + $script:NetworkListSnapshot | Where-Object Name -EQ 'ConferenceSSID' | Should -Not -BeNullOrEmpty + } + } + } +} 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..70dcbd2 --- /dev/null +++ b/tests/Unit/NetClean.Core.Unit.Tests.ps1 @@ -0,0 +1,1014 @@ +$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 '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' { + Set-NetCleanPrivateDirectoryAcl -Path $TestDrive + + (Get-Acl -LiteralPath $TestDrive).AreAccessRulesProtected | Should -BeTrue + } + + It 'honors WhatIf without applying an ACL' { + $before = (Get-Acl -LiteralPath $TestDrive).AreAccessRulesProtected + + Set-NetCleanPrivateDirectoryAcl -Path $TestDrive -WhatIf + + (Get-Acl -LiteralPath $TestDrive).AreAccessRulesProtected | Should -Be $before + } + + 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' + + Start-NetCleanLog -Directory $logDir + + (Get-Acl -LiteralPath $logDir).AreAccessRulesProtected | Should -BeTrue + } + + 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' { + $r = Invoke-NetCleanNativeCapture -FilePath 'cmd.exe' -ArgumentList @('/c', 'echo ok') + + $r.Succeeded | Should -BeTrue + $r.ExitCode | Should -Be 0 + $r.Output | Should -Contain 'ok' + $r.Error | Should -BeNullOrEmpty + } + + 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-CachedFileMetadatum' { + + BeforeEach { + Mock Get-NormalizedFilePathFromCommandLine { 'C:\Tools\agent.exe' } + } + + It 'reuses metadata for command lines that resolve to the same file' { + Mock Get-FileMetadatum { + [pscustomobject]@{ Path = 'C:\Tools\agent.exe'; Exists = $true } + } + $cache = @{} + + $first = Get-CachedFileMetadatum -Path '"C:\Tools\agent.exe" --service' -Cache $cache + $second = Get-CachedFileMetadatum -Path 'C:\Tools\agent.exe' -Cache $cache + + $first.Path | Should -Be 'C:\Tools\agent.exe' + $second.Path | Should -Be 'C:\Tools\agent.exe' + Should -Invoke Get-FileMetadatum -Times 1 -Exactly + } + + It 'caches an unsuccessful metadata lookup' { + Mock Get-FileMetadatum { $null } + $cache = @{} + + Get-CachedFileMetadatum -Path 'C:\Tools\agent.exe' -Cache $cache | Should -BeNullOrEmpty + Get-CachedFileMetadatum -Path 'C:\Tools\agent.exe' -Cache $cache | Should -BeNullOrEmpty + + Should -Invoke Get-FileMetadatum -Times 1 -Exactly + } + } + + Context 'Get-ServiceRegistryMap' { + + It 'builds a reusable service snapshot with values, linkage, and child paths' { + Mock Get-RegistryChildKeyNamesSafe { @('ContosoFilter') } + Mock Get-RegistryValuesSafe { + if ($RegistryPath -like '*\Linkage') { + return [pscustomobject]@{ Bind = @('\Device\ContosoFilter') } + } + + [pscustomobject]@{ + ImagePath = 'C:\Contoso\filter.sys' + DisplayName = 'Contoso Filter' + Type = 1 + Start = 2 + Group = 'NDIS' + } + } + Mock Test-RegistryPathExist { $RegistryPath -match '\\(Linkage|Parameters)$' } + + $result = @(Get-ServiceRegistrySnapshot) + + $result.Count | Should -Be 1 + $result[0].Name | Should -Be 'ContosoFilter' + $result[0].Values.ImagePath | Should -Be 'C:\Contoso\filter.sys' + $result[0].LinkageValues.Bind | Should -Contain '\Device\ContosoFilter' + $result[0].LinkagePath | Should -Match '\\Linkage$' + $result[0].ParamsPath | Should -Match '\\Parameters$' + Should -Invoke Get-RegistryChildKeyNamesSafe -Times 1 -Exactly + Should -Invoke Get-RegistryValuesSafe -Times 2 -Exactly + Should -Invoke Test-RegistryPathExist -Times 4 -Exactly + } + + 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 'maps a supplied snapshot without reading the registry again' { + Mock Get-RegistryChildKeyNamesSafe { throw 'registry should not be queried' } + $snapshot = @( + [pscustomobject]@{ + Name = 'ContosoFilter' + RegistryPath = 'HKLM\SYSTEM\CurrentControlSet\Services\ContosoFilter' + ImagePath = 'C:\Contoso\filter.sys' + DisplayName = 'Contoso Filter' + Type = 1 + Start = 2 + Group = 'NDIS' + EnumPath = $null + LinkagePath = 'HKLM\SYSTEM\CurrentControlSet\Services\ContosoFilter\Linkage' + ParamsPath = $null + InstancesPath = $null + } + ) + + $result = Get-ServiceRegistryMap -Snapshot $snapshot + + $result.contosofilter.ImagePath | Should -Be 'C:\Contoso\filter.sys' + Should -Invoke Get-RegistryChildKeyNamesSafe -Times 0 -Exactly + } + + 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..064d370 --- /dev/null +++ b/tests/Unit/NetClean.Coverage.Unit.Tests.ps1 @@ -0,0 +1,71 @@ +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 + $readmePath = Join-Path $PSScriptRoot '..\..\README.md' + $script:readmeText = Get-Content -LiteralPath $readmePath -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 'includes the security test suite in the coverage run' { + $script:runnerText | Should -Match 'Join-Path\s+\$testsPath\s+''Security''' + } + + 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+\$env:COVERAGE_GATE' + $script:ciText | Should -Match 'min-coverage-overall:\s+94' + } + + It 'pins workflow actions and Pages deployment steps' { + $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' + $script:ciText | Should -Match 'actions/upload-pages-artifact@v3' + $script:ciText | Should -Match 'actions/deploy-pages@v4' + } + + It 'exposes the supported badge set in the README' { + $script:readmeText | Should -Match 'img\.shields\.io/endpoint\?url=https://scweeks\.github\.io/netclean/coverage\.json' + $script:readmeText | Should -Match 'img\.shields\.io/badge/style-PSScriptAnalyzer-00aaff' + $script:readmeText | Should -Match 'img\.shields\.io/badge/license-GPLv3-blue\.svg' + $script:readmeText | Should -Match 'img\.shields\.io/badge/PowerShell-7\.4%2B-blue' + $script:readmeText | Should -Match 'img\.shields\.io/badge/platform-Windows-blue' + $script:readmeText | Should -Match 'img\.shields\.io/badge/coverage_target-94%25-green' + } + + It 'does not run a Windows PowerShell 5.1 compatibility job in CI' { + $script:ciText | Should -Not -Match 'windows-powershell-compatibility:' + $script:ciText | Should -Not -Match '(?m)^\s*shell:\s+powershell\s*$' + } + + It 'requires PowerShell 7.4 or later in the module manifest' { + $manifestPath = Join-Path $PSScriptRoot '..\..\NetClean.psd1' + $manifestText = Get-Content -LiteralPath $manifestPath -Raw + $manifestText | Should -Match "PowerShellVersion\s*=\s*'7\.4'" + $manifestText | Should -Match "CompatiblePSEditions\s*=\s*@\('Core'\)" + } +} diff --git a/tests/Unit/NetClean.Phase1.Unit.Tests.ps1 b/tests/Unit/NetClean.Phase1.Unit.Tests.ps1 new file mode 100644 index 0000000..97a1c84 --- /dev/null +++ b/tests/Unit/NetClean.Phase1.Unit.Tests.ps1 @@ -0,0 +1,1350 @@ +$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' + } + + It 'uses a supplied service snapshot without rereading registry values' { + Mock Get-RegistryChildKeyNamesSafe { throw 'registry should not be queried' } + Mock Get-RegistryValuesSafe { throw 'registry should not be queried' } + $snapshot = @( + [pscustomobject]@{ + Name = 'ContosoFilter' + RegistryPath = 'HKLM\SYSTEM\CurrentControlSet\Services\ContosoFilter' + Values = [pscustomobject]@{ + DisplayName = 'Contoso Network Filter' + Group = 'NDIS' + ImagePath = 'C:\Contoso\filter.sys' + } + LinkageValues = [pscustomobject]@{ + Bind = @('\Device\ContosoFilter') + Export = @() + Route = @() + } + } + ) + + $result = @(Get-NdisServiceBindingEvidence -ServiceRegistrySnapshot $snapshot) + + $result.Count | Should -Be 1 + $result[0].Name | Should -Be 'ContosoFilter' + Should -Invoke Get-RegistryChildKeyNamesSafe -Times 0 -Exactly + Should -Invoke Get-RegistryValuesSafe -Times 0 -Exactly + } + + It 'accepts a supplied snapshot entry with no LinkageValues property at all' { + # Get-ProtectionEvidence builds its own internal snapshot with + # only Name/RegistryPath/Values - no LinkageValues field - so + # this function must not assume every caller-supplied entry + # has that property. + Mock Get-RegistryChildKeyNamesSafe { throw 'registry should not be queried' } + Mock Get-RegistryValuesSafe { throw 'registry should not be queried' } + $snapshot = @( + [pscustomobject]@{ + Name = 'ContosoService' + RegistryPath = 'HKLM\SYSTEM\CurrentControlSet\Services\ContosoService' + Values = [pscustomobject]@{ + DisplayName = 'Contoso Service' + ImagePath = 'C:\Program Files\Contoso\service.exe' + } + } + ) + + { $script:NoLinkageResult = @(Get-NdisServiceBindingEvidence -ServiceRegistrySnapshot $snapshot) } | + Should -Not -Throw + + $script:NoLinkageResult.Count | Should -Be 0 + } + } + + 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-SupplementalProtectionEvidence' { + + It 'uses one bounded parallel invocation for balanced read-only collector groups' { + Mock Invoke-InParallel { + foreach ($inputItem in $InputObjects) { + [pscustomobject]@{ Source = $inputItem.Collector; Name = $inputItem.Collector } + } + } + + $result = @(Get-SupplementalProtectionEvidence -ThrottleLimit 2) + + $result.Count | Should -Be 2 + Should -Invoke Invoke-InParallel -Times 1 -Exactly -ParameterFilter { + @($InputObjects).Count -eq 2 -and $ThrottleLimit -eq 2 + } + } + } + + 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 'uses the parallel supplemental collector only when requested' { + Mock Get-SupplementalProtectionEvidence { + @([pscustomobject]@{ Source = 'WFP'; Name = 'ParallelEvidence' }) + } + + $result = @( + Get-ProtectionEvidence ` + -ServiceRegistrySnapshot @() ` + -ParallelSupplementalEvidence + ) + + $result.Count | Should -Be 1 + $result[0].Name | Should -Be 'ParallelEvidence' + Should -Invoke Get-SupplementalProtectionEvidence -Times 1 -Exactly + Should -Invoke Get-WfpStateEvidence -Times 0 -Exactly + Should -Invoke Get-MsiRegistryEvidence -Times 0 -Exactly + } + + It 'falls back to sequential supplemental evidence collection when the parallel collector fails' { + Mock Get-SupplementalProtectionEvidence { throw 'runspace pool unavailable' } + Mock Get-WfpStateEvidence { @([pscustomobject]@{ Source = 'WFP'; Name = 'SequentialWfp' }) } + Mock Get-NdisFilterClassEvidence { @([pscustomobject]@{ Source = 'NdisFilterClass'; Name = 'SequentialNdis' }) } + Mock Get-MsiRegistryEvidence { @([pscustomobject]@{ Source = 'MsiRegistry'; Name = 'SequentialMsi' }) } + Mock Get-InfFileEvidence { @([pscustomobject]@{ Source = 'InfFile'; Name = 'SequentialInf' }) } + Mock Get-ScheduledTaskEvidence { @([pscustomobject]@{ Source = 'ScheduledTask'; Name = 'SequentialTask' }) } + Mock Get-AppxPackageEvidence { @([pscustomobject]@{ Source = 'AppxPackage'; Name = 'SequentialAppx' }) } + + $result = @( + Get-ProtectionEvidence ` + -ServiceRegistrySnapshot @() ` + -ParallelSupplementalEvidence + ) + + @($result | ForEach-Object Name) | Should -Contain 'SequentialWfp' + @($result | ForEach-Object Name) | Should -Contain 'SequentialNdis' + @($result | ForEach-Object Name) | Should -Contain 'SequentialMsi' + @($result | ForEach-Object Name) | Should -Contain 'SequentialInf' + @($result | ForEach-Object Name) | Should -Contain 'SequentialTask' + @($result | ForEach-Object Name) | Should -Contain 'SequentialAppx' + Should -Invoke Get-SupplementalProtectionEvidence -Times 1 -Exactly + Should -Invoke Get-WfpStateEvidence -Times 1 -Exactly + } + + 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 'collects minimal uninstall evidence for a real entry missing DisplayIcon, Publisher, InstallLocation, and UninstallString' { + # A real Uninstall subkey (common for hotfix/update entries) may + # have only DisplayName set - Get-ItemProperty's returned object + # then genuinely lacks the other properties, not just null values. + Mock Get-ItemProperty { + @([pscustomobject]@{ DisplayName = 'Minimal App' }) + } -ParameterFilter { $Path -notlike '*WOW6432Node*' } + + { $script:MinimalUninstallResult = @(Get-ProtectionEvidence | Where-Object Source -EQ 'Uninstall') } | + Should -Not -Throw + + $script:MinimalUninstallResult.Count | Should -Be 1 + $script:MinimalUninstallResult[0].Name | Should -Be 'Minimal App' + $script:MinimalUninstallResult[0].Publisher | Should -BeNullOrEmpty + $script:MinimalUninstallResult[0].InstallPath | Should -BeNullOrEmpty + $script:MinimalUninstallResult[0].UninstallString | Should -BeNullOrEmpty + } + + 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 'collects partial service-registry evidence when a real service is missing Group, DisplayName, Start, and Type' { + # A real service key very commonly has only a subset of these + # values populated (empirically: ~50% of real services lack + # Group, ~9% lack DisplayName, on a real Windows machine). + Mock Get-RegistryChildKeyNamesSafe { @('MinimalService') } + Mock Get-RegistryValuesSafe { + [pscustomobject]@{ + ImagePath = 'C:\Program Files\Contoso\minimal.exe' + } + } + + { $script:MinimalResult = @(Get-ProtectionEvidence | Where-Object Source -EQ 'ServiceRegistry') } | + Should -Not -Throw + + $script:MinimalResult.Count | Should -Be 1 + $script:MinimalResult[0].Name | Should -Be 'MinimalService' + $script:MinimalResult[0].Path | Should -Be 'C:\Program Files\Contoso\minimal.exe' + $script:MinimalResult[0].DisplayName | Should -BeNullOrEmpty + $script:MinimalResult[0].Start | Should -BeNullOrEmpty + $script:MinimalResult[0].Type | Should -BeNullOrEmpty + $script:MinimalResult[0].Group | Should -BeNullOrEmpty + } + + It 'still collects evidence for other services when an earlier service is missing registry values' { + # Regression guard: the whole per-service loop shares a single + # try/catch, so one service missing a property must not + # silently drop every other service's evidence too. + Mock Get-RegistryChildKeyNamesSafe { @('MinimalService', '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' + } + } + else { + [pscustomobject]@{ ImagePath = 'C:\Program Files\Contoso\minimal.exe' } + } + } + + $result = @(Get-ProtectionEvidence | Where-Object Source -EQ 'ServiceRegistry') + + @($result | Where-Object Name -EQ 'MinimalService').Count | Should -Be 1 + @($result | Where-Object Name -EQ 'CSFalconService').Count | Should -Be 1 + } + + It 'inspects a service binary only once across CIM and registry evidence' { + Mock Get-CimInstance { + [pscustomobject]@{ + Name = 'ContosoService' + DisplayName = 'Contoso Service' + PathName = 'C:\Program Files\Contoso\agent.exe' + State = 'Running' + StartMode = 'Auto' + ServiceType = 'Own Process' + } + } -ParameterFilter { $ClassName -eq 'Win32_Service' } + Mock Get-RegistryChildKeyNamesSafe { @('ContosoService') } + Mock Get-RegistryValuesSafe { + [pscustomobject]@{ + ImagePath = '"C:\Program Files\Contoso\agent.exe" --service' + DisplayName = 'Contoso Service' + Start = 2 + Type = 16 + Group = $null + } + } + Mock Get-NormalizedFilePathFromCommandLine { 'C:\Program Files\Contoso\agent.exe' } + Mock Get-FileMetadatum { + [pscustomobject]@{ + Path = 'C:\Program Files\Contoso\agent.exe' + CompanyName = 'Contoso' + FileDescription = 'Contoso Agent' + ProductName = 'Contoso Endpoint' + SignerSubject = 'CN=Contoso' + InferredVendor = 'Contoso' + } + } + + $result = @(Get-ProtectionEvidence) + + @($result | Where-Object Source -In @('Service', 'ServiceRegistry')).Count | Should -Be 2 + Should -Invoke Get-FileMetadatum -Times 1 -Exactly + } + + 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-ServiceRegistrySnapshot { @() } + Mock Get-AdapterRegistryCorrelation { @() } + } + + It 'collects the service registry once and shares that snapshot' { + $script:serviceSnapshot = @( + [pscustomobject]@{ + Name = 'ContosoFilter' + RegistryPath = 'HKLM\SYSTEM\CurrentControlSet\Services\ContosoFilter' + } + ) + Mock Get-ServiceRegistrySnapshot { $script:serviceSnapshot } + Mock Get-ProtectionEvidence { @() } + + $result = @(Get-ProtectionInventory) + + $result.Count | Should -Be 0 + Should -Invoke Get-ServiceRegistrySnapshot -Times 1 -Exactly + Should -Invoke Get-ProtectionEvidence -Times 1 -Exactly -ParameterFilter { + @($ServiceRegistrySnapshot).Count -eq 1 -and + $ServiceRegistrySnapshot[0].Name -eq 'ContosoFilter' + } + Should -Invoke Get-ServiceRegistryMap -Times 1 -Exactly -ParameterFilter { + @($Snapshot).Count -eq 1 -and + $Snapshot[0].Name -eq 'ContosoFilter' + } + } + + 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 + @($result | Where-Object { $_.Decision -notin @('Remove', 'Preserve') }).Count | Should -Be 0 + @($result | Where-Object { [string]::IsNullOrWhiteSpace($_.Reason) }).Count | Should -Be 0 + } + + It 'identifies the vendor protecting each correlated interface path' { + $guid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + Mock Test-RegistryPathExist { $true } + Mock Get-RegistryChildKeyNamesSafe { @($guid) } + $inventory = @( + [pscustomobject]@{ + Vendor = 'CrowdStrike' + ProtectedInterfaceGuids = @($guid) + } + ) + + $result = @( + Get-NetworkPrivacyArtifactCandidate -Inventory $inventory | + Where-Object { $_.InterfaceGuid -eq $guid } + ) + + $result.Count | Should -BeGreaterThan 0 + @($result | Where-Object Decision -NE 'Preserve').Count | Should -Be 0 + @($result | Where-Object { $_.ProtectionSource -notmatch 'CrowdStrike' }).Count | Should -Be 0 + @($result | Where-Object { $_.Reason -notmatch 'CrowdStrike' }).Count | Should -Be 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-NetworkProfileDecision' { + + It 'marks a user Wi-Fi profile as removable with no protection source' { + $result = @( + Get-NetworkProfileDecision -WiFiProfiles @( + [pscustomobject]@{ Name = 'HomeSSID'; IsPolicyManaged = $false } + ) + ) + + $result.Count | Should -Be 1 + $result[0].ArtifactType | Should -Be 'WiFiProfile' + $result[0].NetworkType | Should -Be 'Wi-Fi' + $result[0].Decision | Should -Be 'Remove' + $result[0].IsProtected | Should -BeFalse + $result[0].CanSanitize | Should -BeTrue + $result[0].ProtectionSource | Should -BeNullOrEmpty + $result[0].Reason | Should -Match 'Saved user Wi-Fi profile' + } + + It 'marks a Group Policy Wi-Fi profile as preserved with a protection source' { + $result = @( + Get-NetworkProfileDecision -WiFiProfiles @( + [pscustomobject]@{ Name = 'CorpSSID'; IsPolicyManaged = $true } + ) + ) + + $result.Count | Should -Be 1 + $result[0].Decision | Should -Be 'Preserve' + $result[0].IsProtected | Should -BeTrue + $result[0].CanSanitize | Should -BeFalse + $result[0].ProtectionSource | Should -Be 'Windows Group Policy' + $result[0].Reason | Should -Match 'Group Policy' + } + + It 'skips Wi-Fi profile records without a name' { + $result = @( + Get-NetworkProfileDecision -WiFiProfiles @( + [pscustomobject]@{ Name = $null; IsPolicyManaged = $false } + ) + ) + + $result.Count | Should -Be 0 + } + + It 'labels a NetworkList profile as Wi-Fi history when its name matches a discovered Wi-Fi profile' { + $result = @( + Get-NetworkProfileDecision ` + -WiFiProfiles @([pscustomobject]@{ Name = 'HomeSSID'; IsPolicyManaged = $false }) ` + -NetworkListProfiles @([pscustomobject]@{ Name = 'HomeSSID'; RegistryPath = 'HKLM\...\Profiles\{GUID}'; ProfileGuid = '{GUID}' }) + ) + + $networkListEntry = $result | Where-Object ArtifactType -EQ 'NetworkListProfile' + $networkListEntry.NetworkType | Should -Be 'Wi-Fi history' + $networkListEntry.Decision | Should -Be 'Remove' + $networkListEntry.IsProtected | Should -BeFalse + $networkListEntry.CanSanitize | Should -BeTrue + $networkListEntry.ProfileGuid | Should -Be '{GUID}' + } + + It 'labels a NetworkList profile as LAN/other history when its name has no matching Wi-Fi profile' { + $result = @( + Get-NetworkProfileDecision -NetworkListProfiles @( + [pscustomobject]@{ Name = 'OfficeLAN'; RegistryPath = 'HKLM\...\Profiles\{GUID2}'; ProfileGuid = '{GUID2}' } + ) + ) + + $result.Count | Should -Be 1 + $result[0].NetworkType | Should -Be 'LAN/other history' + $result[0].Decision | Should -Be 'Remove' + } + + It 'skips NetworkList profile records without a name' { + $result = @( + Get-NetworkProfileDecision -NetworkListProfiles @( + [pscustomobject]@{ Name = ''; RegistryPath = 'HKLM\...\Profiles\{GUID3}' } + ) + ) + + $result.Count | Should -Be 0 + } + + It 'returns an empty array when no profiles are supplied' { + $result = @(Get-NetworkProfileDecision) + $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 + } + } + Mock Get-ServiceRegistrySnapshot { @([pscustomobject]@{ Name = 'CSFalconService' }) } + Mock Get-WiFiProfileSnapshot { + @([pscustomobject]@{ Name = 'ConferenceSSID'; IsPolicyManaged = $false }) + } + Mock Get-NetworkListProfileSnapshot { + @([pscustomobject]@{ Name = 'ConferenceSSID'; ProfileGuid = '{PROFILE}' }) + } + Mock Get-NetworkProfileDecision { + @([pscustomobject]@{ Name = 'ConferenceSSID'; Decision = 'Remove'; Reason = 'Saved user Wi-Fi profile' }) + } + } + + 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 + $ctx.CollectionSnapshot.WiFiProfiles[0].Name | Should -Be 'ConferenceSSID' + $ctx.CollectionSnapshot.NetworkListProfiles[0].ProfileGuid | Should -Be '{PROFILE}' + $ctx.NetworkProfileDecisions[0].Decision | Should -Be 'Remove' + Should -Invoke Get-WiFiProfileSnapshot -Times 1 -Exactly + Should -Invoke Get-NetworkListProfileSnapshot -Times 1 -Exactly + } + + 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..abe1edd --- /dev/null +++ b/tests/Unit/NetClean.Phase2.Unit.Tests.ps1 @@ -0,0 +1,846 @@ +$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 'preserves profile names that differ only by case' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Name = 'List Wi-Fi profiles' + ExitCode = 0 + Succeeded = $true + Output = @( + ' All User Profile : x-meh' + ' All User Profile : X-meh' + ' All User Profile : x-meh' + ) + Error = $null + } + } + + $result = @(Get-WiFiProfileName) + + $result.Count | Should -Be 2 + $result | Should -Contain 'x-meh' + $result | Should -Contain 'X-meh' + } + + It 'classifies policy and user profiles in one native capture' { + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Name = 'List Wi-Fi profiles' + ExitCode = 0 + Succeeded = $true + Output = @( + 'Group policy profiles (read only)' + ' Group Policy Profile : SchoolSSID' + 'User profiles' + ' All User Profile : ConferenceSSID' + ) + Error = $null + } + } + + $result = @(Get-WiFiProfileSnapshot) + + $result.Count | Should -Be 2 + ($result | Where-Object Name -EQ 'SchoolSSID').IsPolicyManaged | Should -BeTrue + ($result | Where-Object Name -EQ 'ConferenceSSID').IsPolicyManaged | Should -BeFalse + Should -Invoke Invoke-NetCleanNativeCapture -Times 1 -Exactly + } + + It 'still detects profiles on a non-English Windows install (localized labels, no English "Profile" text)' { + # Real-world risk: netsh's labels and section headers are + # localized, so matching on the English word "Profile" (or + # "Group policy profiles"/"User profiles") silently detects + # zero profiles on non-English Windows. Fail-closed here + # means a profile we can't classify is treated as policy- + # managed (excluded from removal) rather than risking + # removal of something that should have been protected. + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Name = 'List Wi-Fi profiles' + ExitCode = 0 + Succeeded = $true + Output = @( + 'Profils sur l''interface Wi-Fi :' + 'Profils de tous les utilisateurs : HomeSSID' + 'Profils de tous les utilisateurs : OfficeSSID' + ) + Error = $null + } + } + + $result = @(Get-WiFiProfileSnapshot) + + $result.Count | Should -Be 2 + $result | ForEach-Object Name | Should -Contain 'HomeSSID' + $result | ForEach-Object Name | Should -Contain 'OfficeSSID' + @($result | Where-Object IsPolicyManaged).Count | Should -Be 2 + } + + It 'still classifies policy vs. user profiles correctly when English section headers are recognized' { + # Regression guard: the locale-independent fallback above + # must not weaken the existing English-locale classification. + Mock Invoke-NetCleanNativeCapture { + [pscustomobject]@{ + Name = 'List Wi-Fi profiles' + ExitCode = 0 + Succeeded = $true + Output = @( + 'Group policy profiles (read only)' + ' Group Policy Profile : SchoolSSID' + 'User profiles' + ' All User Profile : ConferenceSSID' + ) + Error = $null + } + } + + $result = @(Get-WiFiProfileSnapshot) + + ($result | Where-Object Name -EQ 'SchoolSSID').IsPolicyManaged | Should -BeTrue + ($result | Where-Object Name -EQ 'ConferenceSSID').IsPolicyManaged | Should -BeFalse + } + + 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 'uses supplied profile names without collecting them again' { + Mock Get-WiFiProfileName { throw 'profiles should come from Phase 1' } + + $result = @(Export-WiFiProfile -Dest 'C:\backup' -Profiles @('HomeSSID') -DryRun) + + $result | Should -Contain 'PROFILE:HomeSSID' + Should -Invoke Get-WiFiProfileName -Times 0 -Exactly + } + + 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') } + + # Model the real netsh/filesystem relationship as state (a virtual + # file list that grows only when a per-profile export "succeeds"), + # rather than a hardcoded call-order sequence: the bulk export + # never creates a file (forcing the per-profile fallback), and + # each per-profile export creates exactly its own named file. + # This is robust to how many times or in what order the + # production code happens to call Get-ChildItem. + $script:virtualExportedFiles = [System.Collections.Generic.List[string]]::new() + Mock Invoke-ExternalCommandSafe { + if ($Name -like 'Export Wi-Fi profile *') { + $profileName = $Name -replace '^Export Wi-Fi profile ', '' + [void]$script:virtualExportedFiles.Add("C:\backup\Wi-Fi-$profileName.xml") + } + + [pscustomobject]@{ + Name = $Name + ExitCode = 0 + Succeeded = $true + Error = $null + } + } + + Mock Get-ChildItem { + @($script:virtualExportedFiles | ForEach-Object { [pscustomobject]@{ FullName = $_ } }) + } + + Mock WriteAllLines {} + + $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' + } + } + + It 'writes a valid empty JSON array instead of failing when there are no sanitizable artifacts' { + # ConvertTo-Json piping an empty array produces zero pipeline output (not even "[]"), + # so a downstream Set-Content/WriteAllText call never runs when the array is empty via + # the pipe form. Export-NetCleanJsonArtifact must use -InputObject, not the pipe, so an + # empty result (e.g. an already-cleaned system with nothing left to sanitize) still + # writes valid JSON instead of throwing a parameter-binding error. + Mock Get-SanitizableNetworkArtifact { @() } + Mock WriteAllText {} + + { Export-SanitizableNetworkArtifact -Inventory @() -Dest $TestDrive } | Should -Not -Throw + + Should -Invoke WriteAllText -Times 1 -Exactly -ParameterFilter { + $Contents -eq '[]' + } + } + } + + 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.HasAdapterConfigurationBackup | Should -BeTrue + $result.Protect.Summary.WiFiBackupCount | Should -Be 1 + $result.Protect.Summary.ProtectedRegistryBackupCount | Should -Be 1 + } + + It 'reports no adapter configuration backup when the export produced no path' { + Mock Export-NetCleanAdapterConfiguration { $null } + + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + $result.Protect.Summary.HasAdapterConfigurationBackup | Should -BeFalse + } + + 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 'degrades gracefully instead of aborting when protection-inventory backup fails' { + Mock Export-ProtectionInventory { throw 'disk full' } + + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + $result.Protect.Manifest.ProtectionInventoryJson | Should -BeNullOrEmpty + $result.Phase | Should -Be 'Protect' + } + + It 'degrades gracefully instead of aborting when protection-registry-map backup fails' { + Mock Export-ProtectionRegistryMap { throw 'disk full' } + + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + $result.Protect.Manifest.ProtectionRegistryMapJson | Should -BeNullOrEmpty + $result.Phase | Should -Be 'Protect' + } + + It 'degrades gracefully instead of aborting when sanitizable-artifact backup fails' { + Mock Export-SanitizableNetworkArtifact { throw 'disk full' } + + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + $result.Protect.Manifest.SanitizableArtifactsJson | Should -BeNullOrEmpty + $result.Phase | Should -Be 'Protect' + } + + It 'degrades gracefully instead of aborting when adapter-configuration backup fails' { + Mock Export-NetCleanAdapterConfiguration { throw 'wmi unavailable' } + + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + $result.Protect.Manifest.AdapterConfigurationJson | Should -BeNullOrEmpty + $result.Phase | Should -Be 'Protect' + } + + It 'degrades gracefully instead of aborting when network-list backup fails' { + Mock Export-NetworkList { throw 'disk full' } + + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + $result.Protect.Manifest.NetworkListBackup | Should -BeNullOrEmpty + $result.Phase | Should -Be 'Protect' + } + + It 'degrades gracefully instead of aborting when Wi-Fi profile backup fails' { + Mock Export-WiFiProfile { throw 'netsh unavailable' } + + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + @($result.Protect.Manifest.WiFiExports).Count | Should -Be 0 + $result.Phase | Should -Be 'Protect' + } + + It 'degrades gracefully instead of aborting when protected-registry-key backup fails' { + Mock Export-ProtectedRegistryKey { throw 'reg.exe failed' } + + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' -DryRun + + @($result.Protect.Manifest.ProtectedRegistryBackups).Count | Should -Be 0 + $result.Phase | Should -Be 'Protect' + } + + It 'creates backup directory when not in dry-run mode' { + $result = Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' + + $result.Phase | Should -Be 'Protect' + Should -Invoke New-DirectoryIfNotExist -Times 1 -ParameterFilter { $Path -eq 'C:\backup' } + Should -Invoke Set-NetCleanPrivateDirectoryAcl -Times 1 -ParameterFilter { $Path -eq 'C:\backup' } + } + + It 'fails with a clear, actionable message when the backup directory cannot be created or secured' { + # Real trigger: -BackupPath resolves to something that already + # exists as a file rather than a directory. + Mock Set-NetCleanPrivateDirectoryAcl { throw 'Private data directory does not exist: C:\backup' } + + { Invoke-NetCleanPhase2Protect -Context $script:context -BackupPath 'C:\backup' } | + Should -Throw '*Unable to create or secure the backup directory*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..d7413c5 --- /dev/null +++ b/tests/Unit/NetClean.Phase3.Unit.Tests.ps1 @@ -0,0 +1,1255 @@ +$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 'returns a failed result instead of throwing when adapter discovery fails' { + Mock Get-NetAdapter { throw 'RPC server unavailable' } + + $result = Reset-NetCleanAdapterConfigurationSafe ` + -Context $script:AdapterContext ` + -DryRun + + $result.Succeeded | Should -BeFalse + $result.ConfiguredCount | Should -Be 0 + $result.FailedCount | Should -Be 0 + @($result.Operations).Count | Should -Be 0 + $result.IPv4Preference.Reason | Should -Be 'AdapterDiscoveryFailed' + $result.IPv4Preference.Error | Should -Match 'RPC server unavailable' + } + + 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 + # Also confirms the call site doesn't hard-code -Confirm:$false (a + # previously-fixed outlier that would silently disable the ambient + # $ConfirmPreference for this call while every cleanup sibling honors it). + Should -Invoke Reset-NetCleanAdapterConfigurationSafe -Times 1 -ParameterFilter { + $Context -eq $script:Context -and $DryRun -and $null -eq $Confirm + } + } + + 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') } + + # Only echoes the merged list back when it actually received the + # union of both sources, so the assertions below are a real-output + # check, not just a call-argument inspection. + Mock Remove-WiFiProfilesSafe { + if (@($WifiProfiles).Count -eq 2 -and + $WifiProfiles -contains 'BackedUpSSID' -and + $WifiProfiles -contains 'NewSSID') { + [pscustomobject]@{ Removed = 2; Profiles = @($WifiProfiles); Operations = @() } + } + else { + [pscustomobject]@{ Removed = 0; Profiles = @(); Operations = @() } + } + } + + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun + + @($result.Clean.WiFi.Profiles) | Should -Contain 'BackedUpSSID' + @($result.Clean.WiFi.Profiles) | Should -Contain 'NewSSID' + @($result.Clean.WiFi.Profiles).Count | Should -Be 2 + } + + It 'skips DNS cleanup when SkipDnsFlush is used' { + $result = Invoke-NetCleanPhase3Clean -Context $script:Context -Mode SafeConferencePrep -DryRun -SkipDnsFlush + + $result.Clean.Dns.Skipped | Should -BeTrue + $result.Clean.Dns.Reason | Should -Be 'SkippedByOption' + 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..66cc978 --- /dev/null +++ b/tests/Unit/NetClean.Phase4.Unit.Tests.ps1 @@ -0,0 +1,1517 @@ +$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-NetCleanArtifactRemovalPostState' { + + BeforeEach { + $script:artifactContext = [pscustomobject]@{ + CandidateArtifacts = @() + NetworkProfileDecisions = @() + Clean = [pscustomobject]@{ + DryRun = $false + RegistryArtifacts = [pscustomobject]@{ + Results = @() + } + } + } + } + + It 'is not applicable during a dry run' { + $script:artifactContext.Clean.DryRun = $true + + $result = Test-NetCleanArtifactRemovalPostState -Context $script:artifactContext + + $result.Applicable | Should -BeFalse + $result.Passed | Should -BeTrue + $result.Reason | Should -Be 'DryRun' + } + + It 'is not applicable when no cleanup results exist' { + $script:artifactContext.PSObject.Properties.Remove('Clean') + + $result = Test-NetCleanArtifactRemovalPostState -Context $script:artifactContext + + $result.Applicable | Should -BeFalse + $result.Passed | Should -BeTrue + $result.Reason | Should -Be 'NoCleanupResults' + } + + It 'verifies a Remove-decision registry candidate that Phase 3 confirmed removed' { + $script:artifactContext.CandidateArtifacts = @( + [pscustomobject]@{ ArtifactType = 'NetworkList'; RegistryPath = 'HKLM\SOFTWARE\...\Profiles'; Decision = 'Remove' } + ) + $script:artifactContext.Clean.RegistryArtifacts.Results = @( + [pscustomobject]@{ RegistryPath = 'HKLM\SOFTWARE\...\Profiles'; Removed = $true } + ) + + $result = Test-NetCleanArtifactRemovalPostState -Context $script:artifactContext + + $result.Passed | Should -BeTrue + $result.Checks[0].Verified | Should -BeTrue + } + + It 'flags a Remove-decision registry candidate that Phase 3 did not confirm removed' { + $script:artifactContext.CandidateArtifacts = @( + [pscustomobject]@{ ArtifactType = 'NetworkList'; RegistryPath = 'HKLM\SOFTWARE\...\Profiles'; Decision = 'Remove' } + ) + $script:artifactContext.Clean.RegistryArtifacts.Results = @() + + $result = Test-NetCleanArtifactRemovalPostState -Context $script:artifactContext + + $result.Passed | Should -BeFalse + $result.Checks[0].Verified | Should -BeFalse + } + + It 'verifies a Remove-decision registry candidate whose parent key removal already took it with it (Skipped/NotFound, not Removed)' { + # Deleting a parent registry key recursively removes its child keys too. When Phase 3 + # then tries to individually remove a child candidate, it finds the child already gone + # and records Skipped=true/Reason=NotFound rather than Removed=true - but the removal + # goal (this path is absent) was still genuinely achieved. + $script:artifactContext.CandidateArtifacts = @( + [pscustomobject]@{ ArtifactType = 'NetworkList'; RegistryPath = 'HKLM\SOFTWARE\...\Signatures\Managed'; Decision = 'Remove' } + ) + $script:artifactContext.Clean.RegistryArtifacts.Results = @( + [pscustomobject]@{ RegistryPath = 'HKLM\SOFTWARE\...\Signatures\Managed'; Removed = $false; Skipped = $true; Reason = 'NotFound' } + ) + + $result = Test-NetCleanArtifactRemovalPostState -Context $script:artifactContext + + $result.Passed | Should -BeTrue + $result.Checks[0].Verified | Should -BeTrue + } + + It 'still flags a Preserve-decision registry candidate as unexpectedly removed when Phase 3 recorded it as NotFound' { + # A NotFound skip should only ever count in favor of a Remove decision, never make a + # Preserve decision look correct - if a protected/preserved path is unexpectedly gone, + # that is still a real failure regardless of how Phase 3's ledger explains the absence. + $script:artifactContext.CandidateArtifacts = @( + [pscustomobject]@{ ArtifactType = 'TcpipInterface'; RegistryPath = 'HKLM\SYSTEM\...\Tcpip\{GUID}'; Decision = 'Preserve' } + ) + $script:artifactContext.Clean.RegistryArtifacts.Results = @( + [pscustomobject]@{ RegistryPath = 'HKLM\SYSTEM\...\Tcpip\{GUID}'; Removed = $false; Skipped = $true; Reason = 'NotFound' } + ) + + $result = Test-NetCleanArtifactRemovalPostState -Context $script:artifactContext + + $result.Passed | Should -BeFalse + $result.Checks[0].Verified | Should -BeFalse + } + + It 'verifies a Preserve-decision registry candidate that Phase 3 left untouched' { + $script:artifactContext.CandidateArtifacts = @( + [pscustomobject]@{ ArtifactType = 'TcpipInterface'; RegistryPath = 'HKLM\SYSTEM\...\Tcpip\{GUID}'; Decision = 'Preserve' } + ) + $script:artifactContext.Clean.RegistryArtifacts.Results = @() + + $result = Test-NetCleanArtifactRemovalPostState -Context $script:artifactContext + + $result.Passed | Should -BeTrue + $result.Checks[0].Verified | Should -BeTrue + } + + It 'flags a Preserve-decision registry candidate that unexpectedly appears removed' { + $script:artifactContext.CandidateArtifacts = @( + [pscustomobject]@{ ArtifactType = 'TcpipInterface'; RegistryPath = 'HKLM\SYSTEM\...\Tcpip\{GUID}'; Decision = 'Preserve' } + ) + $script:artifactContext.Clean.RegistryArtifacts.Results = @( + [pscustomobject]@{ RegistryPath = 'HKLM\SYSTEM\...\Tcpip\{GUID}'; Removed = $true } + ) + + $result = Test-NetCleanArtifactRemovalPostState -Context $script:artifactContext + + $result.Passed | Should -BeFalse + $result.Checks[0].Verified | Should -BeFalse + } + + It 'verifies a Remove-decision Wi-Fi profile that no longer remains' { + $script:artifactContext.NetworkProfileDecisions = @( + [pscustomobject]@{ ArtifactType = 'WiFiProfile'; Name = 'HomeSSID'; Decision = 'Remove' } + ) + + $result = Test-NetCleanArtifactRemovalPostState ` + -Context $script:artifactContext ` + -RemainingWiFiProfiles @() + + $result.Passed | Should -BeTrue + $result.Checks[0].Verified | Should -BeTrue + } + + It 'flags a Remove-decision Wi-Fi profile that still remains' { + $script:artifactContext.NetworkProfileDecisions = @( + [pscustomobject]@{ ArtifactType = 'WiFiProfile'; Name = 'HomeSSID'; Decision = 'Remove' } + ) + + $result = Test-NetCleanArtifactRemovalPostState ` + -Context $script:artifactContext ` + -RemainingWiFiProfiles @('HomeSSID') + + $result.Passed | Should -BeFalse + $result.Checks[0].Verified | Should -BeFalse + } + + It 'flags a Preserve-decision Wi-Fi profile that unexpectedly disappeared' { + $script:artifactContext.NetworkProfileDecisions = @( + [pscustomobject]@{ ArtifactType = 'WiFiProfile'; Name = 'CorpWiFi'; Decision = 'Preserve' } + ) + + $result = Test-NetCleanArtifactRemovalPostState ` + -Context $script:artifactContext ` + -RemainingWiFiProfiles @() + + $result.Passed | Should -BeFalse + $result.Checks[0].Verified | Should -BeFalse + } + } + + 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 'reuses the Phase 1 service-registry snapshot and parallel evidence collection when available' { + $script:context | Add-Member -NotePropertyName CollectionSnapshot -NotePropertyValue ([pscustomobject]@{ + ServiceRegistry = @([pscustomobject]@{ Name = 'ContosoAgent' }) + }) + + # Only returns the real post-inventory when the snapshot/parallel flag were + # actually threaded through; otherwise Passed below would go False, so this + # is a real-output check, not just a call-argument inspection. + Mock Get-ProtectionInventory { + if ($ParallelSupplementalEvidence -and + @($ServiceRegistrySnapshot).Count -eq 1 -and + $ServiceRegistrySnapshot[0].Name -eq 'ContosoAgent') { + $script:postInventory + } + else { + @() + } + } + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeTrue + Should -Invoke Get-ProtectionInventory -Times 1 + } + + It 'falls back to a bare Get-ProtectionInventory call when no CollectionSnapshot is available' { + Mock Get-ProtectionInventory { + if (-not $ParallelSupplementalEvidence -and $null -eq $ServiceRegistrySnapshot) { + $script:postInventory + } + else { + @() + } + } + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeTrue + Should -Invoke Get-ProtectionInventory -Times 1 + } + + 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 overall when per-artifact verification finds an unconfirmed removal' { + Mock Test-NetCleanAdapterPostState { [pscustomobject]@{ Applicable = $false; Passed = $true; Reason = 'NoAdapterChanges'; Checks = @() } } + Mock Test-NetCleanCleanupPostState { [pscustomobject]@{ Applicable = $false; Passed = $true; Reason = 'NoCleanupResults'; Checks = @() } } + $script:context | Add-Member -NotePropertyName CandidateArtifacts -NotePropertyValue @( + [pscustomobject]@{ ArtifactType = 'NetworkList'; RegistryPath = 'HKLM\SOFTWARE\...\Profiles'; Decision = 'Remove' } + ) + $script:context | Add-Member -NotePropertyName Clean -NotePropertyValue ([pscustomobject]@{ + DryRun = $false + RegistryArtifacts = [pscustomobject]@{ Results = @() } + }) + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeFalse + $result.ArtifactVerification.Passed | Should -BeFalse + } + + It 'passes overall when per-artifact verification confirms every decision' { + Mock Test-NetCleanAdapterPostState { [pscustomobject]@{ Applicable = $false; Passed = $true; Reason = 'NoAdapterChanges'; Checks = @() } } + Mock Test-NetCleanCleanupPostState { [pscustomobject]@{ Applicable = $false; Passed = $true; Reason = 'NoCleanupResults'; Checks = @() } } + $script:context | Add-Member -NotePropertyName CandidateArtifacts -NotePropertyValue @( + [pscustomobject]@{ ArtifactType = 'NetworkList'; RegistryPath = 'HKLM\SOFTWARE\...\Profiles'; Decision = 'Remove' } + ) + $script:context | Add-Member -NotePropertyName Clean -NotePropertyValue ([pscustomobject]@{ + DryRun = $false + RegistryArtifacts = [pscustomobject]@{ + Results = @([pscustomobject]@{ RegistryPath = 'HKLM\SOFTWARE\...\Profiles'; Removed = $true }) + } + }) + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeTrue + $result.ArtifactVerification.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 'does not fail when the only remaining Wi-Fi profile is one Phase 1 marked Preserve' { + Mock Get-WiFiProfileName { @('CorpWiFi') } + $script:context | Add-Member -NotePropertyName NetworkProfileDecisions -NotePropertyValue @( + [pscustomobject]@{ + ArtifactType = 'WiFiProfile' + Name = 'CorpWiFi' + Decision = 'Preserve' + } + ) + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeTrue + $result.RemainingWiFiProfiles | Should -Contain 'CorpWiFi' + $result.UnexpectedRemainingWiFiProfiles | Should -Not -Contain 'CorpWiFi' + } + + It 'still fails when an unexpected Wi-Fi profile remains alongside a legitimately preserved one' { + Mock Get-WiFiProfileName { @('CorpWiFi', 'HomeSSID') } + $script:context | Add-Member -NotePropertyName NetworkProfileDecisions -NotePropertyValue @( + [pscustomobject]@{ + ArtifactType = 'WiFiProfile' + Name = 'CorpWiFi' + Decision = 'Preserve' + } + [pscustomobject]@{ + ArtifactType = 'WiFiProfile' + Name = 'HomeSSID' + Decision = 'Remove' + } + ) + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeFalse + $result.UnexpectedRemainingWiFiProfiles | Should -Contain 'HomeSSID' + $result.UnexpectedRemainingWiFiProfiles | Should -Not -Contain 'CorpWiFi' + } + + It 'does not fail when the only remaining NetworkList profile is one Phase 1 marked Preserve' { + Mock Get-NetworkListProfileName { @('Corp network') } + $script:context | Add-Member -NotePropertyName NetworkProfileDecisions -NotePropertyValue @( + [pscustomobject]@{ + ArtifactType = 'NetworkListProfile' + Name = 'Corp network' + Decision = 'Preserve' + } + ) + + $result = Test-NetCleanPostState -Context $script:context + + $result.Passed | Should -BeTrue + $result.UnexpectedRemainingNetworkProfiles | Should -Not -Contain 'Corp 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' + } + } + + It 'includes artifact verification details in the report when present' { + Mock New-DirectoryIfNotExist {} + Mock Set-NetCleanPrivateDirectoryAcl {} + Mock WriteAllText {} + + $verification = [pscustomobject]@{ + Passed = $false + VerificationMode = 'Observed' + VendorComparison = [pscustomobject]@{ Missing = @() } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison = [pscustomobject]@{ Missing = @() } + RemainingWiFiProfiles = @() + RemainingNetworkProfiles = @() + AdapterVerification = [pscustomobject]@{ Passed = $true; Checks = @() } + CleanupVerification = [pscustomobject]@{ Passed = $true; Checks = @() } + ArtifactVerification = [pscustomobject]@{ + Passed = $false + Checks = @( + [pscustomobject]@{ + ArtifactType = 'NetworkList' + Name = 'HKLM\SOFTWARE\...\Signatures\Managed' + Decision = 'Remove' + Verified = $false + Detail = 'Not confirmed removed by Phase 3' + } + ) + } + } + + $result = Export-NetCleanVerificationReport ` + -Dest 'C:\backup' ` + -Verification $verification + + Should -Invoke WriteAllText -Times 1 -Exactly -ParameterFilter { + $Path -eq $result -and + $Contents -match 'Not confirmed removed by Phase 3' + } + } + } + + 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 = @() } + } + } + + $result = Invoke-NetCleanPhase4Verify -Context $script:context + + $result.Verify.VerificationReport | Should -Be 'C:\backup\VerificationReport.json' + 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' } + } + + It 'surfaces artifact-decision verification failures in the summary, context, and log' { + Mock Test-NetCleanPostState { + [pscustomobject]@{ + Passed = $false + VerificationMode = 'Observed' + VendorComparison = [pscustomobject]@{ Missing = @() } + GuidComparison = [pscustomobject]@{ Missing = @() } + ServiceComparison = [pscustomobject]@{ Missing = @() } + RemainingWiFiProfiles = @() + RemainingNetworkProfiles = @() + AdapterVerification = [pscustomobject]@{ Passed = $true; Checks = @() } + CleanupVerification = [pscustomobject]@{ Passed = $true; Checks = @() } + ArtifactVerification = [pscustomobject]@{ + Passed = $false + Checks = @( + [pscustomobject]@{ + ArtifactType = 'NetworkList' + Name = 'HKLM\SOFTWARE\...\Signatures\Managed' + Decision = 'Remove' + Verified = $false + Detail = 'Not confirmed removed by Phase 3' + } + ) + } + } + } + + $result = Invoke-NetCleanPhase4Verify -Context $script:context + + $result.Verify.Passed | Should -BeFalse + $result.Verify.ArtifactVerification.Passed | Should -BeFalse + $result.Verify.Summary.ArtifactCheckFailureCount | Should -Be 1 + Should -Invoke Write-NetCleanLog -ParameterFilter { + $Level -eq 'WARN' -and $Message -match 'NetworkList' -and $Message -match 'Not confirmed removed by Phase 3' + } + } + } + } +} 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 + } + } + } +}