diff --git a/CHANGELOG.md b/CHANGELOG.md index e69199f..1708011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.8.0] - 2026-08-29 + +### Added +- `Scripts/Deployment/Apply-Tweaks.ps1`. Optional step that applies a [WinUtil](https://github.com/ChrisTitusTech/winutil) preset (`Standard` by default) after a Y/N prompt, listing what the preset changes before you answer. Runs in its own process so a failure there cannot take down the deployment. +- BitLocker now creates a recovery password protector, saves it to the operator's Documents folder and prints it on screen. Previously only a TPM protector was created, leaving the drive unrecoverable after a TPM clear, mainboard swap or firmware change — while the script told the operator to export a recovery key that never existed. +- BitLocker is opt-in via a Y/N prompt (`-BitLocker Ask|Yes|No`). All other hardening still applies unconditionally. +- `-NonInteractive` switch on `Deploy.ps1` and `Start.ps1`, forwarded from `autounattend.xml`, so the USB path stays zero-touch. +- Hardening extended with LSA protection (RunAsPPL), WDigest plaintext caching disabled, anonymous SAM/share enumeration restricted, SMB client and server signing required, insecure SMB guest logons blocked, LLMNR disabled, memory integrity (HVCI) enabled, SMBv1 feature removed, and 9 Defender Attack Surface Reduction rules. +- `Remove-Bloat.ps1` now implements the "prevents reinstall" its header promised, via `DisableWindowsConsumerFeatures` and related CloudContent/Store policies. + +### Fixed +- `Docs/autounattend.xml` never launched WinDeploy. The first-logon script was generated as `unattend-02.cmd` but contained PowerShell, which `cmd.exe` cannot run. It is now a `.ps1`, and the generator URL in the header comment was corrected to `FirstLogonScriptType1=Ps1` so regenerating reproduces the fix. +- `Harden-Windows.ps1` set `SMB2 = 0` under `LanmanServer\Parameters`, which disables SMB2 and SMB3 and breaks file and printer sharing. Microsoft advises against it. Removed and replaced with SMB signing and guest-logon hardening. +- `Test-IntuneEnrollment` crashed under `Set-StrictMode` when the `Enrollments` key was absent: `Get-ChildItem -ErrorAction SilentlyContinue` returns `$null`, and `$null.Count` throws. +- `Deploy.ps1` crashed under `Set-StrictMode` on the first step, because `$LASTEXITCODE` is undefined until something sets it. It also never reset between steps, so one failing step marked every later step as failed. Now reset to `0` before each step. +- Screen lock settings were written to `HKCU`, which during deployment belongs to the deployment account rather than the end user. Now written to the machine-wide policy hive. `SCRNSAVE.EXE` was also empty, so Windows never started a screen saver and the secure lock never triggered; it now points at `scrnsave.scr`. +- `winget install` was missing `--silent`, so applications could show installer UI mid-deployment. It now also passes `--exact` and `--disable-interactivity`. +- The Office ODT configuration used ``, which installs interactively. Now `None`. +- Windows Updates without a KB number (drivers, definitions) were skipped, because `Install-WindowsUpdate -KB $update.KB` cannot install them. Replaced with a single `Get-WindowsUpdate -Install` pass, which is also considerably faster. +- Seven WinGet font error codes were typed as `-1979335xxx` instead of `-1978335xxx`, so they could never match a real exit code. +- `Install-Drivers.ps1` matched HP with `-like "*hp*"`, which also matches manufacturers such as "Sharp". Now matched as a whole token. +- `Install-Drivers.ps1` installed `HPCMSL` without bootstrapping the NuGet provider or trusting PSGallery, so it prompted and stalled, or failed outright. It now does the same bootstrap `Install-WindowsUpdates.ps1` already did. +- `Install-WindowsUpdates.ps1` threw under `Set-StrictMode` if `wuauserv` could not be found, instead of reporting it. +- `Remove-Bloat.ps1` logged to `%TEMP%\WinDeploy\Logs` while every other script and the README use `C:\WinDeploy\Logs`. +- `Remove-Bloat.ps1` used the `` `e `` escape (PowerShell 6+) in a script that declares `#requires -Version 5.1`, where it prints as literal text. +- The RMM step no longer wraps the installer in a background job that `Remove-Job -Force` could kill. `Install-RMMAgent.ps1` already launches the agent detached, so it runs inline like every other step. +- "Press Enter to exit" prompts now time out after 120 seconds instead of blocking an unattended deployment. + +### Changed +- Deployment scripts log failures with `Write-Warning` instead of `Write-Error`, which printed a full error record with category and stack trace for every non-fatal skip. `Deploy.ps1` already did this. +- `Remove-Bloat.ps1` bloatware list extended with Windows 11 24H2/25H2 in-box apps: Dev Home, the new Outlook, Edge Game Assist, Cross Device (Phone Link), Start Experiences, Meet Now and the Copilot AI provider. + +--- + ## [0.7.3] - 2026-05-01 ### Fixed diff --git a/Docs/autounattend.xml b/Docs/autounattend.xml index 68b7f9a..59cbbb5 100644 --- a/Docs/autounattend.xml +++ b/Docs/autounattend.xml @@ -1,6 +1,6 @@ - + @@ -567,8 +567,8 @@ Set-WallpaperImage -LiteralPath 'C:\Windows\Setup\Scripts\Wallpaper'; Install-Script winget-install -Force winget-install -Force - -iex (irm "https://raw.githubusercontent.com/Stensel8/WinDeploy/$((irm https://api.github.com/repos/Stensel8/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") + +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Stensel8/WinDeploy/$((irm https://api.github.com/repos/Stensel8/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1"))) -NonInteractive $scripts = @( @@ -814,7 +814,7 @@ $scripts = @( & 'C:\Windows\Setup\Scripts\unattend-01.ps1'; }; { - C:\Windows\Setup\Scripts\unattend-02.cmd; + & 'C:\Windows\Setup\Scripts\unattend-02.ps1'; }; { Remove-Item -LiteralPath @( diff --git a/README.md b/README.md index e1649df..ca3127b 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,17 @@ graph TD H --> I[Install RMM Agent] I --> J[Update Drivers] J --> K[Windows Hardening] - K --> L[Install Applications] + K --> K2{Enable BitLocker?} + K2 -->|Y| K3[Encrypt C: + save recovery key] + K2 -->|N / timeout| L + K3 --> L + L[Install Applications] L --> M[Remove Bloatware] - M --> N[Apply Theme] + M --> M2{Run WinUtil tweaks?} + M2 -->|Y| M3[Apply WinUtil preset] + M2 -->|N / timeout| N + M3 --> N + N[Apply Theme] N --> O[Set Hostname] O --> P[Install Windows Updates] P --> Q[Complete] @@ -84,6 +92,17 @@ graph TD `Start.ps1` ensures PowerShell 7 and WinGet are available, handles elevation, and downloads `Deploy.ps1`. `Deploy.ps1` orchestrates the deployment by downloading and executing each script in sequence. +### Interactive steps + +BitLocker (in `Harden-Windows.ps1`) and the WinUtil tweaks (`Apply-Tweaks.ps1`) each ask Y/N before running. Both time out after 90 seconds and default to **No**, so an unattended run never stalls. Everything else is applied automatically. + +```powershell +.\Deploy.ps1 -NonInteractive # no prompts, both skipped +.\Deploy.ps1 -BitLocker Yes -Tweaks Yes # no prompts, both applied +``` + +The `autounattend.xml` USB deployment passes `-NonInteractive` automatically. + --- ## Configuration @@ -118,11 +137,53 @@ Place your agent installer as `Agent.exe` (or any `*agent*.exe`) on the USB driv --- +## Security hardening + +`Harden-Windows.ps1` applies these automatically: + +| Area | Setting | +|---|---| +| Removable media | AutoRun disabled, `autorun.inf` blocked | +| SMB | SMBv1 feature removed, client + server signing required, insecure guest logons blocked | +| Credentials | LSA protection (RunAsPPL), WDigest plaintext caching off, anonymous SAM/share enumeration restricted | +| Network | LLMNR disabled | +| Code integrity | Memory integrity (HVCI) enabled | +| Defender | 9 Attack Surface Reduction rules enabled | +| Other | Device co-installers disabled, Windows Script Host disabled | +| Screen lock | Secure screen saver after 15 minutes, console lock on resume | + +Memory integrity, LSA protection and SMB signing require a restart. Windows Script Host is disabled; a few legacy MSI installers use VBScript custom actions and can fail because of it. + +### BitLocker + +Opt-in, asks Y/N. On yes: `C:` is encrypted with XTS-AES-256 (used space only, TPM-bound), a recovery password is created, written to your Documents folder and printed on screen. + +**Store that key elsewhere and delete the file.** Without it the drive cannot be recovered after a TPM clear, mainboard swap or firmware change. + +```powershell +.\Harden-Windows.ps1 -BitLocker Yes # encrypt without prompting +.\Harden-Windows.ps1 -BitLocker No # skip BitLocker, apply the rest +``` + +--- + +## Optional tweaks (WinUtil) + +`Apply-Tweaks.ps1` runs a [WinUtil](https://github.com/ChrisTitusTech/winutil) preset after a Y/N prompt, in its own process. Standard creates a restore point, then disables activity history, location, telemetry, consumer features, Delivery Optimization and Explorer folder-type auto-discovery, sets non-essential services to manual, and cleans temp files. + +```powershell +.\Apply-Tweaks.ps1 -Tweaks Yes # Standard preset +.\Apply-Tweaks.ps1 -Tweaks Yes -Preset Minimal +.\Apply-Tweaks.ps1 -Tweaks Yes -Preset Advanced # also removes OneDrive, widgets, Windows AI +``` + +--- + ## Logging All operations are logged to `C:\WinDeploy\Logs\`: - `Start.log`. Main entry point log. -- `Install-Drivers.log`, `Install-Applications.log`, etc. Per-script logs. +- `Install-Drivers.log`, `Install-Applications.log`, `Harden-Windows.log`, `Apply-Tweaks.log`, etc. Per-script logs. View logs in real-time: ```powershell diff --git a/Scripts/Deploy.ps1 b/Scripts/Deploy.ps1 index 5b0eb0b..cff7e16 100644 --- a/Scripts/Deploy.ps1 +++ b/Scripts/Deploy.ps1 @@ -1,6 +1,52 @@ +[CmdletBinding()] +param( + # Skips every prompt (BitLocker, WinUtil tweaks, the final "press Enter"). + # Use this for fully unattended runs such as autounattend.xml deployments. + [switch]$NonInteractive, + + # Passed straight through to the steps that ask for confirmation. + [ValidateSet('Ask', 'Yes', 'No')] + [string]$BitLocker = 'Ask', + + [ValidateSet('Ask', 'Yes', 'No')] + [string]$Tweaks = 'Ask' +) + Set-StrictMode -Version Latest $ErrorActionPreference = 'Continue' +if ($NonInteractive) { + if ($BitLocker -eq 'Ask') { $BitLocker = 'No' } + if ($Tweaks -eq 'Ask') { $Tweaks = 'No' } +} + +# Waits for Enter, but never longer than $TimeoutSeconds, so an unattended +# deployment cannot sit on a prompt forever. +function Wait-ForExit { + param([int]$TimeoutSeconds = 120) + + if ($NonInteractive -or -not [Environment]::UserInteractive) { return } + try { if ([Console]::IsInputRedirected) { return } } catch { return } + try { $null = $Host.UI.RawUI.KeyAvailable } catch { return } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastShown = -1 + while ((Get-Date) -lt $deadline) { + $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds) + if ($remaining -ne $lastShown) { + Write-Host ("`rPress Enter to exit (closing automatically in {0}s) " -f $remaining) -NoNewline + $lastShown = $remaining + } + if ($Host.UI.RawUI.KeyAvailable) { + $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + Write-Host "" + return + } + Start-Sleep -Milliseconds 200 + } + Write-Host "" +} + # Check for minimum PowerShell version if ($PSVersionTable.PSVersion.Major -lt 7) { Write-Output "ERROR: This script requires PowerShell 7 or higher." @@ -47,7 +93,7 @@ if (!$releaseTag) { Write-Output " 1. Your internet connection" Write-Output " 2. GitHub API accessibility" Write-Output " 3. Repository has published releases: github.com/Stensel8/WinDeploy/releases" - Read-Host "Press Enter to exit" + Wait-ForExit exit 1 } @@ -193,12 +239,14 @@ function Get-DeploymentScript { } # Define deployment steps (customize as needed) +# Arguments are splatted into the step, so a step can be driven non-interactively. $deploymentSteps = @( @{ Name = "RMM Agent Installation"; ScriptName = "Install-RMMAgent.ps1" } @{ Name = "Driver Installation"; ScriptName = "Install-Drivers.ps1" } - @{ Name = "Windows Hardening"; ScriptName = "Harden-Windows.ps1" } + @{ Name = "Windows Hardening"; ScriptName = "Harden-Windows.ps1"; Arguments = @{ BitLocker = $BitLocker } } @{ Name = "Application Installation"; ScriptName = "Install-Applications.ps1" } @{ Name = "Bloatware Removal"; ScriptName = "Remove-Bloat.ps1" } + @{ Name = "Optional Tweaks (WinUtil)"; ScriptName = "Apply-Tweaks.ps1"; Arguments = @{ Tweaks = $Tweaks } } @{ Name = "Theme Configuration"; ScriptName = "Set-Theme.ps1" } @{ Name = "Hostname Configuration"; ScriptName = "Set-HostName.ps1" } @{ Name = "Windows Updates"; ScriptName = "Install-WindowsUpdates.ps1" } @@ -223,37 +271,17 @@ foreach ($step in $deploymentSteps) { if ($scriptAvailable) { try { - # Special handling for RMM Agent - run async and check indicators - if ($step.ScriptName -eq "Install-RMMAgent.ps1") { - Write-Output "Starting RMM Agent installation (async)..." - $job = Start-Job -ScriptBlock { - & $using:localPath - } + # Reset first: $LASTEXITCODE is undefined until something sets it + # (which throws under Set-StrictMode), and otherwise keeps the + # previous step's value when a step returns without calling exit. + $global:LASTEXITCODE = 0 - # Wait max 30 seconds for job to complete - $timeout = 30 - $elapsed = 0 - while ($job.State -eq 'Running' -and $elapsed -lt $timeout) { - Start-Sleep -Seconds 1 - $elapsed++ - } + $stepArgs = if ($step.ContainsKey('Arguments')) { $step.Arguments } else { @{} } + & $localPath @stepArgs - # Check if job completed - if ($job.State -eq 'Running') { - Write-Output "RMM installation continuing in background..." - Remove-Job $job -Force - } else { - $jobResult = Receive-Job $job - $jobResult | ForEach-Object { Write-Output $_ } - Remove-Job $job - } - } else { - # Normal execution for all other scripts - & $localPath - if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { - Write-Warning "$($step.Name) completed with errors (Exit Code: $LASTEXITCODE)" - $allSuccessful = $false - } + if ($LASTEXITCODE -ne 0) { + Write-Warning "$($step.Name) completed with errors (Exit Code: $LASTEXITCODE)" + $allSuccessful = $false } } catch { Write-Warning "$($step.Name) failed: $_" @@ -302,4 +330,4 @@ if ($allSuccessful) { Write-Output "Some deployment steps failed. Please review the output above." } Write-Output "" -Read-Host "Press Enter to exit" +Wait-ForExit diff --git a/Scripts/Deployment/Apply-Tweaks.ps1 b/Scripts/Deployment/Apply-Tweaks.ps1 new file mode 100644 index 0000000..f0aad77 --- /dev/null +++ b/Scripts/Deployment/Apply-Tweaks.ps1 @@ -0,0 +1,196 @@ +# ============================================================================ +# Apply-Tweaks.ps1 +# Applies a WinUtil (ChrisTitusTech) tweak preset. +# Standalone script - can be deployed via any management tool. +# +# This step downloads and runs a THIRD-PARTY script from christitus.com, so it +# is opt-in: the operator has to confirm with Y before anything runs. +# ============================================================================ + +#requires -Version 5.1 +#requires -RunAsAdministrator + +[CmdletBinding()] +param( + # Ask = prompt the operator (default) + # Yes = run without prompting + # No = skip this step + [ValidateSet('Ask', 'Yes', 'No')] + [string]$Tweaks = 'Ask', + + # WinUtil preset to apply. See Get-PresetSummary below for what each does. + [ValidateSet('Standard', 'Minimal', 'Advanced')] + [string]$Preset = 'Standard', + + # How long the prompt waits for a keypress before falling back to "No". + # Keeps unattended deployments from hanging forever. + [int]$PromptTimeoutSeconds = 90 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' + +$WinUtilUrl = 'https://christitus.com/win' + +Function Write-DeployLog { + param([string]$Message, [switch]$IsError) + $logDir = "C:\WinDeploy\Logs" + if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + $scriptName = if ($PSCommandPath) { [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) } else { "Apply-Tweaks" } + $logFile = Join-Path $logDir "$scriptName.log" + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + "$timestamp - $Message" | Out-File -FilePath $logFile -Append + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } +} + +# Prompts for Y/N with a timeout. Returns $DefaultYes when the session is not +# interactive or nothing is typed in time, so a zero-touch deployment (which +# may run in a hidden window) never blocks. +function Read-YesNoWithTimeout { + param( + [Parameter(Mandatory = $true)][string]$Question, + [int]$TimeoutSeconds = 90, + [switch]$DefaultYes + ) + + $default = [bool]$DefaultYes + $defaultLabel = if ($default) { 'Y' } else { 'N' } + + # Work out whether anyone can actually answer. UserInteractive alone is not + # enough: it is $true for any process in a user session, including one + # started with a redirected stdin or from a scheduled task, where waiting + # out the full timeout would stall the deployment for nothing. + $interactive = [Environment]::UserInteractive + if ($interactive) { + try { if ([Console]::IsInputRedirected) { $interactive = $false } } catch { $interactive = $false } + } + if ($interactive) { + # Hosts without a real console (ISE, some job runners) throw here. + try { $null = $Host.UI.RawUI.KeyAvailable } catch { $interactive = $false } + } + if (-not $interactive) { + Write-Host "$Question [Y/N] -> no interactive console, using default: $defaultLabel" -ForegroundColor Cyan + return $default + } + + # Drain anything already buffered so a stray keypress doesn't answer for us. + try { + while ($Host.UI.RawUI.KeyAvailable) { $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') } + } catch { + Write-Debug "Could not drain the input buffer: $($_.Exception.Message)" + } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastShown = -1 + while ((Get-Date) -lt $deadline) { + $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds) + # Repaint every 5s rather than every second: Start.ps1 runs a transcript, + # and a once-per-second countdown fills the log with redraw lines. + if ($lastShown -lt 0 -or ($lastShown - $remaining) -ge 5) { + Write-Host ("`r{0} [Y/N] (default {1} in {2}s) " -f $Question, $defaultLabel, $remaining) -NoNewline -ForegroundColor Yellow + $lastShown = $remaining + } + if ($Host.UI.RawUI.KeyAvailable) { + $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + if ($key.Character -eq 'y' -or $key.Character -eq 'Y') { Write-Host "`r$Question [Y/N] -> Yes " -ForegroundColor Green; return $true } + if ($key.Character -eq 'n' -or $key.Character -eq 'N') { Write-Host "`r$Question [Y/N] -> No " -ForegroundColor Cyan; return $false } + } + Start-Sleep -Milliseconds 200 + } + + Write-Host ("`r{0} [Y/N] -> timed out, using default: {1} " -f $Question, $defaultLabel) -ForegroundColor Cyan + return $default +} + +# What each preset changes, so the operator can decide before pressing Y. +function Get-PresetSummary { + param([string]$Name) + switch ($Name) { + 'Minimal' { + return @( + "Disable consumer features (stops Windows re-installing bloatware)", + "Disable WPBT (blocks OEM firmware-injected binaries)", + "Set non-essential services to manual start", + "Disable telemetry" + ) + } + 'Advanced' { + return @( + "Everything in Standard, plus:", + "Disable Store search, widgets and Windows AI/Recall", + "Restore the classic Start menu and right-click menu", + "Remove OneDrive" + ) + } + default { + return @( + "Create a system restore point first", + "Disable activity history, location tracking and telemetry", + "Disable consumer features (stops Windows re-installing bloatware)", + "Disable WPBT (blocks OEM firmware-injected binaries)", + "Disable Delivery Optimization peer-to-peer update sharing", + "Set non-essential services to manual start", + "Disable Explorer folder-type auto-discovery", + "Enable 'End task' in the taskbar right-click menu", + "Run disk cleanup and delete temporary files" + ) + } + } +} + +Write-DeployLog "=== WinUtil tweaks ($Preset preset) ===" + +switch ($Tweaks) { + 'Yes' { $runTweaks = $true } + 'No' { $runTweaks = $false } + default { + Write-Output "" + Write-Host "------------------------------------------------------------" -ForegroundColor Cyan + Write-Host " Optional: WinUtil tweaks - '$Preset' preset" -ForegroundColor Yellow + Write-Host "------------------------------------------------------------" -ForegroundColor Cyan + Write-Host " This downloads and runs a third-party script:" -ForegroundColor Gray + Write-Host " $WinUtilUrl (ChrisTitusTech/winutil)" -ForegroundColor Gray + Write-Host "" + Write-Host " The '$Preset' preset will:" -ForegroundColor Gray + foreach ($line in (Get-PresetSummary -Name $Preset)) { + Write-Host " - $line" -ForegroundColor Gray + } + Write-Host "" + Write-Host " Skipping this step leaves the rest of the deployment intact." -ForegroundColor Gray + Write-Host "" + $runTweaks = Read-YesNoWithTimeout -Question " Run WinUtil '$Preset' tweaks now?" -TimeoutSeconds $PromptTimeoutSeconds + Write-Output "" + } +} + +if (-not $runTweaks) { + Write-DeployLog "WinUtil tweaks skipped (not confirmed). Re-run with -Tweaks Yes to apply them later." + exit 0 +} + +try { + Write-DeployLog "Running WinUtil with the '$Preset' preset. This can take several minutes..." + + # Run WinUtil in its own process. It manages its own transcript, runspace + # pool and global state, and calls Stop-Transcript when it finishes - none + # of which should touch the deployment session that called us. + $command = "& ([ScriptBlock]::Create((irm $WinUtilUrl))) -Preset $Preset" + $hostExe = (Get-Process -Id $PID).Path + if ([string]::IsNullOrWhiteSpace($hostExe)) { $hostExe = 'powershell.exe' } + + $proc = Start-Process -FilePath $hostExe ` + -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $command ` + -Wait -PassThru -NoNewWindow + + if ($proc.ExitCode -eq 0) { + Write-DeployLog "SUCCESS: WinUtil '$Preset' preset applied." + } else { + Write-DeployLog "WinUtil exited with code $($proc.ExitCode). Review C:\WinDeploy\Logs and the WinUtil log for details." -IsError + } +} catch { + Write-DeployLog "Failed to run WinUtil: $($_.Exception.Message)" -IsError +} + +Write-Output "" +Write-Output "Note: some WinUtil tweaks (services, Explorer settings) only take effect after a restart." +exit 0 diff --git a/Scripts/Deployment/Harden-Windows.ps1 b/Scripts/Deployment/Harden-Windows.ps1 index fc41606..d138971 100644 --- a/Scripts/Deployment/Harden-Windows.ps1 +++ b/Scripts/Deployment/Harden-Windows.ps1 @@ -2,17 +2,106 @@ # Harden-Windows.ps1 # Applies security hardenings to Windows 11 systems. # Standalone script - can be deployed via any management tool. +# +# All baseline hardenings are applied unconditionally. BitLocker is the one +# exception: it is only enabled after an explicit Y/N confirmation, because +# it produces a recovery key that the operator MUST write down. # ============================================================================ #requires -Version 5.1 #requires -RunAsAdministrator +[CmdletBinding()] +param( + # Ask = prompt the operator (default) + # Yes = enable BitLocker without prompting + # No = skip BitLocker entirely + [ValidateSet('Ask', 'Yes', 'No')] + [string]$BitLocker = 'Ask', + + # How long the BitLocker prompt waits for a keypress before falling back + # to "No". Keeps unattended deployments from hanging forever. + [int]$PromptTimeoutSeconds = 90 +) + Set-StrictMode -Version Latest $ErrorActionPreference = 'Continue' +Function Write-DeployLog { + param([string]$Message, [switch]$IsError) + $logDir = "C:\WinDeploy\Logs" + if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + $scriptName = if ($PSCommandPath) { [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) } else { "Harden-Windows" } + $logFile = Join-Path $logDir "$scriptName.log" + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + "$timestamp - $Message" | Out-File -FilePath $logFile -Append + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } +} + +# Prompts for Y/N with a timeout. Returns $DefaultYes when the session is not +# interactive or nothing is typed in time, so a zero-touch deployment (which +# may run in a hidden window) never blocks. +function Read-YesNoWithTimeout { + param( + [Parameter(Mandatory = $true)][string]$Question, + [int]$TimeoutSeconds = 90, + [switch]$DefaultYes + ) + + $default = [bool]$DefaultYes + $defaultLabel = if ($default) { 'Y' } else { 'N' } + + # Work out whether anyone can actually answer. UserInteractive alone is not + # enough: it is $true for any process in a user session, including one + # started with a redirected stdin or from a scheduled task, where waiting + # out the full timeout would stall the deployment for nothing. + $interactive = [Environment]::UserInteractive + if ($interactive) { + try { if ([Console]::IsInputRedirected) { $interactive = $false } } catch { $interactive = $false } + } + if ($interactive) { + # Hosts without a real console (ISE, some job runners) throw here. + try { $null = $Host.UI.RawUI.KeyAvailable } catch { $interactive = $false } + } + if (-not $interactive) { + Write-Host "$Question [Y/N] -> no interactive console, using default: $defaultLabel" -ForegroundColor Cyan + return $default + } + + # Drain anything already buffered so a stray keypress doesn't answer for us. + try { + while ($Host.UI.RawUI.KeyAvailable) { $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') } + } catch { + Write-Debug "Could not drain the input buffer: $($_.Exception.Message)" + } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastShown = -1 + while ((Get-Date) -lt $deadline) { + $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds) + # Repaint every 5s rather than every second: Start.ps1 runs a transcript, + # and a once-per-second countdown fills the log with redraw lines. + if ($lastShown -lt 0 -or ($lastShown - $remaining) -ge 5) { + Write-Host ("`r{0} [Y/N] (default {1} in {2}s) " -f $Question, $defaultLabel, $remaining) -NoNewline -ForegroundColor Yellow + $lastShown = $remaining + } + if ($Host.UI.RawUI.KeyAvailable) { + $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + if ($key.Character -eq 'y' -or $key.Character -eq 'Y') { Write-Host "`r$Question [Y/N] -> Yes " -ForegroundColor Green; return $true } + if ($key.Character -eq 'n' -or $key.Character -eq 'N') { Write-Host "`r$Question [Y/N] -> No " -ForegroundColor Cyan; return $false } + } + Start-Sleep -Milliseconds 200 + } + + Write-Host ("`r{0} [Y/N] -> timed out, using default: {1} " -f $Question, $defaultLabel) -ForegroundColor Cyan + return $default +} + # Check for Intune enrollment (pre-check) function Test-IntuneEnrollment { - $enrollments = Get-ChildItem -Path 'HKLM:\SOFTWARE\Microsoft\Enrollments' -ErrorAction SilentlyContinue + # -ErrorAction SilentlyContinue yields $null when the key is absent, and + # $null.Count throws under Set-StrictMode. @() normalises both cases. + $enrollments = @(Get-ChildItem -Path 'HKLM:\SOFTWARE\Microsoft\Enrollments' -ErrorAction SilentlyContinue) if ($enrollments.Count -eq 0) { return $false } foreach ($enrollment in $enrollments) { $guid = $enrollment.PSChildName @@ -40,17 +129,6 @@ if ($build -lt 26200) { exit 1 } -Function Write-DeployLog { - param([string]$Message, [switch]$IsError) - $logDir = "C:\WinDeploy\Logs" - if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } - $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) - $logFile = Join-Path $logDir "$scriptName.log" - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - "$timestamp - $Message" | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } -} - #region Configuration $registryConfigs = @( @{ @@ -75,18 +153,37 @@ $registryConfigs = @( Description = "Device co-installers disabled" }, @{ + # SMB1 server. The SMB1 *feature* is removed separately below; this keeps + # the server side off even if something re-adds the feature. + # Note: there is deliberately no "SMB2 = 0" here. That value disables both + # SMB2 and SMB3 - i.e. all remaining SMB - which breaks file and printer + # sharing. Microsoft explicitly advises against it. Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters" Name = "SMB1" Value = 0 Type = "DWord" - Description = "SMBv1 disabled - https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3?tabs=server" + Description = "SMBv1 disabled - https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3" }, @{ Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters" - Name = "SMB2" + Name = "RequireSecuritySignature" + Value = 1 + Type = "DWord" + Description = "SMB server signing required" + }, + @{ + Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters" + Name = "RequireSecuritySignature" + Value = 1 + Type = "DWord" + Description = "SMB client signing required" + }, + @{ + Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters" + Name = "AllowInsecureGuestAuth" Value = 0 Type = "DWord" - Description = "SMBv2 disabled - https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3?tabs=server" + Description = "SMB insecure guest logons blocked" }, @{ Path = "HKLM:\SOFTWARE\Microsoft\Windows Script Host\Settings" @@ -95,6 +192,48 @@ $registryConfigs = @( Type = "DWord" Description = "Windows Script Host disabled" }, + @{ + # Blocks WDigest from caching plaintext credentials in LSASS. + Path = "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest" + Name = "UseLogonCredential" + Value = 0 + Type = "DWord" + Description = "WDigest plaintext credential caching disabled" + }, + @{ + # LSA runs as a Protected Process Light, blocking credential dumpers + # such as Mimikatz from opening the LSASS process. + Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa" + Name = "RunAsPPL" + Value = 1 + Type = "DWord" + Description = "LSA protection (RunAsPPL) enabled" + }, + @{ + Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa" + Name = "RestrictAnonymous" + Value = 1 + Type = "DWord" + Description = "Anonymous SAM/share enumeration restricted" + }, + @{ + # Mitigates LLMNR poisoning (Responder-style credential theft). + # DNS and NetBIOS name resolution are unaffected. + Path = "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient" + Name = "EnableMulticast" + Value = 0 + Type = "DWord" + Description = "LLMNR disabled" + }, + @{ + # Memory integrity (HVCI). Windows 11 enables this by default on + # compatible clean installs; set it explicitly so upgrades match. + Path = "HKLM:\SYSTEM\CurrentControlSet\Control\DeviceGuard\Scenarios\HypervisorEnforcedCodeIntegrity" + Name = "Enabled" + Value = 1 + Type = "DWord" + Description = "Memory integrity (HVCI) enabled" + }, @{ Path = "HKLM:\SOFTWARE\Policies\Microsoft\FVE" Name = "EnableBDE" @@ -112,9 +251,30 @@ $registryConfigs = @( } ) +# Screen lock is enforced through the machine-wide policy hive rather than +# HKCU. During deployment HKCU belongs to the deployment/admin account, not to +# the end user, so HKCU values would silently apply to the wrong profile. +$lockPolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Control Panel\Desktop" $monitorTimeoutMinutes = 10 $standbyTimeoutMinutes = 30 $screenSaverTimeoutSeconds = 900 +# A screen saver executable is required: with ScreenSaveActive=1 but no +# SCRNSAVE.EXE, Windows never starts one and the secure-lock never triggers. +$screenSaverExe = "$env:SystemRoot\System32\scrnsave.scr" + +# Attack Surface Reduction rules (Defender). Conservative set that does not +# interfere with normal business software. +$asrRules = @( + @{ Id = "56a863a9-875e-4185-98a7-b882c64b5ce5"; Description = "ASR: block abuse of vulnerable signed drivers" } + @{ Id = "d4f940ab-401b-4efc-aadc-ad5f3c50688a"; Description = "ASR: block Office apps creating child processes" } + @{ Id = "9e6c4e1f-7d60-472f-ba1a-a39ef669e4b2"; Description = "ASR: block credential stealing from LSASS" } + @{ Id = "be9ba2d9-53ea-4cdc-84e5-9b1eeee46550"; Description = "ASR: block executable content from email/webmail" } + @{ Id = "d3e037e1-3eb8-44c8-a917-57927947596d"; Description = "ASR: block JS/VBS launching downloaded executables" } + @{ Id = "5beb7efe-fd9a-4556-801d-275e5ffc04cc"; Description = "ASR: block obfuscated scripts" } + @{ Id = "92e97fa1-2edf-4476-bdd6-9dd0b4dddc7b"; Description = "ASR: block Office apps creating executable content" } + @{ Id = "01443614-cd74-433a-b99e-2ecdc07bfc25"; Description = "ASR: block executables unless prevalent/aged/trusted" } + @{ Id = "c1db55ab-c21a-4637-bb3f-a12568109d35"; Description = "ASR: use advanced ransomware protection" } +) #endregion Write-DeployLog "Starting Windows hardening process..." @@ -142,39 +302,150 @@ foreach ($config in $registryConfigs) { } } -# Enable BitLocker +# Remove the SMB1 client/server feature outright (the registry value above only +# covers the server side and only while the feature is still installed). try { - $tpm = Get-Tpm -ErrorAction Stop - - if (-not $tpm.TpmPresent) { - throw "TPM not present" + $smb1 = Get-WindowsOptionalFeature -Online -FeatureName 'SMB1Protocol' -ErrorAction Stop + if ($smb1.State -eq 'Enabled') { + Disable-WindowsOptionalFeature -Online -FeatureName 'SMB1Protocol' -NoRestart -ErrorAction Stop | Out-Null + $appliedConfigs += "SMBv1 feature removed" + } else { + $appliedConfigs += "SMBv1 feature already absent" } - if (-not $tpm.TpmEnabled) { - throw "TPM not enabled" +} catch { + Write-DeployLog "SMBv1 feature removal skipped: $($_.Exception.Message)" -IsError +} + +# Attack Surface Reduction rules +try { + $null = Get-Command Add-MpPreference -ErrorAction Stop + $asrApplied = 0 + foreach ($rule in $asrRules) { + try { + Add-MpPreference -AttackSurfaceReductionRules_Ids $rule.Id -AttackSurfaceReductionRules_Actions Enabled -ErrorAction Stop + $asrApplied++ + } catch { + Write-DeployLog "Failed: $($rule.Description) - $($_.Exception.Message)" -IsError + } } - if (-not $tpm.TpmActivated) { - throw "TPM not activated" + if ($asrApplied -gt 0) { + $appliedConfigs += "Defender ASR rules enabled ($asrApplied of $($asrRules.Count))" } +} catch { + Write-DeployLog "Defender ASR rules skipped: Defender cmdlets unavailable ($($_.Exception.Message))" -IsError + $failedConfigs += "Defender ASR rules" +} + +#region BitLocker +# BitLocker is opt-in: it generates a recovery key that must be written down +# before the machine leaves the bench. Everything above this point is applied +# unconditionally. +$recoveryPassword = $null +$recoveryKeyFile = $null - if (-not $tpm.TpmOwned) { - Write-DeployLog "Initializing TPM ownership..." - Initialize-Tpm -AllowClear -AllowPhysicalPresence -ErrorAction Stop +switch ($BitLocker) { + 'Yes' { $enableBitLocker = $true } + 'No' { $enableBitLocker = $false } + default { + Write-Output "" + Write-Host "------------------------------------------------------------" -ForegroundColor Cyan + Write-Host " BitLocker drive encryption" -ForegroundColor Yellow + Write-Host "------------------------------------------------------------" -ForegroundColor Cyan + Write-Host " Encrypts C: with XTS-AES-256 using the TPM." -ForegroundColor Gray # DevSkim: ignore DS187371 - XTS is the recommended BitLocker mode, not a weak one + Write-Host " A 48-digit recovery key will be generated and saved to your" -ForegroundColor Gray + Write-Host " Documents folder. You MUST store that key somewhere safe -" -ForegroundColor Gray + Write-Host " without it the drive cannot be recovered if the TPM, the" -ForegroundColor Gray + Write-Host " motherboard or the firmware configuration changes." -ForegroundColor Gray + Write-Host "" + $enableBitLocker = Read-YesNoWithTimeout -Question " Enable BitLocker on C: now?" -TimeoutSeconds $PromptTimeoutSeconds + Write-Output "" } +} - $bitLockerStatus = Get-BitLockerVolume -MountPoint "C:" -ErrorAction Stop - if ($bitLockerStatus.ProtectionStatus -eq "Off") { - Enable-BitLocker -MountPoint "C:" -TpmProtector -EncryptionMethod XtsAes256 -UsedSpaceOnly -ErrorAction Stop - $appliedConfigs += "BitLocker encryption started" - Write-Output "WARNING: BitLocker will be enabled after the next reboot. Make sure to export your BitLocker recovery key!" - } else { - $appliedConfigs += "BitLocker already active" +if (-not $enableBitLocker) { + Write-DeployLog "BitLocker skipped (not confirmed). Run this script again with -BitLocker Yes to enable it later." + $appliedConfigs += "BitLocker skipped by operator choice" +} else { + try { + $tpm = Get-Tpm -ErrorAction Stop + + if (-not $tpm.TpmPresent) { throw "TPM not present" } + if (-not $tpm.TpmEnabled) { throw "TPM not enabled" } + if (-not $tpm.TpmActivated) { throw "TPM not activated" } + + if (-not $tpm.TpmOwned) { + # Deliberately no -AllowClear: clearing the TPM destroys any key + # material already sealed to it. If ownership cannot be taken + # without a clear, that is an operator decision, not ours. + Write-DeployLog "Initializing TPM ownership..." + Initialize-Tpm -ErrorAction Stop | Out-Null + } + + $bitLockerStatus = Get-BitLockerVolume -MountPoint "C:" -ErrorAction Stop + if ($bitLockerStatus.ProtectionStatus -eq 'Off') { + Enable-BitLocker -MountPoint "C:" -TpmProtector -EncryptionMethod XtsAes256 -UsedSpaceOnly -SkipHardwareTest -ErrorAction Stop | Out-Null + $appliedConfigs += "BitLocker encryption started (XTS-AES-256)" # DevSkim: ignore DS187371 - XTS is the recommended BitLocker mode, not a weak one + } else { + $appliedConfigs += "BitLocker already active" + } + + # A TPM protector alone is not recoverable. Make sure a recovery + # password exists, then hand it to the operator. + $bitLockerStatus = Get-BitLockerVolume -MountPoint "C:" -ErrorAction Stop + $existing = @($bitLockerStatus.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' }) + if ($existing.Count -eq 0) { + Add-BitLockerKeyProtector -MountPoint "C:" -RecoveryPasswordProtector -ErrorAction Stop | Out-Null + $bitLockerStatus = Get-BitLockerVolume -MountPoint "C:" -ErrorAction Stop + $existing = @($bitLockerStatus.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' }) + } + + if ($existing.Count -gt 0) { + $recoveryPassword = $existing[0].RecoveryPassword + $recoveryId = $existing[0].KeyProtectorId + $appliedConfigs += "BitLocker recovery password created" + + # Save next to the operator, in Documents. Fall back to the + # WinDeploy folder when there is no profile (e.g. SYSTEM). + $documents = [Environment]::GetFolderPath('MyDocuments') + if ([string]::IsNullOrWhiteSpace($documents) -or -not (Test-Path $documents)) { + $documents = "C:\WinDeploy" + if (!(Test-Path $documents)) { New-Item -ItemType Directory -Path $documents -Force | Out-Null } + } + $recoveryKeyFile = Join-Path $documents ("BitLocker-Recovery-Key_{0}_{1}.txt" -f $env:COMPUTERNAME, (Get-Date -Format 'yyyy-MM-dd_HHmmss')) + + $keyFileContent = @" +BitLocker recovery key +====================== + +Computer : $env:COMPUTERNAME +Drive : C: +Created : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') +Identifier : $recoveryId + +Recovery key : $recoveryPassword + +KEEP THIS KEY SAFE. +Without it the contents of this drive cannot be recovered if the TPM is +cleared or fails, the motherboard is replaced, or the firmware/boot +configuration changes. + +Store it in your password manager or another secure location that is NOT +on this machine, then delete this file. +"@ + $keyFileContent | Out-File -FilePath $recoveryKeyFile -Encoding UTF8 -Force + Write-DeployLog "BitLocker recovery key written to $recoveryKeyFile" + } else { + Write-DeployLog "BitLocker enabled but no recovery password could be read back." -IsError + $failedConfigs += "BitLocker recovery password" + } + } catch { + Write-DeployLog "BitLocker skipped: $($_.Exception.Message)" -IsError + $failedConfigs += "BitLocker" } -} catch { - Write-DeployLog "BitLocker skipped: $($_.Exception.Message)" -IsError - $failedConfigs += "BitLocker" } +#endregion -# Configure power settings +# Configure power settings and screen lock try { & powercfg /change monitor-timeout-ac $monitorTimeoutMinutes 2>&1 | Out-Null & powercfg /change monitor-timeout-dc $monitorTimeoutMinutes 2>&1 | Out-Null @@ -184,30 +455,41 @@ try { & powercfg /setdcvalueindex SCHEME_CURRENT SUB_NONE CONSOLELOCK 1 2>&1 | Out-Null & powercfg /setactive SCHEME_CURRENT 2>&1 | Out-Null - Set-ItemProperty -Path "HKCU:\Control Panel\Desktop" -Name "ScreenSaverIsSecure" -Value "1" -ErrorAction Stop - Set-ItemProperty -Path "HKCU:\Control Panel\Desktop" -Name "ScreenSaveTimeOut" -Value "$screenSaverTimeoutSeconds" -ErrorAction Stop - Set-ItemProperty -Path "HKCU:\Control Panel\Desktop" -Name "ScreenSaveActive" -Value "1" -ErrorAction Stop - Set-ItemProperty -Path "HKCU:\Control Panel\Desktop" -Name "SCRNSAVE.EXE" -Value "" -ErrorAction Stop + if (!(Test-Path $lockPolicyPath)) { New-Item -Path $lockPolicyPath -Force -ErrorAction Stop | Out-Null } + Set-ItemProperty -Path $lockPolicyPath -Name "ScreenSaveActive" -Value "1" -Type String -ErrorAction Stop + Set-ItemProperty -Path $lockPolicyPath -Name "ScreenSaverIsSecure" -Value "1" -Type String -ErrorAction Stop + Set-ItemProperty -Path $lockPolicyPath -Name "ScreenSaveTimeOut" -Value "$screenSaverTimeoutSeconds" -Type String -ErrorAction Stop + if (Test-Path $screenSaverExe) { + Set-ItemProperty -Path $lockPolicyPath -Name "SCRNSAVE.EXE" -Value $screenSaverExe -Type String -ErrorAction Stop + } else { + Write-DeployLog "Screen saver executable not found at $screenSaverExe - lock-on-timeout may not trigger." + } - $appliedConfigs += "Power/lock settings configured" + $appliedConfigs += "Power settings configured" + $appliedConfigs += "Screen lock after $([int]($screenSaverTimeoutSeconds / 60)) minutes (machine policy)" } catch { Write-DeployLog "Power settings failed: $($_.Exception.Message)" -IsError $failedConfigs += "Power settings" } -# Verification +# Verification - read a few settings back rather than trusting the writes. $verifications = @( @{Path = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer"; Name = "NoDriveTypeAutoRun"; Expected = 255} + @{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa"; Name = "RunAsPPL"; Expected = 1} + @{Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters"; Name = "AllowInsecureGuestAuth"; Expected = 0} + @{Path = $lockPolicyPath; Name = "ScreenSaverIsSecure"; Expected = "1"} ) $verifyFailed = $false foreach ($verify in $verifications) { try { $value = (Get-ItemProperty -Path $verify.Path -Name $verify.Name -ErrorAction Stop).$($verify.Name) - if ($value -ne $verify.Expected) { + if ("$value" -ne "$($verify.Expected)") { + Write-DeployLog "Verification mismatch: $($verify.Name) is '$value', expected '$($verify.Expected)'" -IsError $verifyFailed = $true } } catch { + Write-DeployLog "Verification failed to read $($verify.Name): $($_.Exception.Message)" -IsError $verifyFailed = $true } } @@ -217,11 +499,22 @@ $hardeningLinks = @{ "AutoRun disabled" = "https://en.wikipedia.org/wiki/AutoRun" "Autorun.inf blocked" = "https://en.wikipedia.org/wiki/AutoRun" "Device co-installers disabled" = "https://learn.microsoft.com/en-us/previous-versions/windows/drivers/install/co-installer-functionality" - "SMBv1 disabled" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3?tabs=server" + "SMBv1 disabled" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3" + "SMBv1 feature removed" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/detect-enable-and-disable-smbv1-v2-v3" + "SMB server signing required" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/smb-signing" + "SMB client signing required" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/smb-signing" + "SMB insecure guest logons blocked" = "https://learn.microsoft.com/en-us/windows-server/storage/file-server/troubleshoot/guest-access-in-smb2-is-disabled-by-default" "Windows Script Host disabled" = "https://en.wikipedia.org/wiki/Windows_Script_Host" + "WDigest plaintext credential caching disabled" = "https://learn.microsoft.com/en-us/troubleshoot/windows-server/windows-security/wdigest-authentication-disabled" + "LSA protection (RunAsPPL) enabled" = "https://learn.microsoft.com/en-us/windows-server/security/credentials-protection-and-management/configuring-additional-lsa-protection" + "Anonymous SAM/share enumeration restricted" = "https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/network-access-do-not-allow-anonymous-enumeration-of-sam-accounts-and-shares" + "LLMNR disabled" = "https://learn.microsoft.com/en-us/windows-server/networking/dns/what-s-new-in-dns-client" + "Memory integrity (HVCI) enabled" = "https://learn.microsoft.com/en-us/windows/security/hardware-security/enable-virtualization-based-protection-of-code-integrity" "BitLocker policy enabled" = "https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/" "BitLocker already active" = "https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/" - "Power/lock settings configured" = "https://learn.microsoft.com/en-us/windows/win32/power/power-management-portal" + "BitLocker encryption started (XTS-AES-256)" = "https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/" # DevSkim: ignore DS187371 - XTS is the recommended BitLocker mode, not a weak one + "BitLocker recovery password created" = "https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/bitlocker-recovery-overview" + "Power settings configured" = "https://learn.microsoft.com/en-us/windows/win32/power/power-management-portal" } # Summary output @@ -240,8 +533,31 @@ if ($failedConfigs.Count -gt 0) { } } +# Show the recovery key last, so it is the final thing on screen. +if ($recoveryPassword) { + Write-Host "" + Write-Host "############################################################" -ForegroundColor Red + Write-Host "# BITLOCKER RECOVERY KEY - WRITE IT DOWN #" -ForegroundColor Red + Write-Host "############################################################" -ForegroundColor Red + Write-Host "" + Write-Host " $recoveryPassword" -ForegroundColor Yellow + Write-Host "" + if ($recoveryKeyFile) { + Write-Host " Also saved to: $recoveryKeyFile" -ForegroundColor Gray + } + Write-Host "" + Write-Host " Store this key in your password manager or another secure" -ForegroundColor Red + Write-Host " location that is NOT this machine, then delete the file." -ForegroundColor Red + Write-Host " Without it, an encrypted drive cannot be recovered after a" -ForegroundColor Red + Write-Host " TPM clear, mainboard swap or firmware change." -ForegroundColor Red + Write-Host "" + Write-Host "############################################################" -ForegroundColor Red + Write-Host "" +} + Write-Output "" -Write-Output "Note: For extra security, manually enable Tamper Protection and Memory Integrity in Windows Security Center." +Write-Output "Note: memory integrity, LSA protection and SMB signing take effect after a restart." +Write-Output "Note: for extra security, manually enable Tamper Protection in Windows Security Center." Write-Output "" if ($failedConfigs.Count -eq 0 -and -not $verifyFailed) { diff --git a/Scripts/Deployment/Install-Applications.ps1 b/Scripts/Deployment/Install-Applications.ps1 index da11bc5..7c6a086 100644 --- a/Scripts/Deployment/Install-Applications.ps1 +++ b/Scripts/Deployment/Install-Applications.ps1 @@ -17,7 +17,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } try { @@ -181,13 +181,13 @@ try { -1978335100 = "The Microsoft Store package does not support download command." -1978335099 = "Failed to retrieve Microsoft Store package license. The Microsoft Entra Id account does not have required privilege." -1978335098 = "Downloaded zero byte installer; ensure that your network connection is working properly." - -1979335097 = "Failed installing one or more fonts." - -1979335096 = "Font file is not supported and cannot be installed." - -1979335095 = "Font package is already installed." - -1979335094 = "Font file not found." - -1979335093 = "Font uninstall failed. The font may not be in a good state. Try uninstalling after a restart." - -1979335092 = "Font validation failed." - -1979335091 = "Font rollback failed. The font may not be in a good state. Try uninstalling after a restart." + -1978335097 = "Failed installing one or more fonts." + -1978335096 = "Font file is not supported and cannot be installed." + -1978335095 = "Font package is already installed." + -1978335094 = "Font file not found." + -1978335093 = "Font uninstall failed. The font may not be in a good state. Try uninstalling after a restart." + -1978335092 = "Font validation failed." + -1978335091 = "Font rollback failed. The font may not be in a good state. Try uninstalling after a restart." -1978334975 = "Application is currently running. Exit the application then try again." -1978334974 = "Another installation is already in progress. Try again later." -1978334973 = "One or more file is being used. Exit the application then try again." @@ -255,7 +255,7 @@ try { $name = $app.Name Write-DeployLog "Installing $name ($alias)..." try { - $output = & winget install --id $alias --source winget --accept-package-agreements --accept-source-agreements 2>&1 + $output = & winget install --id $alias --exact --source winget --silent --disable-interactivity --accept-package-agreements --accept-source-agreements 2>&1 $exitCode = $LASTEXITCODE if ($exitCode -eq 0 -or $output -match "already installed|No available upgrade") { Write-DeployLog "Installed $name ($alias)" @@ -324,7 +324,7 @@ try { - + '@ @@ -362,7 +362,7 @@ try { $name = $app.Name Write-DeployLog "Installing msstore $name ($alias)..." try { - $output = & winget install --id $alias --source msstore --accept-package-agreements --accept-source-agreements 2>&1 + $output = & winget install --id $alias --exact --source msstore --silent --disable-interactivity --accept-package-agreements --accept-source-agreements 2>&1 $exitCode = $LASTEXITCODE if ($exitCode -eq 0 -or $output -match "already installed|No available upgrade") { Write-DeployLog "Installed msstore $name ($alias)" diff --git a/Scripts/Deployment/Install-Drivers.ps1 b/Scripts/Deployment/Install-Drivers.ps1 index 0cebbb7..ff3e807 100644 --- a/Scripts/Deployment/Install-Drivers.ps1 +++ b/Scripts/Deployment/Install-Drivers.ps1 @@ -17,7 +17,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } try { @@ -29,6 +29,10 @@ try { Write-DeployLog "System: $manufacturer $model" + # "*hp*" would also match e.g. "Sharp", so match HP as a whole token. + $isDell = $manufacturer -like "*dell*" + $isHP = $manufacturer -like "*hewlett*" -or $manufacturer -match "(^|[^a-z])hp([^a-z]|$)" + # Embed supported device lists (no more JSON dependency) $supportedDellDevices = @( @@ -40,14 +44,14 @@ try { # Check if supported $isSupported = $false - if ($manufacturer -like "*dell*") { + if ($isDell) { Write-DeployLog "Checking Dell support..." $matchedPattern = $supportedDellDevices | Where-Object { $model -imatch "(?i)$([regex]::Escape($_) -replace '\\ ', '\\s+')" } | Select-Object -First 1 if ($matchedPattern) { $isSupported = $true Write-DeployLog "Matched pattern: $matchedPattern" } - } elseif ($manufacturer -like "*hewlett*" -or $manufacturer -like "*hp*") { + } elseif ($isHP) { Write-DeployLog "Checking HP support..." $matchedPattern = $supportedHPDevices | Where-Object { $model -imatch "(?i)$([regex]::Escape($_) -replace '\\ ', '\\s+')" } | Select-Object -First 1 if ($matchedPattern) { @@ -61,7 +65,7 @@ try { exit 0 } - if ($manufacturer -like "*dell*") { + if ($isDell) { Write-DeployLog "Supported Dell system detected. Installing Dell Command Update..." try { winget install --id Dell.CommandUpdate --silent --accept-package-agreements --accept-source-agreements @@ -108,12 +112,19 @@ try { Write-DeployLog "Failed to install or run Dell Command Update" Write-Warning "Dell driver installation failed. Check logs for details." } - } elseif ($manufacturer -like "*hewlett*" -or $manufacturer -like "*hp*") { + } elseif ($isHP) { Write-DeployLog "HP system detected. Installing HP Client Management Script Library..." try { # Install HPCMSL module if not present if (-not (Get-Module -Name HPCMSL -ListAvailable)) { - Install-Module -Name HPCMSL -Force -AllowClobber -ErrorAction Stop + # Bootstrap the package plumbing first, otherwise Install-Module + # prompts for the NuGet provider and for trusting PSGallery - + # both of which stall an unattended deployment. + if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers -Confirm:$false | Out-Null + } + Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted -ErrorAction SilentlyContinue + Install-Module -Name HPCMSL -Force -AllowClobber -AcceptLicense -Scope AllUsers -Confirm:$false -ErrorAction Stop } Import-Module HPCMSL -ErrorAction Stop Write-DeployLog "HPCMSL installed and imported." diff --git a/Scripts/Deployment/Install-RMMAgent.ps1 b/Scripts/Deployment/Install-RMMAgent.ps1 index 092b67d..35ee440 100644 --- a/Scripts/Deployment/Install-RMMAgent.ps1 +++ b/Scripts/Deployment/Install-RMMAgent.ps1 @@ -17,7 +17,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } try { diff --git a/Scripts/Deployment/Install-WindowsUpdates.ps1 b/Scripts/Deployment/Install-WindowsUpdates.ps1 index c9aa509..f05c41e 100644 --- a/Scripts/Deployment/Install-WindowsUpdates.ps1 +++ b/Scripts/Deployment/Install-WindowsUpdates.ps1 @@ -17,7 +17,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } try { @@ -49,6 +49,10 @@ try { Write-DeployLog "Verifying Windows Update service..." $wuService = Get-Service -Name wuauserv -ErrorAction SilentlyContinue + if (-not $wuService) { + Write-DeployLog "Windows Update service (wuauserv) not found on this system." -IsError + exit 1 + } if ($wuService.Status -ne 'Running') { Write-DeployLog "Starting Windows Update service..." Start-Service -Name wuauserv -ErrorAction Stop @@ -67,18 +71,26 @@ try { Write-DeployLog " - $($update.Title)" } - Write-DeployLog "Installing updates..." + Write-DeployLog "Downloading and installing updates. This can take a while..." $installedCount = 0 $failedCount = 0 - foreach ($update in $updates) { - try { - Write-DeployLog " - Installing: $($update.Title)" - Install-WindowsUpdate -KB $update.KB -AcceptAll -IgnoreReboot -Confirm:$false | Out-Null - Write-DeployLog " Success" + try { + $results = @(Get-WindowsUpdate -MicrosoftUpdate -Install -AcceptAll -IgnoreReboot -Confirm:$false -ErrorAction Stop) + } catch { + Write-DeployLog "Update installation failed: $($_.Exception.Message)" -IsError + $results = @() + $failedCount = $updates.Count + } + + foreach ($result in $results) { + $title = if ($result.PSObject.Properties.Name -contains 'Title') { $result.Title } else { 'Unknown update' } + $status = if ($result.PSObject.Properties.Name -contains 'Result') { $result.Result } else { 'Unknown' } + if ($status -match 'Installed|Succeeded') { + Write-DeployLog " - Installed: $title" $installedCount++ - } catch { - Write-DeployLog " Failed: $($_.Exception.Message)" -IsError + } else { + Write-DeployLog " - $status`: $title" -IsError $failedCount++ } } diff --git a/Scripts/Deployment/Remove-Bloat.ps1 b/Scripts/Deployment/Remove-Bloat.ps1 index dbff1fb..cab00bc 100644 --- a/Scripts/Deployment/Remove-Bloat.ps1 +++ b/Scripts/Deployment/Remove-Bloat.ps1 @@ -10,12 +10,12 @@ $ErrorActionPreference = 'Continue' Function Write-DeployLog { param([string]$Message, [switch]$IsError) - $logDir = Join-Path $env:TEMP "WinDeploy\Logs" + $logDir = "C:\WinDeploy\Logs" if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Host $Message } + if ($IsError) { Write-Warning $Message } else { Write-Host $Message } } # Expanded list for common bloatware (inspired by WinDeploy Remove-Bloat.ps1, excluding Get Help) @@ -80,6 +80,15 @@ $BloatwareList = @( # AI and Assistant "Microsoft.Copilot", + "Microsoft.Windows.Ai.Copilot.Provider", + + # Newer 24H2/25H2 in-box apps + "Microsoft.Windows.DevHome", + "Microsoft.OutlookForWindows", + "Microsoft.Edge.GameAssist", + "MicrosoftWindows.CrossDevice", + "Microsoft.StartExperiencesApp", + "Microsoft.WindowsMeetNow", # System & Utility "Microsoft.PowerAutomateDesktop", @@ -211,6 +220,25 @@ try { + # Stop Windows from silently re-installing suggested apps on the next + # feature update or for the next new user profile. + Write-DeployLog "Blocking automatic reinstall of consumer apps..." + $reinstallPolicies = @( + @{ Path = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent"; Name = "DisableWindowsConsumerFeatures"; Value = 1; Description = "Consumer features disabled" } + @{ Path = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent"; Name = "DisableConsumerAccountStateContent"; Value = 1; Description = "Consumer account state content disabled" } + @{ Path = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent"; Name = "DisableCloudOptimizedContent"; Value = 1; Description = "Cloud optimized content disabled" } + @{ Path = "HKLM:\SOFTWARE\Policies\Microsoft\WindowsStore"; Name = "AutoDownload"; Value = 2; Description = "Store app auto-download disabled" } + ) + foreach ($policy in $reinstallPolicies) { + try { + if (!(Test-Path $policy.Path)) { New-Item -Path $policy.Path -Force -ErrorAction Stop | Out-Null } + Set-ItemProperty -Path $policy.Path -Name $policy.Name -Value $policy.Value -Type DWord -ErrorAction Stop + Write-DeployLog " $($policy.Description)" + } catch { + Write-DeployLog " Failed: $($policy.Description) - $($_.Exception.Message)" -IsError + } + } + $SuccessMsg = "SUCCESS: Removed $Removed apps." Write-DeployLog $SuccessMsg @@ -224,7 +252,7 @@ try { Write-Output "This script is available as an optional download: C:\WinDeploy\Download\Fix-Spotlight.ps1" # Note: Bloatware may be reinstalled with future Windows Updates. For more control, consider using Winutil: https://github.com/ChrisTitusTech/winutil - Write-DeployLog "Note: Bloatware may be reinstalled with future Windows Updates. For more control, consider using Winutil: `e]8;;https://github.com/ChrisTitusTech/winutil`e\https://github.com/ChrisTitusTech/winutil`e]8;;`e\" + Write-DeployLog "Note: for further tweaks, the optional WinUtil step (Apply-Tweaks.ps1) uses https://github.com/ChrisTitusTech/winutil" exit 0 } catch { $ErrMsg = $_.Exception.Message diff --git a/Scripts/Deployment/Set-HostName.ps1 b/Scripts/Deployment/Set-HostName.ps1 index 45b6eed..e0e2275 100644 --- a/Scripts/Deployment/Set-HostName.ps1 +++ b/Scripts/Deployment/Set-HostName.ps1 @@ -15,7 +15,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } Write-Output "Setting hostname." diff --git a/Scripts/Deployment/Set-Theme.ps1 b/Scripts/Deployment/Set-Theme.ps1 index c8c4cad..91ea37e 100644 --- a/Scripts/Deployment/Set-Theme.ps1 +++ b/Scripts/Deployment/Set-Theme.ps1 @@ -15,7 +15,7 @@ Function Write-DeployLog { $scriptName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetFileName($MyInvocation.ScriptName)) $logFile = Join-Path $logDir "$scriptName.log" $Message | Out-File -FilePath $logFile -Append - if ($IsError) { Write-Error $Message } else { Write-Output $Message } + if ($IsError) { Write-Warning $Message } else { Write-Output $Message } } try { diff --git a/Scripts/Start.ps1 b/Scripts/Start.ps1 index 163f594..d84e139 100644 --- a/Scripts/Start.ps1 +++ b/Scripts/Start.ps1 @@ -1,6 +1,11 @@ param( [string]$VersionTag, - [switch]$Relaunched + [switch]$Relaunched, + + # Forwarded to Deploy.ps1: skips every confirmation prompt. Used by the + # autounattend.xml / USB path, which runs in a hidden window where nobody + # can answer a prompt. + [switch]$NonInteractive ) # Fetch latest release with retry logic @@ -215,6 +220,7 @@ if (-not $isAdmin) { $versionArgs = "" if ($VersionTag) { $versionArgs = "-VersionTag '$VersionTag'" } + if ($NonInteractive) { $versionArgs = "$versionArgs -NonInteractive".Trim() } $scriptPath = $PSCommandPath if (-not $scriptPath) { @@ -252,6 +258,7 @@ if (-not $isPwsh7) { $versionArgs = "" if ($VersionTag) { $versionArgs = "-VersionTag '$VersionTag'" } + if ($NonInteractive) { $versionArgs = "$versionArgs -NonInteractive".Trim() } $scriptPath = $PSCommandPath if (-not $scriptPath) { @@ -346,7 +353,7 @@ Write-Host "Starting Deploy.ps1..." -ForegroundColor Yellow Write-Host "" try { - & $deployPath + & $deployPath -NonInteractive:$NonInteractive } catch { Write-Host "Deploy.ps1 failed: $_" -ForegroundColor Red Stop-Transcript diff --git a/VERSION b/VERSION index 3d105a6..b19b521 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.7.3 +v0.8.0