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