From e6349468a1fe088dd6b1dc88206a873a3354d832 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 21:40:46 +0000 Subject: [PATCH 1/7] Fix deployment bugs, rework hardening, add opt-in BitLocker and WinUtil steps Audit of the deployment scripts turned up several bugs that break a zero-touch run, plus a hardening baseline that both did too much in one place and too little in another. Bugs 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. Made it a .ps1 and corrected the generator URL. - Harden-Windows.ps1 set SMB2=0, which disables SMB2 *and* SMB3 and breaks all file and printer sharing. Removed; replaced with SMB signing and guest-logon hardening. - Test-IntuneEnrollment and Deploy.ps1 both crashed under StrictMode ($null.Count, and $LASTEXITCODE before it is ever set). $LASTEXITCODE also leaked between steps, marking later steps as failed. - Screen lock was written to HKCU, which during deployment is the deployment account rather than the end user, and SCRNSAVE.EXE was empty so the secure lock never triggered. Now machine-wide policy. - winget installs were missing --silent, and Office used Display Level="Full", so both could show UI mid-deployment. - Windows updates without a KB number (drivers, definitions) were silently skipped by the per-KB install loop. - Seven winget font error codes were typed -1979335xxx, not -1978335xxx. - HP detection matched "*hp*", which also matches "Sharp"; HPCMSL was installed without bootstrapping NuGet/PSGallery so it stalled. - Remove-Bloat logged to %TEMP% instead of C:\WinDeploy\Logs, used a PowerShell 6+ escape in a 5.1 script, and never implemented the "prevents reinstall" its header promised. - Exit prompts now time out instead of blocking unattended runs. BitLocker previously created only a TPM protector while telling the operator to "export your BitLocker recovery key" that never existed, leaving the drive unrecoverable after a TPM clear or mainboard swap. It now asks Y/N, creates a recovery password, saves it to the operator's Documents folder and prints it on screen with a warning to store it. Added Apply-Tweaks.ps1, an opt-in step that applies a ChrisTitusTech WinUtil preset after a Y/N confirmation, and extended the hardening baseline with LSA protection, WDigest, SMB signing, LLMNR, HVCI and Defender ASR rules. Both prompts default to No after 90s, and -NonInteractive skips them entirely for the autounattend path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pt5Q3XvjMT6HMdubvkyLMW --- CHANGELOG.md | 34 ++ Docs/autounattend.xml | 8 +- README.md | 93 +++- Scripts/Deploy.ps1 | 91 ++-- Scripts/Deployment/Apply-Tweaks.ps1 | 186 ++++++++ Scripts/Deployment/Harden-Windows.ps1 | 404 +++++++++++++++--- Scripts/Deployment/Install-Applications.ps1 | 22 +- Scripts/Deployment/Install-Drivers.ps1 | 23 +- Scripts/Deployment/Install-RMMAgent.ps1 | 2 +- Scripts/Deployment/Install-WindowsUpdates.ps1 | 30 +- Scripts/Deployment/Remove-Bloat.ps1 | 34 +- Scripts/Deployment/Set-HostName.ps1 | 2 +- Scripts/Deployment/Set-Theme.ps1 | 2 +- Scripts/Start.ps1 | 11 +- VERSION | 2 +- 15 files changed, 822 insertions(+), 122 deletions(-) create mode 100644 Scripts/Deployment/Apply-Tweaks.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e69199f..ea7ba20 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`. New optional deployment step that applies a [ChrisTitusTech WinUtil](https://github.com/ChrisTitusTech/winutil) preset (`Standard` by default). Asks for Y/N confirmation first, since it downloads and executes a third-party script, and lists exactly what the preset changes before you answer. Runs WinUtil in its own process so a failure there cannot take down the deployment. +- BitLocker now creates a **recovery password protector** and saves it to the operator's Documents folder, then prints it on screen with a warning to store it safely. Previously only a TPM protector was created, which meant an encrypted drive was unrecoverable after a TPM clear, mainboard swap or firmware change — while the script told the operator to "export your BitLocker recovery key" that never existed. +- BitLocker is now 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. +- Modern hardening: LSA protection (RunAsPPL), WDigest plaintext credential caching disabled, anonymous SAM/share enumeration restricted, SMB client + 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 actually 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 (`iex (irm ...)`), 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` disabled SMB2 *and* SMB3.** The `SMB2 = 0` value under `LanmanServer\Parameters` turns off every remaining SMB version, breaking file and printer sharing. Microsoft explicitly 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 the wrong profile.** `ScreenSaveActive` / `ScreenSaverIsSecure` were set under `HKCU`, which during deployment belongs to the deployment account, not the end user. Now written to the machine-wide policy hive. `SCRNSAVE.EXE` was also set to an empty string, which means Windows never starts a screen saver and the secure lock never triggers — it now points at `scrnsave.scr`. +- **Applications could show installer UI during a zero-touch deployment.** `winget install` was missing `--silent`; it now also passes `--exact` and `--disable-interactivity`. +- **Office installed interactively.** The ODT configuration used ``; now `None`. +- **Windows Updates without a KB number were skipped.** `Install-WindowsUpdate -KB $update.KB` cannot install driver and definition updates, which have no KB. 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 garbage. +- 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 forever. + +### Changed +- Deployment scripts log failures with `Write-Warning` instead of `Write-Error`. `Write-Error` printed a full error record with category and stack trace for every non-fatal skip, which is the console noise that made a normal run look broken. `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..c6e97ef 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,29 @@ 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 + +Two steps ask for confirmation before they run. Everything else is applied automatically. + +| Step | Prompt | If you answer N or do nothing | +|---|---|---| +| **BitLocker** (in `Harden-Windows.ps1`) | Encrypt `C:` with XTS-AES-256? | Skipped. The rest of the hardening is still applied. | +| **WinUtil tweaks** (`Apply-Tweaks.ps1`) | Run the ChrisTitusTech WinUtil preset? | Skipped. The rest of the deployment continues. | + +Both prompts time out after 90 seconds and default to **No**, so an unattended deployment never stalls. + +To answer up front, or to skip both without waiting: + +```powershell +# Fully unattended: no prompts, BitLocker and tweaks skipped +.\Deploy.ps1 -NonInteractive + +# Unattended, but do enable BitLocker and apply the tweaks +.\Deploy.ps1 -BitLocker Yes -Tweaks Yes +``` + +The `autounattend.xml` USB deployment passes `-NonInteractive` automatically. + --- ## Configuration @@ -118,11 +149,67 @@ 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 (mitigates Responder-style poisoning) | +| 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 (machine-wide policy) | + +Memory integrity, LSA protection and SMB signing take effect **after a restart**. + +> **Note:** Windows Script Host is disabled as part of the baseline. A small number of legacy MSI installers use VBScript custom actions and can fail because of it. If you hit that, re-enable it temporarily via `HKLM:\SOFTWARE\Microsoft\Windows Script Host\Settings\Enabled`. + +### BitLocker and the recovery key + +BitLocker is **opt-in** and asks for confirmation. When you answer **Y**: + +1. `C:` is encrypted with XTS-AES-256, used space only, bound to the TPM. +2. A 48-digit **recovery password** is created — a TPM protector alone cannot be recovered. +3. The key is written to your **Documents** folder as `BitLocker-Recovery-Key__.txt`. +4. The key is printed on screen at the end of the hardening step. + +> **Write the recovery key down before the machine leaves your desk.** Move it into your password manager (or another secure location that is *not* this machine) and delete the file. Without it the drive cannot be recovered after a TPM clear, mainboard swap or firmware change. + +To run it non-interactively: + +```powershell +.\Harden-Windows.ps1 -BitLocker Yes # encrypt without prompting +.\Harden-Windows.ps1 -BitLocker No # skip BitLocker, apply everything else +``` + +--- + +## Optional tweaks (WinUtil) + +`Apply-Tweaks.ps1` runs a [ChrisTitusTech WinUtil](https://github.com/ChrisTitusTech/winutil) preset. It asks for confirmation first, because it downloads and executes a third-party script from `christitus.com`. + +The default **Standard** preset creates a restore point, then disables activity history, location tracking, telemetry, consumer features (which is what stops Windows re-installing bloatware), Delivery Optimization and Explorer folder-type auto-discovery, sets non-essential services to manual, enables "End task" on the taskbar, and cleans up temp files. + +```powershell +.\Apply-Tweaks.ps1 -Tweaks Yes # Standard preset, no prompt +.\Apply-Tweaks.ps1 -Tweaks Yes -Preset Minimal # fewer changes +.\Apply-Tweaks.ps1 -Tweaks Yes -Preset Advanced # also removes OneDrive, widgets, Windows AI +.\Apply-Tweaks.ps1 -Tweaks No # skip +``` + +WinUtil runs in its own process, so a failure there cannot take down the rest of the deployment. + +--- + ## 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..879b913 100644 --- a/Scripts/Deploy.ps1 +++ b/Scripts/Deploy.ps1 @@ -1,6 +1,51 @@ +[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 { $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 +92,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 +238,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 +270,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 +329,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..23ca646 --- /dev/null +++ b/Scripts/Deployment/Apply-Tweaks.ps1 @@ -0,0 +1,186 @@ +# ============================================================================ +# 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' } + + if (-not [Environment]::UserInteractive) { + Write-Output "$Question [Y/N] -> non-interactive session, using default: $defaultLabel" + return $default + } + + try { $null = $Host.UI.RawUI.KeyAvailable } catch { + Write-Output "$Question [Y/N] -> no console available, using default: $defaultLabel" + return $default + } + + 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) + if ($remaining -ne $lastShown) { + 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..105098f 100644 --- a/Scripts/Deployment/Harden-Windows.ps1 +++ b/Scripts/Deployment/Harden-Windows.ps1 @@ -2,17 +2,98 @@ # 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' } + + if (-not [Environment]::UserInteractive) { + Write-Output "$Question [Y/N] -> non-interactive session, using default: $defaultLabel" + return $default + } + + # Hosts without a real console (ISE, some job runners) throw here. + try { $null = $Host.UI.RawUI.KeyAvailable } catch { + Write-Output "$Question [Y/N] -> no console available, using default: $defaultLabel" + 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) + if ($remaining -ne $lastShown) { + 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 +121,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 +145,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 +184,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 +243,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 +294,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 + 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)" + } 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 +447,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 +491,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/" + "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 +525,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 From 2e9b6e99385d05af0d4022c7ef0dd7c7d3203c40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 21:50:37 +0000 Subject: [PATCH 2/7] Fix Y/N prompt returning an array, and detect non-interactive sessions properly Smoke-testing the new confirmation prompts turned up two problems in the code added in the previous commit. Read-YesNoWithTimeout used Write-Output for its "no console" message. Write-Output goes to the same stream as the return value, so the caller got @("...message...", $false) instead of $false. A 2-element array is truthy, so `if (-not $enableBitLocker)` took the wrong branch and answering No would have ENABLED BitLocker. Switched to Write-Host, which does not touch the output stream, and added a test asserting the return value is a clean [bool]. The non-interactive short-circuit also never fired. [Environment]:: UserInteractive is $true for any process in a user session, including one with redirected stdin, so the helper fell through to the polling loop and sat there for the full 90-second timeout instead of returning the default immediately. Now also checks [Console]::IsInputRedirected, and Deploy.ps1's Wait-ForExit got the same guard. While there: the countdown repainted once a second, which fills the Start.ps1 transcript with 90 redraw lines per prompt. It now repaints every 5 seconds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pt5Q3XvjMT6HMdubvkyLMW --- Scripts/Deploy.ps1 | 1 + Scripts/Deployment/Apply-Tweaks.ps1 | 24 +++++++++++++++++------- Scripts/Deployment/Harden-Windows.ps1 | 24 ++++++++++++++++-------- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/Scripts/Deploy.ps1 b/Scripts/Deploy.ps1 index 879b913..cff7e16 100644 --- a/Scripts/Deploy.ps1 +++ b/Scripts/Deploy.ps1 @@ -26,6 +26,7 @@ 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) diff --git a/Scripts/Deployment/Apply-Tweaks.ps1 b/Scripts/Deployment/Apply-Tweaks.ps1 index 23ca646..f0aad77 100644 --- a/Scripts/Deployment/Apply-Tweaks.ps1 +++ b/Scripts/Deployment/Apply-Tweaks.ps1 @@ -56,16 +56,24 @@ function Read-YesNoWithTimeout { $default = [bool]$DefaultYes $defaultLabel = if ($default) { 'Y' } else { 'N' } - if (-not [Environment]::UserInteractive) { - Write-Output "$Question [Y/N] -> non-interactive session, using default: $defaultLabel" - return $default + # 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 } } - - try { $null = $Host.UI.RawUI.KeyAvailable } catch { - Write-Output "$Question [Y/N] -> no console available, using default: $defaultLabel" + 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 { @@ -76,7 +84,9 @@ function Read-YesNoWithTimeout { $lastShown = -1 while ((Get-Date) -lt $deadline) { $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds) - if ($remaining -ne $lastShown) { + # 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 } diff --git a/Scripts/Deployment/Harden-Windows.ps1 b/Scripts/Deployment/Harden-Windows.ps1 index 105098f..08ba8f6 100644 --- a/Scripts/Deployment/Harden-Windows.ps1 +++ b/Scripts/Deployment/Harden-Windows.ps1 @@ -51,14 +51,20 @@ function Read-YesNoWithTimeout { $default = [bool]$DefaultYes $defaultLabel = if ($default) { 'Y' } else { 'N' } - if (-not [Environment]::UserInteractive) { - Write-Output "$Question [Y/N] -> non-interactive session, using default: $defaultLabel" - return $default + # 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 } } - - # Hosts without a real console (ISE, some job runners) throw here. - try { $null = $Host.UI.RawUI.KeyAvailable } catch { - Write-Output "$Question [Y/N] -> no console available, using default: $defaultLabel" + 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 } @@ -73,7 +79,9 @@ function Read-YesNoWithTimeout { $lastShown = -1 while ((Get-Date) -lt $deadline) { $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds) - if ($remaining -ne $lastShown) { + # 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 } From 493b9a4be4b52077ee41cc014504cca262b2c21d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 00:23:47 +0000 Subject: [PATCH 3/7] Suppress DevSkim DS187371 false positive on "XTS-AES-256" DevSkim reported three errors on this PR, all "A potentially weak cipher mode of operation was used". Fetched the check-run annotations rather than guessing: all three are the literal prose string "XTS-AES-256" in a console message, a summary label and a docs-link key. DS187371 word-matches XTS from a list of cipher modes. The hyphens in "XTS-AES-256" form word boundaries, which is why the prose matches while the actual code (-EncryptionMethod XtsAes256) does not. The finding is wrong on the merits: XTS is the mode Microsoft recommends for BitLocker, and it is what this script configures. Rewording cannot fix it either, since any text naming the mode matches, and dropping the name would hide information the operator wants. Suppressed inline with a justification, which is DevSkim's own mechanism for this. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pt5Q3XvjMT6HMdubvkyLMW --- Scripts/Deployment/Harden-Windows.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Scripts/Deployment/Harden-Windows.ps1 b/Scripts/Deployment/Harden-Windows.ps1 index 08ba8f6..d138971 100644 --- a/Scripts/Deployment/Harden-Windows.ps1 +++ b/Scripts/Deployment/Harden-Windows.ps1 @@ -351,7 +351,7 @@ switch ($BitLocker) { 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 + 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 @@ -384,7 +384,7 @@ if (-not $enableBitLocker) { $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)" + $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" } @@ -512,7 +512,7 @@ $hardeningLinks = @{ "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/" - "BitLocker encryption started (XTS-AES-256)" = "https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/" + "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" } From 843d5b6672d6a300f2d02bd47290e91f9b428b75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 16:04:55 +0000 Subject: [PATCH 4/7] Trim README and CHANGELOG prose to match repo style Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pt5Q3XvjMT6HMdubvkyLMW --- CHANGELOG.md | 34 +++++++++++++++++----------------- README.md | 52 +++++++++++++--------------------------------------- 2 files changed, 30 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7ba20..1708011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,33 +10,33 @@ 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`. New optional deployment step that applies a [ChrisTitusTech WinUtil](https://github.com/ChrisTitusTech/winutil) preset (`Standard` by default). Asks for Y/N confirmation first, since it downloads and executes a third-party script, and lists exactly what the preset changes before you answer. Runs WinUtil in its own process so a failure there cannot take down the deployment. -- BitLocker now creates a **recovery password protector** and saves it to the operator's Documents folder, then prints it on screen with a warning to store it safely. Previously only a TPM protector was created, which meant an encrypted drive was unrecoverable after a TPM clear, mainboard swap or firmware change — while the script told the operator to "export your BitLocker recovery key" that never existed. -- BitLocker is now opt-in via a Y/N prompt (`-BitLocker Ask|Yes|No`). All other hardening still applies unconditionally. +- `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. -- Modern hardening: LSA protection (RunAsPPL), WDigest plaintext credential caching disabled, anonymous SAM/share enumeration restricted, SMB client + 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 actually implements the "prevents reinstall" its header promised, via `DisableWindowsConsumerFeatures` and related CloudContent/Store policies. +- 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 (`iex (irm ...)`), 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` disabled SMB2 *and* SMB3.** The `SMB2 = 0` value under `LanmanServer\Parameters` turns off every remaining SMB version, breaking file and printer sharing. Microsoft explicitly 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 the wrong profile.** `ScreenSaveActive` / `ScreenSaverIsSecure` were set under `HKCU`, which during deployment belongs to the deployment account, not the end user. Now written to the machine-wide policy hive. `SCRNSAVE.EXE` was also set to an empty string, which means Windows never starts a screen saver and the secure lock never triggers — it now points at `scrnsave.scr`. -- **Applications could show installer UI during a zero-touch deployment.** `winget install` was missing `--silent`; it now also passes `--exact` and `--disable-interactivity`. -- **Office installed interactively.** The ODT configuration used ``; now `None`. -- **Windows Updates without a KB number were skipped.** `Install-WindowsUpdate -KB $update.KB` cannot install driver and definition updates, which have no KB. Replaced with a single `Get-WindowsUpdate -Install` pass, which is also considerably faster. +- `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-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 garbage. +- `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 forever. +- "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`. `Write-Error` printed a full error record with category and stack trace for every non-fatal skip, which is the console noise that made a normal run look broken. `Deploy.ps1` already did this. +- 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. --- diff --git a/README.md b/README.md index c6e97ef..ca3127b 100644 --- a/README.md +++ b/README.md @@ -94,23 +94,11 @@ graph TD ### Interactive steps -Two steps ask for confirmation before they run. Everything else is applied automatically. - -| Step | Prompt | If you answer N or do nothing | -|---|---|---| -| **BitLocker** (in `Harden-Windows.ps1`) | Encrypt `C:` with XTS-AES-256? | Skipped. The rest of the hardening is still applied. | -| **WinUtil tweaks** (`Apply-Tweaks.ps1`) | Run the ChrisTitusTech WinUtil preset? | Skipped. The rest of the deployment continues. | - -Both prompts time out after 90 seconds and default to **No**, so an unattended deployment never stalls. - -To answer up front, or to skip both without waiting: +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 -# Fully unattended: no prompts, BitLocker and tweaks skipped -.\Deploy.ps1 -NonInteractive - -# Unattended, but do enable BitLocker and apply the tweaks -.\Deploy.ps1 -BitLocker Yes -Tweaks Yes +.\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. @@ -158,51 +146,37 @@ Place your agent installer as `Agent.exe` (or any `*agent*.exe`) on the USB driv | 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 (mitigates Responder-style poisoning) | +| 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 (machine-wide policy) | +| Screen lock | Secure screen saver after 15 minutes, console lock on resume | -Memory integrity, LSA protection and SMB signing take effect **after a restart**. +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. -> **Note:** Windows Script Host is disabled as part of the baseline. A small number of legacy MSI installers use VBScript custom actions and can fail because of it. If you hit that, re-enable it temporarily via `HKLM:\SOFTWARE\Microsoft\Windows Script Host\Settings\Enabled`. +### BitLocker -### BitLocker and the recovery key +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. -BitLocker is **opt-in** and asks for confirmation. When you answer **Y**: - -1. `C:` is encrypted with XTS-AES-256, used space only, bound to the TPM. -2. A 48-digit **recovery password** is created — a TPM protector alone cannot be recovered. -3. The key is written to your **Documents** folder as `BitLocker-Recovery-Key__.txt`. -4. The key is printed on screen at the end of the hardening step. - -> **Write the recovery key down before the machine leaves your desk.** Move it into your password manager (or another secure location that is *not* this machine) and delete the file. Without it the drive cannot be recovered after a TPM clear, mainboard swap or firmware change. - -To run it non-interactively: +**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 everything else +.\Harden-Windows.ps1 -BitLocker No # skip BitLocker, apply the rest ``` --- ## Optional tweaks (WinUtil) -`Apply-Tweaks.ps1` runs a [ChrisTitusTech WinUtil](https://github.com/ChrisTitusTech/winutil) preset. It asks for confirmation first, because it downloads and executes a third-party script from `christitus.com`. - -The default **Standard** preset creates a restore point, then disables activity history, location tracking, telemetry, consumer features (which is what stops Windows re-installing bloatware), Delivery Optimization and Explorer folder-type auto-discovery, sets non-essential services to manual, enables "End task" on the taskbar, and cleans up temp files. +`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, no prompt -.\Apply-Tweaks.ps1 -Tweaks Yes -Preset Minimal # fewer changes +.\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 -.\Apply-Tweaks.ps1 -Tweaks No # skip ``` -WinUtil runs in its own process, so a failure there cannot take down the rest of the deployment. - --- ## Logging From 859147ebbd5edc6a456792a1dcf5aff79e2d2ca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 22:56:50 +0000 Subject: [PATCH 5/7] Restructure into the THectic Hugo-site pattern, matching BypassNRO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Hugo landing page (src/, EN+NL) for windeploy.thectic.nl, deployed to Bunny.net, mirroring the structure already applied to BypassNRO. Scripts/ and Docs/ keep their location and behaviour — GitHub Releases stays the distribution mechanism, so a run stays pinned to one release tag throughout, unlike BypassNRO where the Bunny CDN became the file source itself. CI: swapped codeql.yml/dependency-review.yml/security.yml/stale.yml for the org's leaner set (config-validation, pr-checks, pr-title, trivy-scan, update-checksums, deploy-bunny), matching what already happened to BypassNRO. This drops DevSkim/Semgrep/CodeQL-for-Actions/ dependency-review/stale-bot coverage - noted in the CHANGELOG since it's a real reduction, not just a rename. validate.yml's PowerShell syntax and helper-function checks are kept (bypassnro didn't need their own equivalent; this repo does) and merged into one job. All Stensel8/WinDeploy references updated to Thectic-NL/WinDeploy across README, SECURITY.md, autounattend.xml (plain and URL-encoded forms in the generator header comment), Start.ps1, Deploy.ps1 and the changelog's release links. windeploy.stensel.nl (an external redirect, not part of this repo) is flagged in the README as needing separate attention. Verified before pushing: PSScriptAnalyzer (0 findings), actionlint (0 findings) and a full Hugo build against the real go.mod/go.sum - both generated via `go mod download` and checksum-matched against BypassNRO's identical hextra v0.12.3 dependency. The build only fails in this sandbox on two outbound fetches this environment blocks (go.dev's toolchain download, cdn.jsdelivr.net for search) - neither is a defect in the added content, and BypassNRO's own build depends on the identical jsdelivr fetch already. What this does NOT do, and can't from here: the actual GitHub repo transfer to Thectic-NL (no transfer API available, and this session can't attach Thectic-NL/WinDeploy - cross-tier repo access is blocked once Stensel8 repos are already attached), and adding the project card to Thectic-NL/THectic.nl's homepage (same cross-tier block). Both need to happen from the user's own side or a separate session. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pt5Q3XvjMT6HMdubvkyLMW --- .editorconfig | 35 ++++ .gitattributes | 24 +++ .github/pull_request_template.md | 23 ++- .github/scripts/check-renovate-patterns.py | 74 ++++++++ .github/scripts/update-tool-checksums.sh | 158 ++++++++++++++++ .github/workflows/codeql.yml | 44 ----- .github/workflows/config-validation.yml | 126 +++++++++++++ .github/workflows/dependency-review.yml | 28 --- .github/workflows/deploy-bunny.yml | 89 +++++++++ .github/workflows/powershell.yml | 63 +++++++ .github/workflows/pr-checks.yml | 206 +++++++++++++++++++++ .github/workflows/pr-title.yml | 45 +++++ .github/workflows/security.yml | 162 ---------------- .github/workflows/stale.yml | 53 ------ .github/workflows/trivy-scan.yml | 32 ++++ .github/workflows/update-checksums.yml | 85 +++++++++ .github/workflows/validate.yml | 59 +++--- .gitignore | 6 + .lychee.toml | 10 + .markdownlint.yml | 55 ++++++ CHANGELOG.md | 52 ++++-- CONTRIBUTING.md | 94 +++++++--- Docs/autounattend.xml | 4 +- README.md | 18 +- SECURITY.md | 2 +- Scripts/Archived/Get-InstalledSoftware.ps1 | 4 +- Scripts/Deploy.ps1 | 10 +- Scripts/Start.ps1 | 12 +- VERSION | 2 +- renovate.json | 133 +++++++++---- src/assets/css/custom.css | 92 +++++++++ src/content/_index.md | 70 +++++++ src/content/_index.nl.md | 70 +++++++ src/go.mod | 5 + src/go.sum | 2 + src/hugo.toml | 73 ++++++++ src/layouts/_partials/custom/footer.html | 4 + src/layouts/_partials/footer.html | 35 ++++ src/layouts/_partials/navbar-title.html | 38 ++++ src/static/robots.txt | 4 + 40 files changed, 1676 insertions(+), 425 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100755 .github/scripts/check-renovate-patterns.py create mode 100755 .github/scripts/update-tool-checksums.sh delete mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/config-validation.yml delete mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/deploy-bunny.yml create mode 100644 .github/workflows/powershell.yml create mode 100644 .github/workflows/pr-checks.yml create mode 100644 .github/workflows/pr-title.yml delete mode 100644 .github/workflows/security.yml delete mode 100644 .github/workflows/stale.yml create mode 100644 .github/workflows/trivy-scan.yml create mode 100644 .github/workflows/update-checksums.yml create mode 100644 .lychee.toml create mode 100644 .markdownlint.yml create mode 100644 src/assets/css/custom.css create mode 100644 src/content/_index.md create mode 100644 src/content/_index.nl.md create mode 100644 src/go.mod create mode 100644 src/go.sum create mode 100644 src/hugo.toml create mode 100644 src/layouts/_partials/custom/footer.html create mode 100644 src/layouts/_partials/footer.html create mode 100644 src/layouts/_partials/navbar-title.html create mode 100644 src/static/robots.txt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7c42757 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,35 @@ +# EditorConfig houdt de opmaak gelijk tussen editors en IDE's. +# VS Code, JetBrains en anderen lezen dit bestand vanzelf. +# Meer info: https://editorconfig.org +# +# Deze versie is organisatiebreed gelijk. Wijk hier niet per repository van af: +# vijf licht verschillende varianten leverden alleen ruis op, geen voordeel. +# +# De configbestanden die zelf geen comments kunnen dragen omdat het JSON is, +# staan hier genoemd zodat er ergens een aanwijzing is wat ze doen: +# +# .htmlhintrc - HTML-linting (HTMLHint). Structuur- en toegankelijkheidsregels. +# renovate.json - Renovate-bot. Automatische dependency-updates via pull requests. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +# Trailing whitespace betekent iets in Markdown (regeleinde) +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[*.py] +indent_size = 4 + +[*.sh] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..45ff4d7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,24 @@ +# .gitattributes zorgt voor consistente regeleindes tussen Windows, Mac en Linux. +# Zonder dit kunnen regeleindes per ontwikkelaar of OS verschillen, wat leidt +# tot onnodige git-diffs en merge-conflicten. +# +# Deze versie is organisatiebreed gelijk. Wijk hier niet per repository van af: +# vier licht verschillende varianten leverden alleen ruis op, geen voordeel. + +# Standaard: forceer LF voor alle tekstbestanden +* text=auto eol=lf + +# Binaire bestanden: geen regeleindeconversie +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.avif binary +*.pdf binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.zip binary diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 08e83fb..a194fb7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,17 +4,26 @@ ## Type of change -- [ ] Bug fix -- [ ] New feature / script -- [ ] Refactor / cleanup -- [ ] CI / workflow change -- [ ] Documentation +- [ ] `feat` — new feature or script +- [ ] `fix` — bug fix +- [ ] `content` — update or improve the site's landing page +- [ ] `docs` — changes to README, CONTRIBUTING, or meta documentation +- [ ] `chore` — maintenance (dependencies, config, CI/CD) +- [ ] `refactor` — restructuring without behaviour changes +- [ ] `style` — formatting, whitespace, typos +- [ ] `revert` — reverting a previous commit -## Testing +> [PR title and commit types must follow these standards — view the contributing guide](https://github.com/Thectic-NL/WinDeploy/blob/main/CONTRIBUTING.md#commit-messages) -- [ ] Tested on Windows 11 25H2 +## Checklist + +- [ ] PR title follows the commit convention +- [ ] Tested on a real Windows 11 25H2 machine (required for anything under `Scripts/` or `Docs/` — CI cannot exercise BitLocker, Sysprep, WinGet or Defender) - [ ] PSScriptAnalyzer passes locally - [ ] No hardcoded IPs, credentials, or company-specific data +- [ ] Both EN (`*.md`) and NL (`*.nl.md`) versions updated, if `src/content/` changed +- [ ] No broken internal links, if `src/` changed +- [ ] Site tested locally with `cd src && hugo server`, if `src/` changed ## Notes diff --git a/.github/scripts/check-renovate-patterns.py b/.github/scripts/check-renovate-patterns.py new file mode 100755 index 0000000..bb7c695 --- /dev/null +++ b/.github/scripts/check-renovate-patterns.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +"""Flag Renovate file patterns that look like a regex but are not delimited. + +managerFilePatterns and matchFileNames accept "RegEx (re2) and glob patterns". +A value counts as a regex only when it is wrapped in slashes; everything else +is read as a glob. So a pattern like + + "^\\.github/workflows/.*\\.ya?ml$" + +matches no file at all, and the custom manager around it never fires. Nothing +reports this: renovate-config-validator says the config is valid, because it +is -- it just silently does nothing. The only visible symptom is a dependency +that stops receiving updates, which is easy to miss for months. + +Usage: check-renovate-patterns.py [config.json ...] +Missing files are skipped, so the same call works in every repository. +""" +import json +import pathlib +import re +import sys + +# Constructs that carry meaning in a regex but not in a glob. +REGEXY = re.compile(r"^\^|\$$|\\\.|\.\*|\.\+|\(\?|\[\^|\\d|\\w|\\s|[)?]\|") + +# Renovate options whose values are matched as "regex or glob". +PATTERN_KEYS = { + "managerFilePatterns", + "matchFileNames", + "fileMatch", + "matchPackageNames", +} + +problems = [] + + +def walk(node, path, source): + if isinstance(node, dict): + for key, value in node.items(): + if key in PATTERN_KEYS and isinstance(value, list): + for index, pattern in enumerate(value): + if not isinstance(pattern, str): + continue + # A trailing "i" flag is allowed: /pattern/i + delimited = pattern.startswith("/") and pattern.rstrip("i").endswith("/") + if REGEXY.search(pattern) and not delimited: + problems.append((source, f"{path}.{key}[{index}]", pattern)) + walk(value, f"{path}.{key}", source) + elif isinstance(node, list): + for index, item in enumerate(node): + walk(item, f"{path}[{index}]", source) + + +files = [path for path in (pathlib.Path(a) for a in sys.argv[1:]) if path.is_file()] +if not files: + print("No Renovate config found to check.") + sys.exit(0) + +for config in files: + walk(json.loads(config.read_text()), "$", str(config)) + +if problems: + print("Renovate file patterns that look like a regex but are not wrapped in slashes.") + print("Renovate reads these as globs, so they match nothing and the rule never fires.\n") + for source, where, pattern in problems: + print(f"::error file={source}::{where}: {pattern!r} is read as a glob, not a regex") + print(f" {source} {where}") + print(f" found: {pattern!r}") + print(f" expect: '/{pattern}/'\n") + sys.exit(1) + +print(f"Checked {len(files)} Renovate config file(s): all file patterns are well formed.") diff --git a/.github/scripts/update-tool-checksums.sh b/.github/scripts/update-tool-checksums.sh new file mode 100755 index 0000000..9b4f15e --- /dev/null +++ b/.github/scripts/update-tool-checksums.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# +# Recalculate and optionally apply the SHA-256 checksums of the pinned CI tools. +# +# Renovate bumps the version numbers but cannot compute a checksum, so without +# this the pinned hash keeps pointing at the previous release and every bump +# fails the build with "computed checksum did NOT match". The workflow in +# .github/workflows/update-checksums.yml runs this on Renovate's own pull +# requests and commits the result back onto the branch. +# +# The hash is not simply taken from whatever the download happened to return. +# Each project publishes its own checksum file next to the release; the +# download is verified against that first, and only a verified hash is written +# into the repository. +# +# Usage: +# .github/scripts/update-tool-checksums.sh # show, then ask +# .github/scripts/update-tool-checksums.sh --apply # write without asking +# + +set -euo pipefail + +readonly RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' + +Write-Log() { + local level=$1; shift + local color=$NC + case $level in + INFO) color=$BLUE ;; + SUCCESS) color=$GREEN ;; + WARN) color=$YELLOW ;; + ERROR) color=$RED ;; + esac + if [[ $level == ERROR ]]; then + echo -e "${color}[$level]${NC} $*" >&2 + else + echo -e "${color}[$level]${NC} $*" + fi +} + +Stop-Script() { + Write-Log ERROR "$1" + exit 1 +} + +Show-Usage() { + cat <<'EOF' +Usage: update-tool-checksums.sh [--apply] + +Options: + --apply Write the checksums without prompting + -h, --help Show this help +EOF +} + +APPLY=false +while [[ $# -gt 0 ]]; do + case "$1" in + --apply) APPLY=true; shift ;; + -h|--help) Show-Usage; exit 0 ;; + *) Write-Log ERROR "Unknown argument: $1"; Show-Usage; exit 1 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +readonly REPO_ROOT +cd "$REPO_ROOT" + +readonly CONFIG_VALIDATION=".github/workflows/config-validation.yml" +readonly PR_CHECKS=".github/workflows/pr-checks.yml" + +# ── Reading and writing the pinned values ─────────────────────────────────── + +# Usage: Get-KeyValue -> value of `KEY: "value"` +Get-KeyValue() { + sed -n "s/^[[:space:]]*$2:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$1" | head -n1 +} + +# Usage: Set-KeyValue +Set-KeyValue() { + sed -i "s|^\([[:space:]]*$2:[[:space:]]*\"\)[^\"]*\"|\1$3\"|" "$1" +} + +# ── Fetching and verifying ────────────────────────────────────────────────── + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf -- "$TEMP_DIR"' EXIT + +# Usage: Get-VerifiedHash +# Downloads the artifact, checks it against the hash the project published, and +# echoes that hash. Refuses to return anything if the two disagree. +Get-VerifiedHash() { + local name=$1 url=$2 expected=$3 + local file="$TEMP_DIR/$name" + + [[ "$expected" =~ ^[a-f0-9]{64}$ ]] || Stop-Script "$name: no valid checksum published upstream (got: '$expected')" + + curl -sSL --fail-with-body --retry 5 --retry-delay 3 --retry-all-errors -o "$file" "$url" \ + || Stop-Script "$name: download failed ($url)" + + local actual + actual="$(sha256sum "$file" | awk '{print $1}')" + + if [[ "$actual" != "$expected" ]]; then + Stop-Script "$name: download does not match the published checksum. published=$expected downloaded=$actual" + fi + + echo "$actual" +} + +# Usage: Get-PublishedHash +# Pulls one line out of a checksums file and returns the hash on it. +Get-PublishedHash() { + curl -sSL --fail-with-body --retry 5 --retry-delay 3 --retry-all-errors "$1" \ + | grep -- "$2" | awk '{print $1}' | head -n1 +} + +# ── The tools ─────────────────────────────────────────────────────────────── + +ACTIONLINT_VERSION="$(Get-KeyValue "$CONFIG_VALIDATION" ACTIONLINT_VERSION)" +LYCHEE_VERSION="$(Get-KeyValue "$PR_CHECKS" LYCHEE_VERSION)" + +for pair in "actionlint:$ACTIONLINT_VERSION" "lychee:$LYCHEE_VERSION"; do + [[ -n "${pair#*:}" ]] || Stop-Script "Could not read the ${pair%%:*} version. Did the file layout change?" +done + +Write-Log INFO "Versions found in the repository:" +echo " actionlint: $ACTIONLINT_VERSION" +echo " lychee: $LYCHEE_VERSION" +echo + +Write-Log INFO "Downloading and verifying against the published checksums..." + +ACTIONLINT_SHA256="$(Get-VerifiedHash "actionlint.tar.gz" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + "$(Get-PublishedHash "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" "linux_amd64.tar.gz")")" +Write-Log SUCCESS "actionlint: $ACTIONLINT_SHA256" + +LYCHEE_SHA256="$(Get-VerifiedHash "lychee.tar.gz" \ + "https://github.com/lycheeverse/lychee/releases/download/lychee-v${LYCHEE_VERSION}/lychee-x86_64-unknown-linux-gnu.tar.gz" \ + "$(Get-PublishedHash "https://github.com/lycheeverse/lychee/releases/download/lychee-v${LYCHEE_VERSION}/lychee-x86_64-unknown-linux-gnu.tar.gz.sha256" "")")" +Write-Log SUCCESS "lychee: $LYCHEE_SHA256" + +echo +if [[ "$APPLY" != true ]]; then + read -rp "Write these checksums into the repository? [y/N] " response + if [[ ! "$response" =~ ^[Yy]$ ]]; then + Write-Log INFO "No changes made" + exit 0 + fi +fi + +Set-KeyValue "$CONFIG_VALIDATION" ACTIONLINT_SHA256 "$ACTIONLINT_SHA256" +Set-KeyValue "$PR_CHECKS" LYCHEE_SHA256 "$LYCHEE_SHA256" + +Write-Log SUCCESS "Updated:" +echo " - $CONFIG_VALIDATION" +echo " - $PR_CHECKS" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 625dc37..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: CodeQL - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - schedule: - - cron: '0 4 * * 1' # Every Monday at 04:00 UTC - -# Default minimal permissions — each job overrides only what it needs. -permissions: read-all - -# ============================================================ -# Overview of checks: -# -# analyze-actions → CodeQL static analysis on GitHub Actions workflows -# ============================================================ - -jobs: - - # ---------------------------------------------------------- - # GITHUB ACTIONS ANALYSIS - # Scans workflow YAML files for logic errors and misconfigurations. - # ---------------------------------------------------------- - analyze-actions: - name: Analyze GitHub Actions (CodeQL) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - with: - languages: actions - build-mode: none - - - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - with: - category: "/language:actions" diff --git a/.github/workflows/config-validation.yml b/.github/workflows/config-validation.yml new file mode 100644 index 0000000..88e08ed --- /dev/null +++ b/.github/workflows/config-validation.yml @@ -0,0 +1,126 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: Config validation + +# De bot-configs zijn het enige deel van CI dat verder nergens door wordt +# geraakt: een kapotte renovate.json of dependabot.yml laat geen build falen, +# die houdt gewoon stilletjes op met zijn werk. Deze workflow merkt dat op. + +on: + push: + branches: [main] + paths: + - 'renovate.json' + - '.github/renovate.json' + - '.github/dependabot.yml' + - '.github/dependabot.yaml' + - '.github/scripts/check-renovate-patterns.py' + # Broader than the other repos: the actionlint job below covers every + # workflow, so every workflow change is relevant here. + - '.github/workflows/**' + pull_request: + branches: [main] + paths: + - 'renovate.json' + - '.github/renovate.json' + - '.github/dependabot.yml' + - '.github/dependabot.yaml' + - '.github/scripts/check-renovate-patterns.py' + # Broader than the other repos: the actionlint job below covers every + # workflow, so every workflow change is relevant here. + - '.github/workflows/**' + workflow_dispatch: + +permissions: {} + +jobs: + bot-configs: + name: Renovate and Dependabot config + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 'lts/*' + + # Renovates eigen validator. --strict laat hem ook falen op warnings, + # bijvoorbeeld een optie die geldig maar verouderd is. Zonder argumenten + # zoekt hij de configbestanden zelf op en valideert hij ze als + # repository-config; geef je een pad mee, dan valideert hij ze als + # global config, en dat is een andere en zwakkere set regels. + # + # Bewust niet vastgezet. Dit is een linter op onze eigen config en geen + # onderdeel van wat we uitleveren, en juist de nieuwste release kent de + # nieuwste deprecations. Zijn eigen versie is geen pull request waard. + # + # NPM_CONFIG_LOGLEVEL: npm print "npm warn deprecated ..." voor packages + # diep in Renovates eigen dependency-boom. Die zeggen niets over de config + # die gevalideerd wordt, en ze lezen alsof dat wel zo is, is de fout die + # je vanzelf maakt als ze vlak boven de output van de validator staan. + - name: Validate Renovate config + env: + NPM_CONFIG_LOGLEVEL: error + run: npx --yes --package renovate -- renovate-config-validator --strict + + # De validator hierboven accepteert een correct gevormd patroon dat + # nergens op matcht; dit dekt het gat dat hij daarmee laat. + - name: Check Renovate file patterns + run: python3 .github/scripts/check-renovate-patterns.py renovate.json .github/renovate.json + + # GitHub valideert dependabot.yml pas als die op de default branch staat, + # en meldt het resultaat op een tabblad dat niemand opent. Dit haalt dat + # naar voren, naar de pull request. + - name: Validate Dependabot config + env: + # renovate: datasource=pypi depName=check-jsonschema + CHECK_JSONSCHEMA_VERSION: "0.38.0" + run: | + config="" + for candidate in .github/dependabot.yml .github/dependabot.yaml; do + if [ -f "$candidate" ]; then + config="$candidate" + break + fi + done + if [ -z "$config" ]; then + echo "No dependabot.yml in this repository; nothing to validate." + exit 0 + fi + pipx install "check-jsonschema==${CHECK_JSONSCHEMA_VERSION}" + check-jsonschema --builtin-schema vendor.dependabot "$config" + + # De workflowbestanden zijn ook config. De andere repositories draaien + # actionlint vanuit hun quality-workflow; deze had geen equivalent, dus + # het hoort hier. + # + # Als stap en niet als eigen job: GitHub rekent per job en rondt naar + # boven af op een hele minuut. actionlint is in vijf seconden klaar en + # heeft dezelfde checkout nodig als de stappen hierboven, dus een eigen + # job kostte een volle minuut extra voor niets. + # + # Vanaf hier draait elke stap op !cancelled(), zodat één rode controle de + # andere niet verbergt. De job faalt alsnog zodra er iets fout is. + - name: Install actionlint + if: ${{ !cancelled() }} + env: + # renovate: datasource=github-releases depName=rhysd/actionlint + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" + run: | + curl -sSL --fail-with-body -o actionlint.tar.gz \ + --retry 5 --retry-delay 3 --retry-all-errors \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum -c - + tar -xzf actionlint.tar.gz actionlint + sudo install -m 0755 actionlint /usr/local/bin/actionlint + + - name: Run actionlint + if: ${{ !cancelled() }} + run: actionlint -color diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 1438d8f..0000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Dependency Review - -on: - pull_request: - branches: ["main"] - -# Default minimal permissions — each job overrides only what it needs. -permissions: read-all - -# ---------------------------------------------------------- -# DEPENDENCY REVIEW -# Blocks merging if HIGH or CRITICAL vulnerabilities are -# introduced by a dependency change in a pull request. -# ---------------------------------------------------------- - -jobs: - dependency-review: - name: Dependency review - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Review dependencies - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 - with: - fail-on-severity: high diff --git a/.github/workflows/deploy-bunny.yml b/.github/workflows/deploy-bunny.yml new file mode 100644 index 0000000..36c00f5 --- /dev/null +++ b/.github/workflows/deploy-bunny.yml @@ -0,0 +1,89 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: Deploy to Bunny.net + +on: + push: + branches: ["main"] + paths: + - 'src/**' + - '.github/workflows/deploy-bunny.yml' + workflow_dispatch: + +# Geen token nodig; jobs die dat wel zijn, vragen er expliciet om. +permissions: {} + +concurrency: + group: "deploy" + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + build: + name: Build and deploy to Bunny Storage + runs-on: ubuntu-latest + permissions: + contents: read + env: + HUGO_VERSION: 0.165.0 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # go-version-file houdt de Go van de runner gelijk aan wat src/go.mod + # vraagt. Zonder dit installeert setup-go een oudere Go met + # GOTOOLCHAIN=local, en dan weigert `go` de hextra-module op te halen + # omdat go.mod een nieuwere Go eist dan er staat. + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: src/go.mod + + - name: Install Hugo + run: | + wget -O "${{ runner.temp }}/hugo.deb" \ + "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb" \ + && sudo dpkg -i "${{ runner.temp }}/hugo.deb" + + - name: Build with Hugo + env: + HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache + HUGO_ENVIRONMENT: production + TZ: Europe/Amsterdam + run: | + cd src && hugo \ + --gc \ + --minify \ + --baseURL "https://windeploy.thectic.nl/" + + - name: Upload to Bunny Storage + env: + AWS_ACCESS_KEY_ID: ${{ secrets.BUNNY_STORAGE_ZONE }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.BUNNY_ACCESS_KEY }} + AWS_DEFAULT_REGION: de + STORAGE_ZONE: ${{ secrets.BUNNY_STORAGE_ZONE }} + STORAGE_ENDPOINT: ${{ secrets.BUNNY_STORAGE_ENDPOINT }} + run: | + aws s3 sync src/public/ "s3://${STORAGE_ZONE}/" \ + --endpoint-url "${STORAGE_ENDPOINT}" \ + --delete \ + --no-progress + + - name: Wait for storage replication + run: sleep 15 + + - name: Purge Bunny Pull Zone cache + env: + PULL_ZONE_ID: ${{ secrets.BUNNY_PULL_ZONE_ID }} + API_KEY: ${{ secrets.BUNNY_API_KEY }} + run: | + curl -sS --fail-with-body -X POST \ + "https://api.bunny.net/pullzone/${PULL_ZONE_ID}/purgeCache" \ + -H "AccessKey: ${API_KEY}" \ + -H "Content-Type: application/json" diff --git a/.github/workflows/powershell.yml b/.github/workflows/powershell.yml new file mode 100644 index 0000000..3083bd8 --- /dev/null +++ b/.github/workflows/powershell.yml @@ -0,0 +1,63 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: PSScriptAnalyzer + +# The deployment scripts are the core of this repository, so the PowerShell +# lint keeps its own workflow. Settings live in .github/PSScriptAnalyzerSettings.psd1. +# +# microsoft/psscriptanalyzer-action is not certified by GitHub and carries no +# version tags, so it is pinned to a commit SHA. + +on: + push: + branches: [main] + paths: + - '**/*.ps1' + - '**/*.psm1' + - '**/*.psd1' + - '.github/workflows/powershell.yml' + pull_request: + branches: [main] + paths: + - '**/*.ps1' + - '**/*.psm1' + - '**/*.psd1' + - '.github/workflows/powershell.yml' + schedule: + - cron: '43 19 * * 3' + workflow_dispatch: + +permissions: {} + +concurrency: + group: powershell-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + psscriptanalyzer: + name: PSScriptAnalyzer + runs-on: ubuntu-latest + permissions: + contents: read + # For the SARIF upload to the Security tab. + security-events: write + steps: + - name: Check out source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run PSScriptAnalyzer + uses: microsoft/psscriptanalyzer-action@6b2948b1944407914a58661c49941824d149734f # v1.1 + with: + path: .\ + recurse: true + settings: .github/PSScriptAnalyzerSettings.psd1 + output: results.sarif + + - name: Upload SARIF results file + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + if: ${{ !cancelled() }} + with: + sarif_file: results.sarif + category: psscriptanalyzer diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..0fa0f86 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,206 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: PR Checks + +on: + pull_request: + branches: [main] + paths: + - 'src/**' + - '*.md' + - '.github/workflows/pr-checks.yml' + +# Snel achter elkaar naar dezelfde pull request pushen startte evenveel volledige +# runs, en de eerste zijn dan al achterhaald. +concurrency: + group: pr-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + # Alle controles op een pull request, in een job. + # + # GitHub rekent per job en rondt elke job naar boven af op een hele minuut. + # Losse jobs voor markdownlint, de Hugo-build en de linkcheck kostten drie + # gefactureerde minuten voor werk dat samen in een minuut klaar is, plus een + # artefact met upload en download om de gebouwde site naar de linkcheck te + # krijgen. In een job leest lychee gewoon de map die de build ernaast zet. + # + # Elke stap draait op !cancelled(), zodat een rode markdownlint de Hugo-build + # niet verbergt. De job faalt alsnog zodra er iets fout is. + # + # De job draagt `pull-requests: write` omdat de laatste stap de checklist in + # de omschrijving bijwerkt. Alle actions staan op een vastgezette SHA. + pr-checks: + name: PR checks + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + env: + HUGO_VERSION: 0.165.0 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # fetch-depth: 0 voor Hugo's .GitInfo en .Lastmod. + fetch-depth: 0 + persist-credentials: false + + # ── 1. Markdown-opmaak ────────────────────────────────────────────────── + - name: Markdown lint + id: markdown + if: ${{ !cancelled() }} + uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0 + with: + globs: | + src/content/**/*.md + *.md + + # ── 2. Elk Engels document heeft een Nederlandse tegenhanger ──────────── + - name: Check every .md has a matching .nl.md + id: bilingual + if: ${{ !cancelled() }} + run: | + missing="" + while IFS= read -r en; do + base="${en%.md}" + nl="${base}.nl.md" + if [ ! -f "$nl" ]; then + missing="$missing\n $en → $nl missing" + fi + done < <(find src/content -name '*.md' ! -name '*.nl.md') + if [ -n "$missing" ]; then + echo -e "::error::Missing Dutch translation(s):$missing" + exit 1 + fi + echo "All content has EN + NL versions." + + # ── 3. Hugo bouwt zonder fouten ───────────────────────────────────────── + # + # go-version-file houdt de Go van de runner gelijk aan wat src/go.mod + # vraagt; anders weigert `go` de hextra-module op te halen. + - name: Setup Go + if: ${{ !cancelled() }} + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: src/go.mod + + - name: Install Hugo + if: ${{ !cancelled() }} + run: | + wget -O "${{ runner.temp }}/hugo.deb" \ + "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb" \ + && sudo dpkg -i "${{ runner.temp }}/hugo.deb" + + - name: Build + id: hugo + if: ${{ !cancelled() }} + env: + HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache + HUGO_ENVIRONMENT: production + TZ: Europe/Amsterdam + run: cd src && hugo --gc --minify --baseURL "http://localhost/" + + # ── 4. Kapotte interne links ──────────────────────────────────────────── + # + # Met de hand geïnstalleerd in plaats van via lycheeverse/lychee-action, + # dat zijn binary met een kale `curl -sfLO` ophaalt: geen retry, en geen + # controle op wat er terugkomt. Vastgezette versie, geverifieerde + # checksum, retry. De checksum wordt op Renovate-PR's herberekend door + # .github/workflows/update-checksums.yml. + - name: Install lychee + if: ${{ !cancelled() }} + env: + # extractVersion: lychee tagt zijn releases als "lychee-v0.24.2" en + # niet als "v0.24.2", dus het standaardpatroon leest de versie er niet + # uit. + # renovate: datasource=github-releases depName=lycheeverse/lychee extractVersion=^lychee-v(?.+)$ + LYCHEE_VERSION: "0.24.2" + # Uit de lychee-x86_64-unknown-linux-gnu.tar.gz.sha256 van de release zelf + LYCHEE_SHA256: "1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a" + run: | + curl -sSL --fail-with-body -o lychee.tar.gz \ + --retry 5 --retry-delay 3 --retry-all-errors \ + "https://github.com/lycheeverse/lychee/releases/download/lychee-v${LYCHEE_VERSION}/lychee-x86_64-unknown-linux-gnu.tar.gz" + echo "${LYCHEE_SHA256} lychee.tar.gz" | sha256sum -c - + tar -xzf lychee.tar.gz lychee-x86_64-unknown-linux-gnu/lychee + sudo install -m 0755 lychee-x86_64-unknown-linux-gnu/lychee /usr/local/bin/lychee + lychee --version + + # lychee in offline modus: elke interne href en src moet uitkomen op een + # bestand dat de build daadwerkelijk heeft opgeleverd. + # + # --index-files: Hugo serveert elke pagina als /index.html, en + # zonder dit stopt lychee bij de map en zijn #fragments naar een andere + # pagina niet te controleren. + # + # De glob staat bewust tussen quotes: zonder quotes vult bash hem eerst + # in, en zonder globstar valt ** terug op een mapniveau. + - name: Check internal links + id: links + if: ${{ !cancelled() }} + run: | + lychee --offline --include-fragments --index-files index.html \ + --root-dir "${GITHUB_WORKSPACE}/src/public" "src/public/**/*.html" + + # ── 5. Checklist in de omschrijving bijwerken ─────────────────────────── + # + # Leest de uitkomst van de stappen hierboven in plaats van van losse jobs. + # Draait op !cancelled() en niet op success(), want juist bij een rode + # controle wil je de checklist bijgewerkt zien. + - name: Update PR checklist + if: ${{ !cancelled() }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RESULT_BILINGUAL: ${{ steps.bilingual.outcome }} + RESULT_HUGO: ${{ steps.hugo.outcome }} + RESULT_LINKS: ${{ steps.links.outcome }} + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + + let body = pr.body || ''; + if (!body.trim()) return; + + const setCheck = (keyword, passed) => { + body = body.replace( + new RegExp(`- \\[[ xX]\\] (.*${keyword}.*)`, 'i'), + `- [${passed ? 'x' : ' '}] $1` + ); + }; + + // Dezelfde typelijst als pr-title.yml en CONTRIBUTING.md. Een scope + // en een `!` voor een breaking change zijn toegestaan: feat(nav)!: ... + const TITLE_RE = + /^(feat|fix|content|docs|chore|refactor|style|revert)(\([^)]+\))?!?: .+/; + + setCheck('PR title follows', TITLE_RE.test(pr.title)); + setCheck('Both EN', process.env.RESULT_BILINGUAL === 'success'); + setCheck('No broken', process.env.RESULT_LINKS === 'success'); + setCheck('Tested locally', process.env.RESULT_HUGO === 'success'); + + // De niet-gekozen types weghalen, maar alleen als er al een gekozen + // is. Zonder die voorwaarde stript de eerste run alle regels weg + // voordat de auteur er een heeft aangevinkt. + const TYPE_LINE = /^- \[([ xX])\] `\w+` —[^\n]*\n?/gm; + const ticked = [...body.matchAll(TYPE_LINE)] + .some(m => m[1].toLowerCase() === 'x'); + if (ticked) { + body = body.replace(/^- \[ \] `\w+` —[^\n]*\n?/gm, ''); + } + + body = body.replace(/\n{3,}/g, '\n\n'); + + if (body !== pr.body) { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + body, + }); + } diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..e7840e9 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,45 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: PR title + +# De titel van de pull request is wat er op main terechtkomt zodra je squasht, +# dus dat is de plek waar Conventional Commits gecontroleerd moet worden en niet +# op de losse commits in de branch. +# +# Renovate levert zijn eigen titels al in dit formaat aan; dat is de +# semanticCommits-instelling in renovate.json. Deze controle dekt de rest. + +on: + pull_request: + # edited hoort erbij: zonder dat blijft de check rood staan nadat iemand de + # titel heeft verbeterd, want een titelwijziging is geen nieuwe push. + types: [opened, edited, synchronize, reopened] + +# Snel achter elkaar de omschrijving aanpassen startte evenveel runs. Alleen +# de laatste zegt nog iets, dus de rest mag weg. +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + pr-title: + name: Conventional commit title + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + content + docs + chore + refactor + style + revert diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml deleted file mode 100644 index eb38b86..0000000 --- a/.github/workflows/security.yml +++ /dev/null @@ -1,162 +0,0 @@ -name: Security scanning - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - workflow_dispatch: - -# Default minimal permissions — each job overrides only what it actually needs. -permissions: read-all - -# Cancel in-progress runs for the same branch/PR when a new push arrives. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -# ============================================================ -# Overview of checks: -# -# powershell-lint → PSScriptAnalyzer (all .ps1 scripts) -# trivy-scan → Trivy (filesystem vulnerability scan) -# devskim → DevSkim (insecure code patterns in PowerShell) -# semgrep → Semgrep (OWASP Top 10, secrets detection) -# actionlint → actionlint (validates GitHub Actions workflow YAML) -# ============================================================ - -jobs: - - # ---------------------------------------------------------- - # POWERSHELL LINT - # Runs PSScriptAnalyzer on all PowerShell scripts. - # Settings: .github/PSScriptAnalyzerSettings.psd1 - # ---------------------------------------------------------- - powershell-lint: - name: PowerShell lint (PSScriptAnalyzer) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run PSScriptAnalyzer (SARIF output) - uses: microsoft/psscriptanalyzer-action@6b2948b1944407914a58661c49941824d149734f # v1.1 - with: - path: ./ - recurse: true - settings: .github/PSScriptAnalyzerSettings.psd1 - output: psscriptanalyzer-results.sarif - - - name: Upload PSScriptAnalyzer results to GitHub Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - with: - sarif_file: psscriptanalyzer-results.sarif - category: psscriptanalyzer - - # ---------------------------------------------------------- - # TRIVY SECURITY SCAN - # Scans the repository for HIGH/CRITICAL vulnerabilities. - # ---------------------------------------------------------- - trivy-scan: - name: Security scan (Trivy) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run Trivy filesystem scan - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - scan-type: 'fs' - scan-ref: '.' - severity: 'HIGH,CRITICAL' - format: 'sarif' - output: 'trivy-results.sarif' - exit-code: '1' - - - name: Upload Trivy results to GitHub Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - if: always() - with: - sarif_file: 'trivy-results.sarif' - category: 'trivy' - - # ---------------------------------------------------------- - # DEVSKIM SECURITY LINT - # Catches insecure patterns in PowerShell: deprecated crypto, - # injection risks, unsafe APIs. - # ---------------------------------------------------------- - devskim: - name: Security lint (DevSkim) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run DevSkim - uses: microsoft/DevSkim-Action@4b5047945a44163b94642a1cecc0d93a3f428cc6 # v1.0.16 - - - name: Upload DevSkim results to GitHub Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - if: always() - with: - sarif_file: devskim-results.sarif - category: devskim - - # ---------------------------------------------------------- - # SEMGREP - # Detects hardcoded secrets and OWASP Top 10 patterns. - # Never blocks CI — findings go to Security tab only. - # ---------------------------------------------------------- - semgrep: - name: Semgrep (OWASP / secrets) - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Semgrep - run: pip install semgrep - - - name: Run Semgrep - run: | - semgrep \ - --config auto \ - --config p/owasp-top-ten \ - --config p/secrets \ - --sarif \ - --output semgrep.sarif \ - Scripts/ || true - - - name: Upload Semgrep results to GitHub Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - if: always() - with: - sarif_file: semgrep.sarif - category: semgrep - - # ---------------------------------------------------------- - # ACTIONLINT - # Validates all GitHub Actions workflow YAML files. - # Fails CI so broken workflows are caught before merge. - # ---------------------------------------------------------- - actionlint: - name: GitHub Actions lint (actionlint) - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run actionlint - uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 - with: - args: -color diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index ad51256..0000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Stale - -# Automatically marks inactive issues and pull requests as stale -# and closes them if there is no response. -# -# Issues: stale after 30 days of inactivity, closed after 14 more days -# PRs: stale after 30 days of inactivity, closed after 14 more days - -on: - schedule: - - cron: "0 2 * * *" # Daily at 02:00 UTC - workflow_dispatch: - -permissions: - issues: write - pull-requests: write - -jobs: - stale: - name: Mark and close stale issues and PRs - runs-on: ubuntu-latest - steps: - - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 - with: - # ── Issues ────────────────────────────────────────────────── - days-before-issue-stale: 30 - days-before-issue-close: 14 - stale-issue-label: "stale" - stale-issue-message: > - This issue has not been updated in 30 days and has been marked as **stale**. - If there is no activity within 14 days it will be closed automatically. - Reply or remove the `stale` label to keep it open. - close-issue-message: > - This issue has been automatically closed due to inactivity. - Feel free to reopen it if it is still relevant. - - # ── Pull Requests ──────────────────────────────────────────── - days-before-pr-stale: 30 - days-before-pr-close: 14 - stale-pr-label: "stale" - stale-pr-message: > - This pull request has not been updated in 30 days and has been marked as **stale**. - If there is no activity within 14 days it will be closed automatically. - Reply or remove the `stale` label to keep it open. - close-pr-message: > - This pull request has been automatically closed due to inactivity. - Feel free to reopen it if the work is still relevant. - - # ── Exemptions ─────────────────────────────────────────────── - exempt-issue-labels: "pinned,security,breaking-change" - exempt-pr-labels: "pinned,security,breaking-change" - - operations-per-run: 30 diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml new file mode 100644 index 0000000..6422c97 --- /dev/null +++ b/.github/workflows/trivy-scan.yml @@ -0,0 +1,32 @@ +name: "Trivy filesystem scan" + +on: + schedule: + - cron: '0 2 * * 0' + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + trivy-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run Trivy filesystem scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + severity: CRITICAL,HIGH + format: sarif + output: trivy-results.sarif + + - name: Upload Trivy results to GitHub Security tab + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + if: always() + with: + sarif_file: trivy-results.sarif diff --git a/.github/workflows/update-checksums.yml b/.github/workflows/update-checksums.yml new file mode 100644 index 0000000..3017d10 --- /dev/null +++ b/.github/workflows/update-checksums.yml @@ -0,0 +1,85 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT +name: Update tool SHA256 checksums + +# Renovate bumpt de vastgezette toolversies maar kan geen checksum berekenen, +# dus op eigen kracht landt elke bump met de hash van de vorige release er nog +# in, en stopt de build op "computed checksum did NOT match". Dit herberekent +# de hashes op Renovates pull requests en commit ze terug op de branch. +# +# Renovate moet die commits leren negeren, anders ziet hij de branch als door +# iemand anders gewijzigd en onderhoudt hij de pull request niet meer. Dat is +# de gitIgnoredAuthors-regel in renovate.json. + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: [main] + paths: + - '.github/workflows/config-validation.yml' + - '.github/workflows/pr-checks.yml' + - '.github/scripts/update-tool-checksums.sh' + +permissions: {} + +jobs: + update-checksums: + name: Recalculate SHA256 checksums + runs-on: ubuntu-latest + # Alleen Renovates eigen branches. Dit op een pull request van een mens + # draaien betekent commits pushen naar een branch waar iemand op dat moment + # aan werkt. + # + # De auteur van de pull request, niet github.actor. actor is degene die het + # meest recente event veroorzaakte, en dat is bij een synchronize degene die + # als laatste pushte; dat vergelijken met een botnaam is een controle die + # zizmor terecht spoofbaar noemt. De auteur ligt vast zodra de pull request + # geopend wordt en is niet naar een ander account te zetten. + # + # Also require the head repo to be this repo, not a fork. This job checks + # out github.head_ref and runs a script from it with a write token, so a + # PR from a fork naming its branch renovate/* would otherwise get its + # attacker-controlled script executed with push access. + if: >- + startsWith(github.head_ref, 'renovate/') && + github.event.pull_request.user.login == 'renovate[bot]' && + github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: write + steps: + # persist-credentials: false, ook al pusht deze job. Anders staat het + # token de hele job in .git/config, ook terwijl het script hieronder + # release-tarballs van internet haalt. De push-stap krijgt het token in + # plaats daarvan expliciet mee, voor precies één commando. + # Pinned to the exact commit the pull_request event fired for, not the + # mutable branch name. head_ref is a moving target: a push to the + # renovate/* branch between the job's "if:" check above and this step + # would check out commits that check never evaluated. head.sha is fixed + # in the event payload, so it can't move underneath the job. + - name: Check out the pull request branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + # Het script controleert elke download tegen de checksum die het project + # naast de release publiceert voordat er iets wordt weggeschreven, dus een + # hash landt hier alleen als upstream er ook voor instaat. + - name: Recalculate and apply checksums + run: .github/scripts/update-tool-checksums.sh --apply + + - name: Commit updated checksums + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: ${{ github.head_ref }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .github/workflows/config-validation.yml .github/workflows/pr-checks.yml + if git diff --staged --quiet; then + echo "Checksums are already up to date, nothing to commit." + else + git commit -m "chore: update tool SHA256 checksums" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/${BRANCH}" + fi diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ea4dbd2..430d1e5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,34 +1,46 @@ +# Copyright (C) 2026 Sten Tijhuis +# SPDX-License-Identifier: MIT name: Validate scripts +# A full deployment cannot run on GitHub-hosted runners (it needs Windows 11 +# 25H2 and real hardware for drivers/BitLocker), but syntax errors, missing +# functions and broken helper logic are caught here before they reach a +# production machine. Windows-latest, not ubuntu: the syntax check wants +# Windows PowerShell/PS7 parsing semantics, and the hostname/architecture +# checks read real Win32_BIOS/PROCESSOR_ARCHITECTURE values. +# +# One job rather than two: GitHub bills per job, rounded up to a whole minute, +# and both halves need the same windows-latest checkout. + on: push: - branches: ["**"] + branches: [main] + paths: + - 'Scripts/**' + - '.github/workflows/validate.yml' pull_request: - branches: ["**"] + branches: [main] + paths: + - 'Scripts/**' + - '.github/workflows/validate.yml' workflow_dispatch: -permissions: read-all +permissions: {} -# ============================================================ -# Validates that all PowerShell scripts parse and load without -# errors. A full deployment cannot run on GitHub-hosted runners -# (requires Windows 11 25H2 + real hardware for drivers/BitLocker), -# but syntax and logic errors are caught here before they reach -# a production machine. -# ============================================================ +concurrency: + group: validate-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: - - # ---------------------------------------------------------- - # SYNTAX CHECK - # Parses every .ps1 file with PowerShell 7 without executing. - # Fails if any file contains a syntax error. - # ---------------------------------------------------------- - syntax-check: - name: PowerShell syntax check + validate: + name: Syntax and helper function checks runs-on: windows-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Check PowerShell version shell: pwsh @@ -64,17 +76,6 @@ jobs: Write-Host "All scripts passed syntax check." -ForegroundColor Green - # ---------------------------------------------------------- - # HELPER FUNCTION TESTS - # Dot-sources helper functions from Deploy.ps1 and Start.ps1 - # and exercises non-destructive logic in a dry-run context. - # ---------------------------------------------------------- - helper-tests: - name: PowerShell helper function tests - runs-on: windows-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Test Deploy.ps1 helper functions shell: pwsh run: | diff --git a/.gitignore b/.gitignore index f7c709a..2f79f19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# Hugo +/src/public/ +/src/resources/ +/src/_vendor/ +src/.hugo_build.lock + # PowerShell *.ps1xml diff --git a/.lychee.toml b/.lychee.toml new file mode 100644 index 0000000..3cf93a0 --- /dev/null +++ b/.lychee.toml @@ -0,0 +1,10 @@ +# Lychee link checker configuration +# Used by the PR checks workflow (offline mode — internal links only) + +# Skip anchors that Hugo generates dynamically +include_fragments = true + +# Don't fail on these — they're valid Hugo internal paths +exclude = [ + "^http://localhost", +] diff --git a/.markdownlint.yml b/.markdownlint.yml new file mode 100644 index 0000000..a4ab999 --- /dev/null +++ b/.markdownlint.yml @@ -0,0 +1,55 @@ +default: true + +# ── Disabled: not applicable to Hugo docs ────────────────────────────────── + +# Line length — docs have long tables, code examples, and URLs +MD013: false + +# Inline HTML — Hugo shortcodes ({{< callout >}}, {{% steps %}}) trigger this +MD033: false + +# First line must be H1 — files start with YAML front matter +MD041: false + +# Bare URLs — allowed in code blocks and examples +MD034: false + +# Multiple H1s — Hugo steps/details shortcodes contain headings +MD025: false + +# Blank lines around fences — common inside Hugo shortcode blocks +MD031: false + +# Blank lines around lists — pervasive in existing content +MD032: false + +# Multiple consecutive blank lines — style preference, not a bug +MD012: false + +# Blank line inside blockquote — Hugo callout/details shortcodes trigger this +MD028: false + +# Ordered list prefix style — 1/2/3 vs 1/1/1 is a style choice +MD029: false + +# Emphasis used instead of heading — intentional in existing docs +MD036: false + +# Code block language — desirable but too many pre-existing violations +MD040: false + +# Headings surrounded by blank lines — pre-existing violations in some docs +MD022: false + +# Heading levels increment by one — Hugo docs intentionally start at ### because +# h1/h2 are rendered by the theme template, not the content file +MD001: false + +# Duplicate headings — only flag duplicates within the same section, not across +# different top-level sections (e.g. two separate "### Install" blocks are fine) +MD024: + siblings_only: true + +# Table column style (pipe spacing/alignment) — overly pedantic, tables render +# correctly regardless of exact pipe spacing +MD060: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 1708011..e23ceeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.9.0] - 2026-09-02 + +Repository moved from `Stensel8/WinDeploy` to `Thectic-NL/WinDeploy`. + +### Added +- `src/`. A Hugo landing page for windeploy.thectic.nl (English + Dutch), matching the site structure already in use for [BypassNRO](https://github.com/Thectic-NL/BypassNRO). It documents the project; it does not host the deployment scripts. `Scripts/` and `Docs/` are unchanged in location and behaviour, and GitHub Releases stays the distribution mechanism, so a run started against one release tag keeps using that tag's scripts throughout, even if `main` changes mid-run. +- `.github/workflows/deploy-bunny.yml`, `pr-checks.yml`, `pr-title.yml`, `config-validation.yml`, `trivy-scan.yml`, `update-checksums.yml`, `.github/scripts/`. CI for the new site, matching the shared org convention. +- `renovate.json`: `gomod` and `custom.regex` managers, for the site's Go module and the hand-pinned tool versions in the new workflows. + +### Changed +- All `Stensel8/WinDeploy` references (README, SECURITY.md, `Docs/autounattend.xml`, `Scripts/Start.ps1`, `Scripts/Deploy.ps1`, old changelog release links) now point at `Thectic-NL/WinDeploy`. +- `.github/workflows/validate.yml`: the syntax-check and helper-function-test jobs merged into one job (same runner, one less billed minute), path-scoped to `Scripts/**`, otherwise unchanged. +- `CONTRIBUTING.md`, `.github/pull_request_template.md`: extended with the site's bilingual-content and Hugo-build checks, alongside the existing Windows-testing requirement, which stays required for anything under `Scripts/` or `Docs/`. + +### Removed +- `.github/workflows/codeql.yml`, `dependency-review.yml`, `security.yml`, `stale.yml`, in favour of the leaner CI set above (PSScriptAnalyzer + Trivy + actionlint, no DevSkim/Semgrep/CodeQL-for-Actions/dependency-review/stale-bot). This mirrors what already happened to BypassNRO; flagging it here since it is a real reduction in automated security-scanning coverage, not just a rename. + +### Notes +- `windeploy.stensel.nl` (the "Option 3" one-liner in the README) is an external redirect that pointed at the old repository. It is not part of this repository and needs to be repointed or retired separately. + +--- + ## [0.8.0] - 2026-08-29 ### Added @@ -162,7 +184,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Bitlocker enablement issue: Sometimes Bitlocker failed to enable due to the TPM not being ready. -- Fixed an issue with RMM Agents not installing correctly. [#11](https://github.com/Stensel8/WinDeploy/issues/11) +- Fixed an issue with RMM Agents not installing correctly. [#11](https://github.com/Thectic-NL/WinDeploy/issues/11) - Fixed a rare hang where deployment would stall after detecting the RMM installer on USB. The installer was being invoked via PowerShell incorrectly which could prevent it from receiving silent switches; changed to run the installer directly and wait for completion, added longer timeouts and improved logging. ## [0.5.6] - 2025-11-20 @@ -284,16 +306,18 @@ First open-source release of WinDeploy - Windows Deployment Automation Toolkit. --- -[0.7.3]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.7.3 -[0.7.2]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.7.2 -[0.7.1]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.7.1 -[0.7.0]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.7.0 -[0.6.1]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.6.1 -[0.6.0]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.6.0 -[0.5.5]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.5.5 -[0.5.4]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.5.4 -[0.5.3]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.5.3 -[0.5.2]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.5.2 -[0.5.0]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.5.0 -[0.1.2]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.1.2 -[0.1.1]: https://github.com/Stensel8/WinDeploy/releases/tag/v0.1.1 +[0.9.0]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.9.0 +[0.8.0]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.8.0 +[0.7.3]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.7.3 +[0.7.2]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.7.2 +[0.7.1]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.7.1 +[0.7.0]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.7.0 +[0.6.1]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.6.1 +[0.6.0]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.6.0 +[0.5.5]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.5.5 +[0.5.4]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.5.4 +[0.5.3]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.5.3 +[0.5.2]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.5.2 +[0.5.0]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.5.0 +[0.1.2]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.1.2 +[0.1.1]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.1.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39c93db..51a27f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,27 +2,73 @@ Thanks. Small, tested PRs help most. -### Quick start -1. Fork → branch → change → PR. -2. Test on Windows 11. - -### Guidelines -- Commit: `: short summary` (feat, fix, docs, style, refactor, test, chore). -- Follow PowerShell conventions (PascalCase funcs, camelCase vars, verb-noun, comment-based help). -- Avoid hard-coded paths; use config/variables. - -### Testing -- Test on a clean Windows 11 machine. -- Run PSScriptAnalyzer: Install-Module PSScriptAnalyzer; Invoke-ScriptAnalyzer -Path .\ -Recurse. -- No syntax errors; logs show expected behavior. - -### Pull requests -- Update docs if needed, include test notes and related issue references. -- Checklist: tested, linter passed, clear description/screenshots. - -### Issues -Include: short description, reproduction steps, expected vs actual, logs and env (Windows/PowerShell versions). - -### Allowed / Not allowed -Welcome: bug fixes, docs, tests, small features. -Not accepted: malware, proprietary deps, breaking changes without prior discussion. +## Commit messages + +This project uses [Conventional Commits](https://www.conventionalcommits.org/). + +**Format:** +``` +: +``` + +**Types:** + +| Type | When to use | +|------|-------------| +| `feat` | New feature or script | +| `fix` | Bug fix | +| `content` | Update or improve the site's landing page content | +| `docs` | Changes to README, CONTRIBUTING, or other meta files | +| `chore` | Maintenance — dependencies, config, CI/CD | +| `refactor` | Restructure without changing behaviour | +| `style` | Formatting, whitespace, typo fixes | +| `revert` | Reverting a previous commit | + +**Rules:** +- Use lowercase for the type and description +- Keep the subject line under 72 characters +- No period at the end +- Use the imperative mood ("add", "fix", "update" — not "added", "fixed") + +## PowerShell conventions + +- PascalCase functions, camelCase variables, `Verb-Noun` naming, comment-based help +- Avoid hard-coded paths; use config/variables +- Run PSScriptAnalyzer before opening a PR: `Install-Module PSScriptAnalyzer; Invoke-ScriptAnalyzer -Path .\ -Recurse -Settings .github/PSScriptAnalyzerSettings.psd1` + +## Testing + +- **Test on a real Windows 11 25H2 machine.** Nothing here runs in CI — GitHub-hosted runners can't exercise BitLocker, TPM, Sysprep, WinGet installs or Defender ASR — so this is the only way a change is actually verified. +- No syntax errors; logs in `C:\WinDeploy\Logs\` show the expected behaviour. + +## Pull requests + +- PR titles follow the commit convention above +- One logical change per PR +- If you touch `src/content/`, update both EN (`*.md`) and NL (`*.nl.md`) versions +- Test the site locally with `cd src && hugo server` before opening a PR that touches `src/` + +## Project layout + +``` +Scripts/ Deployment scripts (Start.ps1, Deploy.ps1, Scripts/Deployment/*) +Docs/ autounattend.xml and Intune setup docs +src/ The Hugo site (windeploy.thectic.nl) — landing page only. + Scripts/ and Docs/ are NOT duplicated here; the site + links to GitHub Releases, which stays the source of + truth for what actually gets deployed. + src/content/ Landing page content (_index.md EN, _index.nl.md NL) + src/layouts/ Template overrides on top of the Hextra theme + src/hugo.toml Site configuration +``` + +The site is built and deployed to Bunny.net from `.github/workflows/deploy-bunny.yml` on every push to `main` that touches `src/`. It is bilingual (EN + NL); keep structure and headings in sync between the two versions of a page. + +## Issues + +Include: short description, reproduction steps, expected vs actual, logs and environment (Windows/PowerShell versions). + +## Allowed / not allowed + +Welcome: bug fixes, docs, tests, small features. +Not accepted: malware, proprietary dependencies, breaking changes without prior discussion. diff --git a/Docs/autounattend.xml b/Docs/autounattend.xml index 59cbbb5..1859902 100644 --- a/Docs/autounattend.xml +++ b/Docs/autounattend.xml @@ -1,6 +1,6 @@ - + @@ -568,7 +568,7 @@ Install-Script winget-install -Force winget-install -Force -& ([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 +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$((irm https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1"))) -NonInteractive $scripts = @( diff --git a/README.md b/README.md index ca3127b..4067c74 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ [![PowerShell 7.0+](https://img.shields.io/badge/PowerShell-7.0+-blue.svg)](https://github.com/PowerShell/PowerShell) [![Windows 11 25H2](https://img.shields.io/badge/Windows-11_25H2-0078D6.svg)](https://www.microsoft.com/windows) -[![Security scanning](https://github.com/Stensel8/WinDeploy/actions/workflows/security.yml/badge.svg)](https://github.com/Stensel8/WinDeploy/actions/workflows/security.yml) -[![Validate scripts](https://github.com/Stensel8/WinDeploy/actions/workflows/validate.yml/badge.svg)](https://github.com/Stensel8/WinDeploy/actions/workflows/validate.yml) -[![CodeQL](https://github.com/Stensel8/WinDeploy/actions/workflows/codeql.yml/badge.svg)](https://github.com/Stensel8/WinDeploy/actions/workflows/codeql.yml) -[![Dependabot Updates](https://github.com/Stensel8/WinDeploy/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/Stensel8/WinDeploy/actions/workflows/dependabot/dependabot-updates) +[![Security scanning](https://github.com/Thectic-NL/WinDeploy/actions/workflows/security.yml/badge.svg)](https://github.com/Thectic-NL/WinDeploy/actions/workflows/security.yml) +[![Validate scripts](https://github.com/Thectic-NL/WinDeploy/actions/workflows/validate.yml/badge.svg)](https://github.com/Thectic-NL/WinDeploy/actions/workflows/validate.yml) +[![CodeQL](https://github.com/Thectic-NL/WinDeploy/actions/workflows/codeql.yml/badge.svg)](https://github.com/Thectic-NL/WinDeploy/actions/workflows/codeql.yml) +[![Dependabot Updates](https://github.com/Thectic-NL/WinDeploy/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/Thectic-NL/WinDeploy/actions/workflows/dependabot/dependabot-updates) Zero-touch Windows deployment with automatic driver updates, application installation, bloatware removal, and system configuration. Deploy via USB, network, RMM agents, or AutoUnattend.xml. @@ -47,10 +47,14 @@ Zero-touch Windows deployment with automatic driver updates, application install ### Option 2: Direct Execution ```powershell # Run as Administrator in PowerShell 7 -iex (irm "https://raw.githubusercontent.com/Stensel8/WinDeploy/$((irm https://api.github.com/repos/Stensel8/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") +iex (irm "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$((irm https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") ``` ### Option 3: One-liner + +> [!NOTE] +> `windeploy.stensel.nl` pointed at Option 2 before this project moved to THectic-NL. That redirect needs to be repointed (or retired) separately — it isn't part of this repository. + ```powershell # Run as Administrator in PowerShell 7 iex (irm windeploy.stensel.nl) @@ -257,8 +261,8 @@ winget-install Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). -- **Issues**: [GitHub Issues](https://github.com/Stensel8/WinDeploy/issues) -- **Discussions**: [GitHub Discussions](https://github.com/Stensel8/WinDeploy/discussions) +- **Issues**: [GitHub Issues](https://github.com/Thectic-NL/WinDeploy/issues) +- **Discussions**: [GitHub Discussions](https://github.com/Thectic-NL/WinDeploy/discussions) ## Disclaimer diff --git a/SECURITY.md b/SECURITY.md index 0bf7319..3021401 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ **Do not open a public GitHub issue for security vulnerabilities.** -Report privately via [GitHub Security Advisories](https://github.com/Stensel8/WinDeploy/security/advisories/new). +Report privately via [GitHub Security Advisories](https://github.com/Thectic-NL/WinDeploy/security/advisories/new). Include: - Description of the vulnerability diff --git a/Scripts/Archived/Get-InstalledSoftware.ps1 b/Scripts/Archived/Get-InstalledSoftware.ps1 index 982033a..58df5e1 100644 --- a/Scripts/Archived/Get-InstalledSoftware.ps1 +++ b/Scripts/Archived/Get-InstalledSoftware.ps1 @@ -6,7 +6,7 @@ .TAGS PowerShell Windows Software Inventory Registry AppX Reporting -.PROJECTURI https://github.com/Stensel8/WinDeploy +.PROJECTURI https://github.com/Thectic-NL/WinDeploy #> @@ -30,7 +30,7 @@ Requires : PowerShell 5.1+ .LINK - Project Site: https://github.com/Stensel8/WinDeploy + Project Site: https://github.com/Thectic-NL/WinDeploy #> Set-StrictMode -Version Latest diff --git a/Scripts/Deploy.ps1 b/Scripts/Deploy.ps1 index cff7e16..4554a60 100644 --- a/Scripts/Deploy.ps1 +++ b/Scripts/Deploy.ps1 @@ -62,7 +62,7 @@ function Get-LatestRelease { for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { try { - $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Stensel8/WinDeploy/releases/latest" -ErrorAction Stop + $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest" -ErrorAction Stop if ($latestRelease.tag_name) { return $latestRelease.tag_name } @@ -92,7 +92,7 @@ if (!$releaseTag) { Write-Output "Please check:" Write-Output " 1. Your internet connection" Write-Output " 2. GitHub API accessibility" - Write-Output " 3. Repository has published releases: github.com/Stensel8/WinDeploy/releases" + Write-Output " 3. Repository has published releases: github.com/Thectic-NL/WinDeploy/releases" Wait-ForExit exit 1 } @@ -100,7 +100,7 @@ if (!$releaseTag) { # Read version $version = $null try { - $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue + $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue $version = $version.Trim() if ($version) { Write-Output "Version: $version" @@ -203,7 +203,7 @@ function Get-DeploymentScript { for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/Scripts/Deployment/$ScriptName" -OutFile $LocalPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/Scripts/Deployment/$ScriptName" -OutFile $LocalPath -UseBasicParsing -ErrorAction Stop Write-DeployLog "Downloaded $ScriptName to $LocalPath" return $true } catch { @@ -301,7 +301,7 @@ try { $downloaded = $false for ($attempt = 1; $attempt -le 3; $attempt++) { try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/Scripts/Archived/Fix-Spotlight.ps1" -OutFile $fixScriptPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/Scripts/Archived/Fix-Spotlight.ps1" -OutFile $fixScriptPath -UseBasicParsing -ErrorAction Stop Write-DeployLog "Downloaded Fix-Spotlight.ps1 to $fixScriptPath as an optional script to fix missing Spotlight options on the system. This can be run manually if needed." $downloaded = $true break diff --git a/Scripts/Start.ps1 b/Scripts/Start.ps1 index d84e139..0f437f6 100644 --- a/Scripts/Start.ps1 +++ b/Scripts/Start.ps1 @@ -17,7 +17,7 @@ function Get-LatestRelease { for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { try { - $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Stensel8/WinDeploy/releases/latest" -ErrorAction Stop + $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest" -ErrorAction Stop if ($latestRelease.tag_name) { return $latestRelease.tag_name } @@ -50,14 +50,14 @@ if (!$releaseTag) { Write-Host "Releases are required for deployment. Please check:" -ForegroundColor Yellow Write-Host " 1. Your internet connection" -ForegroundColor Yellow Write-Host " 2. GitHub API accessibility" -ForegroundColor Yellow - Write-Host " 3. Repository has published releases: github.com/Stensel8/WinDeploy/releases" -ForegroundColor Yellow + Write-Host " 3. Repository has published releases: github.com/Thectic-NL/WinDeploy/releases" -ForegroundColor Yellow exit 1 } # Read version $version = $null try { - $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue + $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue $version = $version.Trim() } catch { Write-Warning "Failed to fetch version information." @@ -227,7 +227,7 @@ if (-not $isAdmin) { # Script run via iex - download to temp $scriptPath = [System.IO.Path]::GetTempFileName() + ".ps1" try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop } catch { Write-Host "Failed to download Start.ps1: $_" -ForegroundColor Red exit 1 @@ -265,7 +265,7 @@ if (-not $isPwsh7) { # Script run via iex - download to temp $scriptPath = [System.IO.Path]::GetTempFileName() + ".ps1" try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop } catch { Write-Host "Failed to download Start.ps1: $_" -ForegroundColor Red exit 1 @@ -303,7 +303,7 @@ $downloadSuccess = $false for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { try { Write-Host "Downloading Deploy.ps1 (Attempt $attempt of $maxRetries)..." -ForegroundColor Cyan - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Stensel8/WinDeploy/$releaseTag/Scripts/Deploy.ps1" -OutFile $deployPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/Scripts/Deploy.ps1" -OutFile $deployPath -UseBasicParsing -ErrorAction Stop Write-Host "Downloaded Deploy.ps1 to $deployPath" -ForegroundColor Green $downloadSuccess = $true break diff --git a/VERSION b/VERSION index b19b521..f979ade 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.8.0 +v0.9.0 diff --git a/renovate.json b/renovate.json index f5467c4..39efb16 100644 --- a/renovate.json +++ b/renovate.json @@ -1,41 +1,94 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended" - ], - "timezone": "Europe/Amsterdam", - "forkProcessing": "enabled", - "pinDigests": true, - "assigneesFromCodeOwners": true, - "reviewersFromCodeOwners": true, - "enabledManagers": [ - "github-actions" - ], - "labels": [ - "dependencies" - ], - "packageRules": [ - { - "matchManagers": [ - "dockerfile", - "docker-compose" - ], - "groupName": "Docker images", - "addLabels": [ - "docker" - ] - }, - { - "matchManagers": [ - "github-actions" - ], - "groupName": "GitHub Actions", - "addLabels": [ - "github-actions" - ] - } - ], - "automerge": true, - "automergeType": "pr", - "semanticCommits": "enabled" +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "timezone": "Europe/Amsterdam", + "forkProcessing": "enabled", + "pinDigests": true, + "assigneesFromCodeOwners": true, + "reviewersFromCodeOwners": true, + "gitIgnoredAuthors": [ + "github-actions[bot]@users.noreply.github.com", + "41898282+github-actions[bot]@users.noreply.github.com" + ], + "enabledManagers": [ + "github-actions", + "gomod", + "custom.regex" + ], + "prHourlyLimit": 2, + "prConcurrentLimit": 5, + "labels": [ + "dependencies" + ], + "packageRules": [ + { + "matchManagers": [ + "dockerfile", + "docker-compose" + ], + "groupName": "Docker images", + "addLabels": [ + "docker" + ] + }, + { + "matchManagers": [ + "github-actions" + ], + "groupName": "GitHub Actions", + "addLabels": [ + "github-actions" + ] + }, + { + "matchManagers": [ + "gomod" + ], + "groupName": "Go modules", + "addLabels": [ + "go" + ] + }, + { + "description": "Versions pinned by hand in the workflows. Not automerged: actionlint and lychee are pinned alongside a checksum that has to be updated in the same PR.", + "matchManagers": [ + "custom.regex" + ], + "groupName": "Build tooling versions", + "addLabels": [ + "build-tooling" + ], + "automerge": false + } + ], + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": [ + "/^\\.github/workflows/.*\\.ya?ml$/" + ], + "matchStrings": [ + "HUGO_VERSION:\\s*(?\\d+\\.\\d+\\.\\d+)" + ], + "depNameTemplate": "gohugoio/hugo", + "datasourceTemplate": "github-releases", + "versioningTemplate": "semver" + }, + { + "customType": "regex", + "description": "Tool versions pinned in workflows, annotated with a `# renovate:` comment on the line above", + "managerFilePatterns": [ + "/^\\.github/workflows/.*\\.ya?ml$/" + ], + "matchStrings": [ + "# renovate: datasource=(?[a-z-]+) depName=(?\\S+)(?: extractVersion=(?\\S+))?\\s+[A-Za-z_]+: \"(?[^\"]+)\"" + ], + "extractVersionTemplate": "^v?(?.*)$" + } + ], + "automerge": true, + "automergeType": "pr", + "semanticCommits": "enabled" } diff --git a/src/assets/css/custom.css b/src/assets/css/custom.css new file mode 100644 index 0000000..473f0b5 --- /dev/null +++ b/src/assets/css/custom.css @@ -0,0 +1,92 @@ +/* Overrides Hextra's empty custom.css stub. Concatenated after the theme's + precompiled Tailwind bundle without ever going through Tailwind itself, so + plain CSS here is the only reliable way to style markup that isn't part of + the theme's own shipped templates -- see the comment in + layouts/_partials/navbar-title.html. */ + +.brand-lockup { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} + +.brand-mark, +.brand-title { + display: flex; + align-items: center; + transition: opacity 0.15s ease; +} + +.brand-mark { + flex-shrink: 0; +} + +.brand-mark:hover, +.brand-title:hover { + opacity: 0.75; +} + +.brand-title span { + font-weight: 800; + user-select: none; +} + +/* GitHub-style hover card on the monogram tile: appears on hover and on + keyboard focus, after a short delay. Standardised across the THectic repos -- + keep this block together with navbar-title.html. */ +.brand-home { + position: relative; + display: inline-flex; +} + +.brand-home-card { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + z-index: 30; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.75rem; + line-height: 1; + font-weight: 600; + white-space: nowrap; + color: #f6f8fa; + background: #1f2328; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + opacity: 0; + transform: translateY(-0.25rem); + pointer-events: none; + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.brand-home-card::before { + content: ""; + position: absolute; + bottom: 100%; + left: 0.75rem; + border: 0.3125rem solid transparent; + border-bottom-color: #1f2328; +} + +.brand-home:hover .brand-home-card, +.brand-home:focus-within .brand-home-card { + opacity: 1; + transform: translateY(0); + transition-delay: 0.35s; +} + +.dark .brand-home-card { + color: #1f2328; + background: #f6f8fa; +} + +.dark .brand-home-card::before { + border-bottom-color: #f6f8fa; +} + +@media (prefers-reduced-motion: reduce) { + .brand-home-card { + transition: none; + } +} diff --git a/src/content/_index.md b/src/content/_index.md new file mode 100644 index 0000000..32366b5 --- /dev/null +++ b/src/content/_index.md @@ -0,0 +1,70 @@ +--- +title: "" +toc: false +--- + +
+{{< hextra/hero-headline >}} + WinDeploy +{{< /hextra/hero-headline >}} +
+ +
+{{< hextra/hero-subtitle >}} + Zero-touch Windows 11 deployment: drivers, applications, bloatware removal, security hardening and BitLocker +{{< /hextra/hero-subtitle >}} +
+ +
+{{< hextra/hero-badge link="https://github.com/Thectic-NL/WinDeploy#quick-start" >}} + Quick start + {{< icon name="arrow-right" attributes="height=20" >}} +{{< /hextra/hero-badge >}} +{{< hextra/hero-badge link="https://github.com/Thectic-NL/WinDeploy" >}} + View on GitHub + {{< icon name="github" attributes="height=20" >}} +{{< /hextra/hero-badge >}} +
+ +
+ +## Run it + +As Administrator, in PowerShell 7: + +```powershell +iex (irm "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$((irm https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") +``` + +`Start.ps1` elevates if needed, installs PowerShell 7 and WinGet if they are missing, then hands off to `Deploy.ps1`, which downloads and runs each deployment step from that release in sequence. + +{{< callout type="info" >}} +Every run is pinned to one GitHub release tag, start to finish. A deployment that starts against `v0.9.0` keeps using `v0.9.0`'s scripts even if `main` changes mid-run. + +For USB / offline installs via `autounattend.xml`, and the full configuration reference, see the [GitHub repository](https://github.com/Thectic-NL/WinDeploy). +{{< /callout >}} + +## What it does + +| Step | | +|---|---| +| RMM agent | Installs a monitoring agent from a USB drive, if present | +| Drivers | Dell Command Update or HP Client Management, model-detected | +| Hardening | SMB signing, LSA protection, HVCI, Defender ASR rules, screen lock — see below | +| Applications | WinGet + Microsoft 365 via CDN/ODT | +| Bloatware removal | Removes consumer apps, blocks their reinstall via policy | +| Tweaks *(opt-in)* | A [WinUtil](https://github.com/ChrisTitusTech/winutil) preset, behind a Y/N prompt | +| Theme, hostname | Dark mode, `PC-` naming | +| Windows Update | Installs everything available | + +Two steps ask before they run: **BitLocker** and the **WinUtil tweaks**. Both time out after 90 seconds and default to no, so an unattended run never stalls. Pass `-NonInteractive` to skip both outright. + +## Security hardening + +Applied automatically: SMBv1 removed, SMB signing required, insecure guest logons blocked, LSA protection (RunAsPPL), WDigest plaintext credential caching disabled, anonymous SAM/share enumeration restricted, LLMNR disabled, memory integrity (HVCI), 9 Defender Attack Surface Reduction rules, AutoRun disabled, secure screen lock. + +BitLocker, on confirmation, encrypts `C:` with XTS-AES-256, creates a recovery password, saves it to the operator's Documents folder and prints it on screen. + +## Credits + +Built on [PowerShell](https://github.com/PowerShell/PowerShell), [WinGet](https://github.com/microsoft/winget-cli), [PSWindowsUpdate](https://www.powershellgallery.com/packages/PSWindowsUpdate), [winget-install](https://github.com/asheroto/winget-install) by asheroto, and — for the optional tweaks step — [WinUtil](https://github.com/ChrisTitusTech/winutil) by Chris Titus. diff --git a/src/content/_index.nl.md b/src/content/_index.nl.md new file mode 100644 index 0000000..85dfe41 --- /dev/null +++ b/src/content/_index.nl.md @@ -0,0 +1,70 @@ +--- +title: "" +toc: false +--- + +
+{{< hextra/hero-headline >}} + WinDeploy +{{< /hextra/hero-headline >}} +
+ +
+{{< hextra/hero-subtitle >}} + Zero-touch Windows 11-deployment: drivers, applicaties, bloatware verwijderen, security hardening en BitLocker +{{< /hextra/hero-subtitle >}} +
+ +
+{{< hextra/hero-badge link="https://github.com/Thectic-NL/WinDeploy#quick-start" >}} + Snel starten + {{< icon name="arrow-right" attributes="height=20" >}} +{{< /hextra/hero-badge >}} +{{< hextra/hero-badge link="https://github.com/Thectic-NL/WinDeploy" >}} + Bekijk op GitHub + {{< icon name="github" attributes="height=20" >}} +{{< /hextra/hero-badge >}} +
+ +
+ +## Uitvoeren + +Als Administrator, in PowerShell 7: + +```powershell +iex (irm "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$((irm https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") +``` + +`Start.ps1` elevate't indien nodig, installeert PowerShell 7 en WinGet als die ontbreken, en geeft daarna door aan `Deploy.ps1`, dat elke deploymentstap van die release na elkaar downloadt en uitvoert. + +{{< callout type="info" >}} +Elke run staat vast op één GitHub release tag, van begin tot eind. Een deployment die start tegen `v0.9.0` blijft de scripts van `v0.9.0` gebruiken, ook als `main` tijdens de run verandert. + +Voor USB/offline installaties via `autounattend.xml`, en de volledige configuratiereferentie, zie de [GitHub-repository](https://github.com/Thectic-NL/WinDeploy). +{{< /callout >}} + +## Wat het doet + +| Stap | | +|---|---| +| RMM-agent | Installeert een monitoring agent vanaf een USB-stick, indien aanwezig | +| Drivers | Dell Command Update of HP Client Management, gedetecteerd op model | +| Hardening | SMB signing, LSA-protection, HVCI, Defender ASR-regels, schermvergrendeling — zie hieronder | +| Applicaties | WinGet + Microsoft 365 via CDN/ODT | +| Bloatware verwijderen | Verwijdert consumer-apps, blokkeert herinstallatie via policy | +| Tweaks *(optioneel)* | Een [WinUtil](https://github.com/ChrisTitusTech/winutil)-preset, achter een Y/N-prompt | +| Thema, hostnaam | Dark mode, `PC-`-naamgeving | +| Windows Update | Installeert alles wat beschikbaar is | + +Twee stappen vragen eerst om bevestiging: **BitLocker** en de **WinUtil-tweaks**. Beide lopen na 90 seconden af en kiezen dan standaard nee, zodat een onbeheerde run nooit vastloopt. Geef `-NonInteractive` mee om beide direct over te slaan. + +## Security hardening + +Automatisch toegepast: SMBv1 verwijderd, SMB signing verplicht, onveilige guest-logons geblokkeerd, LSA-protection (RunAsPPL), WDigest plaintext-credentialcaching uitgeschakeld, anonieme SAM/share-enumeratie beperkt, LLMNR uitgeschakeld, memory integrity (HVCI), 9 Defender Attack Surface Reduction-regels, AutoRun uitgeschakeld, beveiligde schermvergrendeling. + +BitLocker versleutelt, na bevestiging, `C:` met XTS-AES-256, maakt een recovery password aan, slaat dit op in de Documenten-map van de operator en toont het op het scherm. + +## Met dank aan + +Gebouwd op [PowerShell](https://github.com/PowerShell/PowerShell), [WinGet](https://github.com/microsoft/winget-cli), [PSWindowsUpdate](https://www.powershellgallery.com/packages/PSWindowsUpdate), [winget-install](https://github.com/asheroto/winget-install) van asheroto, en — voor de optionele tweaks-stap — [WinUtil](https://github.com/ChrisTitusTech/winutil) van Chris Titus. diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..2385528 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,5 @@ +module github.com/THectic-NL/WinDeploy + +go 1.26 + +require github.com/imfing/hextra v0.12.3 // indirect diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..afa8680 --- /dev/null +++ b/src/go.sum @@ -0,0 +1,2 @@ +github.com/imfing/hextra v0.12.3 h1:DZHY2rUWYteyzjlHi9r4n7Bb5e2Q+6LXe4C1Dqn0ZjM= +github.com/imfing/hextra v0.12.3/go.mod h1:vi+yhpq8YPp/aghvJlNKVnJKcPJ/VyAEcfC1BSV9ARo= diff --git a/src/hugo.toml b/src/hugo.toml new file mode 100644 index 0000000..6c26286 --- /dev/null +++ b/src/hugo.toml @@ -0,0 +1,73 @@ +baseURL = 'https://windeploy.thectic.nl/' +title = 'WinDeploy' +defaultContentLanguage = 'en' +enableRobotsTXT = true +disableKinds = ['taxonomy', 'term', 'RSS'] +disableHugoGeneratorInject = true + +[languages] + [languages.en] + label = 'English' + weight = 1 + title = 'WinDeploy' + locale = 'en-US' + [languages.nl] + label = 'Nederlands' + weight = 2 + title = 'WinDeploy' + locale = 'nl-NL' + +[menu] + [[menu.main]] + name = 'GitHub' + weight = 1 + url = 'https://github.com/Thectic-NL/WinDeploy' + [menu.main.params] + icon = 'github' + [[menu.main]] + name = 'Language' + weight = 2 + [menu.main.params] + type = 'language-switch' + [[menu.main]] + name = 'Theme' + weight = 3 + [menu.main.params] + type = 'theme-toggle' + +[params] + description = 'Zero-touch Windows 11 deployment: drivers, applications, bloatware removal, security hardening and BitLocker, orchestrated from PowerShell 7' + copyright = 'THectic' + + [params.navbar] + displayTitle = true + displayLogo = false + width = 'wide' + + [params.theme] + default = 'system' + displayToggle = false + + [params.footer] + enable = true + displayCopyright = false + displayPoweredBy = false + width = 'normal' + + displayUpdatedDate = true + dateFormat = 'January 2, 2006' + + [params.editURL] + enable = false + + [params.page] + width = 'wide' + +[markup] + [markup.goldmark] + [markup.goldmark.renderer] + unsafe = true + +[module] + [[module.imports]] + path = "github.com/imfing/hextra" diff --git a/src/layouts/_partials/custom/footer.html b/src/layouts/_partials/custom/footer.html new file mode 100644 index 0000000..5b860e0 --- /dev/null +++ b/src/layouts/_partials/custom/footer.html @@ -0,0 +1,4 @@ +
+ © {{ now.Year }} + THectic +
diff --git a/src/layouts/_partials/footer.html b/src/layouts/_partials/footer.html new file mode 100644 index 0000000..f197303 --- /dev/null +++ b/src/layouts/_partials/footer.html @@ -0,0 +1,35 @@ +{{- /* Overrides the Hextra partial to drop the footer's language/theme + switches. Both already live in the navbar (GitHub, Language, Theme menu + items), and on pages without a sidebar Hextra otherwise repeats the + language switch down here. Everything else matches the theme version. */ -}} +{{- $copyrightSectionVisible := or (.Site.Params.footer.displayPoweredBy | default true) .Site.Params.footer.displayCopyright -}} + +{{- $copyright := (T "copyright") | default "© 2024 Hextra." -}} +{{- $poweredBy := (T "poweredBy") | default "Powered by Hextra" -}} + +
+ + {{- if $copyrightSectionVisible -}} + + {{- end -}} +
+ +{{- define "theme-credit" -}} + + + {{- . | markdownify -}} + {{- if strings.Contains . "Hextra" -}} + {{- partial "utils/icon.html" (dict "name" "hextra" "attributes" `height=1em class="hx:inline-block hx:ltr:ml-1 hx:rtl:mr-1 hx:align-[-2.5px]"`) -}} + {{- end -}} + + +{{- end -}} diff --git a/src/layouts/_partials/navbar-title.html b/src/layouts/_partials/navbar-title.html new file mode 100644 index 0000000..f3515a8 --- /dev/null +++ b/src/layouts/_partials/navbar-title.html @@ -0,0 +1,38 @@ +{{- /* Overrides the Hextra partial. The navbar's left edge is a logo lockup: + the THectic monogram tile links to the umbrella site (thectic.nl, which + fronts every project subdomain) and shows a GitHub-style hover card + naming that destination; the wordmark links to this site's own home. + + Standardised across the THectic project repos: keep this file byte-for- + byte identical everywhere, and onboard a new repo by copying it in + alongside the .brand-* block in assets/css/custom.css. + + The monogram is inlined twice, one variant per theme, so the tile stays + readable on both the light and the near-black navbar. Its plate colour + and glyph path match THectic.nl's own brand mark. + + Layout lives in custom.css rather than Tailwind utility classes: Hextra + ships a precompiled bundle and never reruns Tailwind against the site's + own templates, so a `hx:` class not already used by the theme itself + (gap-2.5, hover:opacity-80, ...) silently compiles to nothing. */ -}} +{{- $displayTitle := .Site.Params.navbar.displayTitle | default true -}} +
+ + + + + + + + + + + + + + {{- if $displayTitle }} + + {{- .Site.Title -}} + + {{- end }} +
diff --git a/src/static/robots.txt b/src/static/robots.txt new file mode 100644 index 0000000..998efe4 --- /dev/null +++ b/src/static/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://windeploy.thectic.nl/sitemap.xml From 9fc403805345b508ed35a6850eaf4a7a520a387b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 23:02:24 +0000 Subject: [PATCH 6/7] fix: merge duplicate "### Fixed" heading in v0.5.0 changelog entry markdownlint (MD024, siblings_only) caught this once .markdownlint.yml was added: two "### Fixed" sections existed under the same [0.5.0] version heading, pre-dating this restructuring. Merged into one. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pt5Q3XvjMT6HMdubvkyLMW --- CHANGELOG.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e23ceeb..cd02037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,10 +253,6 @@ Repository moved from `Stensel8/WinDeploy` to `Thectic-NL/WinDeploy`. ## [0.5.0] - 2025-11-14 -### Fixed -- Fixed bloatware removal printing duplicate messages on screen by removing redundant Write-Output calls -- Improved admin elevation handling in Start.ps1 and Deploy.ps1 to prevent script crashes when not run as administrator - ### Added - Added documentation for Intune Autopilot device preparation setup (`Docs/Intune-Autopilot-Setup.md`) - Added RMM agent installation support with USB detection and download fallback @@ -272,6 +268,8 @@ Repository moved from `Stensel8/WinDeploy` to `Thectic-NL/WinDeploy`. - Removed unused utility modules and scripts ### Fixed +- Fixed bloatware removal printing duplicate messages on screen by removing redundant Write-Output calls +- Improved admin elevation handling in Start.ps1 and Deploy.ps1 to prevent script crashes when not run as administrator - Improved error handling and logging across all scripts - Enhanced compatibility and reliability of deployment process From 5ed5d2eb4b1f331aeeb46bb1f4feee2a808f6c12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 23:04:49 +0000 Subject: [PATCH 7/7] fix: correct org casing to THectic-NL (verified against GitHub's own API) Checked api.github.com/search/repositories?q=repo:THectic-NL/BypassNRO directly: owner.login is "THectic-NL" (capital T-H), not "Thectic-NL". My own earlier commit had it wrong in src/go.mod, and BypassNRO's own repo (hugo.toml, go.mod, README, content pages) has the same imprecise casing throughout - harmless since GitHub resolves logins case- insensitively, but real drift from the account's actual stored name. Corrected everywhere in this repo; BypassNRO's own instances are unchanged since fixing another repo wasn't asked for. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Pt5Q3XvjMT6HMdubvkyLMW --- .github/pull_request_template.md | 2 +- CHANGELOG.md | 38 +++++++++++----------- Docs/autounattend.xml | 4 +-- README.md | 14 ++++---- SECURITY.md | 2 +- Scripts/Archived/Get-InstalledSoftware.ps1 | 4 +-- Scripts/Deploy.ps1 | 10 +++--- Scripts/Start.ps1 | 12 +++---- src/content/_index.md | 8 ++--- src/content/_index.nl.md | 8 ++--- src/hugo.toml | 2 +- 11 files changed, 52 insertions(+), 52 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a194fb7..819e7c4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -13,7 +13,7 @@ - [ ] `style` — formatting, whitespace, typos - [ ] `revert` — reverting a previous commit -> [PR title and commit types must follow these standards — view the contributing guide](https://github.com/Thectic-NL/WinDeploy/blob/main/CONTRIBUTING.md#commit-messages) +> [PR title and commit types must follow these standards — view the contributing guide](https://github.com/THectic-NL/WinDeploy/blob/main/CONTRIBUTING.md#commit-messages) ## Checklist diff --git a/CHANGELOG.md b/CHANGELOG.md index cd02037..c450442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.9.0] - 2026-09-02 -Repository moved from `Stensel8/WinDeploy` to `Thectic-NL/WinDeploy`. +Repository moved from `Stensel8/WinDeploy` to `THectic-NL/WinDeploy`. ### Added -- `src/`. A Hugo landing page for windeploy.thectic.nl (English + Dutch), matching the site structure already in use for [BypassNRO](https://github.com/Thectic-NL/BypassNRO). It documents the project; it does not host the deployment scripts. `Scripts/` and `Docs/` are unchanged in location and behaviour, and GitHub Releases stays the distribution mechanism, so a run started against one release tag keeps using that tag's scripts throughout, even if `main` changes mid-run. +- `src/`. A Hugo landing page for windeploy.thectic.nl (English + Dutch), matching the site structure already in use for [BypassNRO](https://github.com/THectic-NL/BypassNRO). It documents the project; it does not host the deployment scripts. `Scripts/` and `Docs/` are unchanged in location and behaviour, and GitHub Releases stays the distribution mechanism, so a run started against one release tag keeps using that tag's scripts throughout, even if `main` changes mid-run. - `.github/workflows/deploy-bunny.yml`, `pr-checks.yml`, `pr-title.yml`, `config-validation.yml`, `trivy-scan.yml`, `update-checksums.yml`, `.github/scripts/`. CI for the new site, matching the shared org convention. - `renovate.json`: `gomod` and `custom.regex` managers, for the site's Go module and the hand-pinned tool versions in the new workflows. ### Changed -- All `Stensel8/WinDeploy` references (README, SECURITY.md, `Docs/autounattend.xml`, `Scripts/Start.ps1`, `Scripts/Deploy.ps1`, old changelog release links) now point at `Thectic-NL/WinDeploy`. +- All `Stensel8/WinDeploy` references (README, SECURITY.md, `Docs/autounattend.xml`, `Scripts/Start.ps1`, `Scripts/Deploy.ps1`, old changelog release links) now point at `THectic-NL/WinDeploy`. - `.github/workflows/validate.yml`: the syntax-check and helper-function-test jobs merged into one job (same runner, one less billed minute), path-scoped to `Scripts/**`, otherwise unchanged. - `CONTRIBUTING.md`, `.github/pull_request_template.md`: extended with the site's bilingual-content and Hugo-build checks, alongside the existing Windows-testing requirement, which stays required for anything under `Scripts/` or `Docs/`. @@ -184,7 +184,7 @@ Repository moved from `Stensel8/WinDeploy` to `Thectic-NL/WinDeploy`. ### Fixed - Bitlocker enablement issue: Sometimes Bitlocker failed to enable due to the TPM not being ready. -- Fixed an issue with RMM Agents not installing correctly. [#11](https://github.com/Thectic-NL/WinDeploy/issues/11) +- Fixed an issue with RMM Agents not installing correctly. [#11](https://github.com/THectic-NL/WinDeploy/issues/11) - Fixed a rare hang where deployment would stall after detecting the RMM installer on USB. The installer was being invoked via PowerShell incorrectly which could prevent it from receiving silent switches; changed to run the installer directly and wait for completion, added longer timeouts and improved logging. ## [0.5.6] - 2025-11-20 @@ -304,18 +304,18 @@ First open-source release of WinDeploy - Windows Deployment Automation Toolkit. --- -[0.9.0]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.9.0 -[0.8.0]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.8.0 -[0.7.3]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.7.3 -[0.7.2]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.7.2 -[0.7.1]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.7.1 -[0.7.0]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.7.0 -[0.6.1]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.6.1 -[0.6.0]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.6.0 -[0.5.5]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.5.5 -[0.5.4]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.5.4 -[0.5.3]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.5.3 -[0.5.2]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.5.2 -[0.5.0]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.5.0 -[0.1.2]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.1.2 -[0.1.1]: https://github.com/Thectic-NL/WinDeploy/releases/tag/v0.1.1 +[0.9.0]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.9.0 +[0.8.0]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.8.0 +[0.7.3]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.7.3 +[0.7.2]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.7.2 +[0.7.1]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.7.1 +[0.7.0]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.7.0 +[0.6.1]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.6.1 +[0.6.0]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.6.0 +[0.5.5]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.5.5 +[0.5.4]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.5.4 +[0.5.3]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.5.3 +[0.5.2]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.5.2 +[0.5.0]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.5.0 +[0.1.2]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.1.2 +[0.1.1]: https://github.com/THectic-NL/WinDeploy/releases/tag/v0.1.1 diff --git a/Docs/autounattend.xml b/Docs/autounattend.xml index 1859902..0f0a21c 100644 --- a/Docs/autounattend.xml +++ b/Docs/autounattend.xml @@ -1,6 +1,6 @@ - + @@ -568,7 +568,7 @@ Install-Script winget-install -Force winget-install -Force
-& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$((irm https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1"))) -NonInteractive +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$((irm https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1"))) -NonInteractive $scripts = @( diff --git a/README.md b/README.md index 4067c74..b73ca37 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ [![PowerShell 7.0+](https://img.shields.io/badge/PowerShell-7.0+-blue.svg)](https://github.com/PowerShell/PowerShell) [![Windows 11 25H2](https://img.shields.io/badge/Windows-11_25H2-0078D6.svg)](https://www.microsoft.com/windows) -[![Security scanning](https://github.com/Thectic-NL/WinDeploy/actions/workflows/security.yml/badge.svg)](https://github.com/Thectic-NL/WinDeploy/actions/workflows/security.yml) -[![Validate scripts](https://github.com/Thectic-NL/WinDeploy/actions/workflows/validate.yml/badge.svg)](https://github.com/Thectic-NL/WinDeploy/actions/workflows/validate.yml) -[![CodeQL](https://github.com/Thectic-NL/WinDeploy/actions/workflows/codeql.yml/badge.svg)](https://github.com/Thectic-NL/WinDeploy/actions/workflows/codeql.yml) -[![Dependabot Updates](https://github.com/Thectic-NL/WinDeploy/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/Thectic-NL/WinDeploy/actions/workflows/dependabot/dependabot-updates) +[![Security scanning](https://github.com/THectic-NL/WinDeploy/actions/workflows/security.yml/badge.svg)](https://github.com/THectic-NL/WinDeploy/actions/workflows/security.yml) +[![Validate scripts](https://github.com/THectic-NL/WinDeploy/actions/workflows/validate.yml/badge.svg)](https://github.com/THectic-NL/WinDeploy/actions/workflows/validate.yml) +[![CodeQL](https://github.com/THectic-NL/WinDeploy/actions/workflows/codeql.yml/badge.svg)](https://github.com/THectic-NL/WinDeploy/actions/workflows/codeql.yml) +[![Dependabot Updates](https://github.com/THectic-NL/WinDeploy/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/THectic-NL/WinDeploy/actions/workflows/dependabot/dependabot-updates) Zero-touch Windows deployment with automatic driver updates, application installation, bloatware removal, and system configuration. Deploy via USB, network, RMM agents, or AutoUnattend.xml. @@ -47,7 +47,7 @@ Zero-touch Windows deployment with automatic driver updates, application install ### Option 2: Direct Execution ```powershell # Run as Administrator in PowerShell 7 -iex (irm "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$((irm https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") +iex (irm "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$((irm https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") ``` ### Option 3: One-liner @@ -261,8 +261,8 @@ winget-install Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). -- **Issues**: [GitHub Issues](https://github.com/Thectic-NL/WinDeploy/issues) -- **Discussions**: [GitHub Discussions](https://github.com/Thectic-NL/WinDeploy/discussions) +- **Issues**: [GitHub Issues](https://github.com/THectic-NL/WinDeploy/issues) +- **Discussions**: [GitHub Discussions](https://github.com/THectic-NL/WinDeploy/discussions) ## Disclaimer diff --git a/SECURITY.md b/SECURITY.md index 3021401..afaa342 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ **Do not open a public GitHub issue for security vulnerabilities.** -Report privately via [GitHub Security Advisories](https://github.com/Thectic-NL/WinDeploy/security/advisories/new). +Report privately via [GitHub Security Advisories](https://github.com/THectic-NL/WinDeploy/security/advisories/new). Include: - Description of the vulnerability diff --git a/Scripts/Archived/Get-InstalledSoftware.ps1 b/Scripts/Archived/Get-InstalledSoftware.ps1 index 58df5e1..55f57b2 100644 --- a/Scripts/Archived/Get-InstalledSoftware.ps1 +++ b/Scripts/Archived/Get-InstalledSoftware.ps1 @@ -6,7 +6,7 @@ .TAGS PowerShell Windows Software Inventory Registry AppX Reporting -.PROJECTURI https://github.com/Thectic-NL/WinDeploy +.PROJECTURI https://github.com/THectic-NL/WinDeploy #> @@ -30,7 +30,7 @@ Requires : PowerShell 5.1+ .LINK - Project Site: https://github.com/Thectic-NL/WinDeploy + Project Site: https://github.com/THectic-NL/WinDeploy #> Set-StrictMode -Version Latest diff --git a/Scripts/Deploy.ps1 b/Scripts/Deploy.ps1 index 4554a60..91a3a3c 100644 --- a/Scripts/Deploy.ps1 +++ b/Scripts/Deploy.ps1 @@ -62,7 +62,7 @@ function Get-LatestRelease { for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { try { - $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest" -ErrorAction Stop + $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest" -ErrorAction Stop if ($latestRelease.tag_name) { return $latestRelease.tag_name } @@ -92,7 +92,7 @@ if (!$releaseTag) { Write-Output "Please check:" Write-Output " 1. Your internet connection" Write-Output " 2. GitHub API accessibility" - Write-Output " 3. Repository has published releases: github.com/Thectic-NL/WinDeploy/releases" + Write-Output " 3. Repository has published releases: github.com/THectic-NL/WinDeploy/releases" Wait-ForExit exit 1 } @@ -100,7 +100,7 @@ if (!$releaseTag) { # Read version $version = $null try { - $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue + $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue $version = $version.Trim() if ($version) { Write-Output "Version: $version" @@ -203,7 +203,7 @@ function Get-DeploymentScript { for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/Scripts/Deployment/$ScriptName" -OutFile $LocalPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/Scripts/Deployment/$ScriptName" -OutFile $LocalPath -UseBasicParsing -ErrorAction Stop Write-DeployLog "Downloaded $ScriptName to $LocalPath" return $true } catch { @@ -301,7 +301,7 @@ try { $downloaded = $false for ($attempt = 1; $attempt -le 3; $attempt++) { try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/Scripts/Archived/Fix-Spotlight.ps1" -OutFile $fixScriptPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/Scripts/Archived/Fix-Spotlight.ps1" -OutFile $fixScriptPath -UseBasicParsing -ErrorAction Stop Write-DeployLog "Downloaded Fix-Spotlight.ps1 to $fixScriptPath as an optional script to fix missing Spotlight options on the system. This can be run manually if needed." $downloaded = $true break diff --git a/Scripts/Start.ps1 b/Scripts/Start.ps1 index 0f437f6..3eef48e 100644 --- a/Scripts/Start.ps1 +++ b/Scripts/Start.ps1 @@ -17,7 +17,7 @@ function Get-LatestRelease { for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { try { - $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest" -ErrorAction Stop + $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest" -ErrorAction Stop if ($latestRelease.tag_name) { return $latestRelease.tag_name } @@ -50,14 +50,14 @@ if (!$releaseTag) { Write-Host "Releases are required for deployment. Please check:" -ForegroundColor Yellow Write-Host " 1. Your internet connection" -ForegroundColor Yellow Write-Host " 2. GitHub API accessibility" -ForegroundColor Yellow - Write-Host " 3. Repository has published releases: github.com/Thectic-NL/WinDeploy/releases" -ForegroundColor Yellow + Write-Host " 3. Repository has published releases: github.com/THectic-NL/WinDeploy/releases" -ForegroundColor Yellow exit 1 } # Read version $version = $null try { - $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue + $version = Invoke-RestMethod -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/VERSION" -ErrorAction SilentlyContinue $version = $version.Trim() } catch { Write-Warning "Failed to fetch version information." @@ -227,7 +227,7 @@ if (-not $isAdmin) { # Script run via iex - download to temp $scriptPath = [System.IO.Path]::GetTempFileName() + ".ps1" try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop } catch { Write-Host "Failed to download Start.ps1: $_" -ForegroundColor Red exit 1 @@ -265,7 +265,7 @@ if (-not $isPwsh7) { # Script run via iex - download to temp $scriptPath = [System.IO.Path]::GetTempFileName() + ".ps1" try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/Scripts/Start.ps1" -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop } catch { Write-Host "Failed to download Start.ps1: $_" -ForegroundColor Red exit 1 @@ -303,7 +303,7 @@ $downloadSuccess = $false for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { try { Write-Host "Downloading Deploy.ps1 (Attempt $attempt of $maxRetries)..." -ForegroundColor Cyan - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$releaseTag/Scripts/Deploy.ps1" -OutFile $deployPath -UseBasicParsing -ErrorAction Stop + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$releaseTag/Scripts/Deploy.ps1" -OutFile $deployPath -UseBasicParsing -ErrorAction Stop Write-Host "Downloaded Deploy.ps1 to $deployPath" -ForegroundColor Green $downloadSuccess = $true break diff --git a/src/content/_index.md b/src/content/_index.md index 32366b5..1b34646 100644 --- a/src/content/_index.md +++ b/src/content/_index.md @@ -16,11 +16,11 @@ toc: false
-{{< hextra/hero-badge link="https://github.com/Thectic-NL/WinDeploy#quick-start" >}} +{{< hextra/hero-badge link="https://github.com/THectic-NL/WinDeploy#quick-start" >}} Quick start {{< icon name="arrow-right" attributes="height=20" >}} {{< /hextra/hero-badge >}} -{{< hextra/hero-badge link="https://github.com/Thectic-NL/WinDeploy" >}} +{{< hextra/hero-badge link="https://github.com/THectic-NL/WinDeploy" >}} View on GitHub {{< icon name="github" attributes="height=20" >}} {{< /hextra/hero-badge >}} @@ -33,7 +33,7 @@ toc: false As Administrator, in PowerShell 7: ```powershell -iex (irm "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$((irm https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") +iex (irm "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$((irm https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") ``` `Start.ps1` elevates if needed, installs PowerShell 7 and WinGet if they are missing, then hands off to `Deploy.ps1`, which downloads and runs each deployment step from that release in sequence. @@ -41,7 +41,7 @@ iex (irm "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$((irm https:// {{< callout type="info" >}} Every run is pinned to one GitHub release tag, start to finish. A deployment that starts against `v0.9.0` keeps using `v0.9.0`'s scripts even if `main` changes mid-run. -For USB / offline installs via `autounattend.xml`, and the full configuration reference, see the [GitHub repository](https://github.com/Thectic-NL/WinDeploy). +For USB / offline installs via `autounattend.xml`, and the full configuration reference, see the [GitHub repository](https://github.com/THectic-NL/WinDeploy). {{< /callout >}} ## What it does diff --git a/src/content/_index.nl.md b/src/content/_index.nl.md index 85dfe41..34cccaa 100644 --- a/src/content/_index.nl.md +++ b/src/content/_index.nl.md @@ -16,11 +16,11 @@ toc: false
-{{< hextra/hero-badge link="https://github.com/Thectic-NL/WinDeploy#quick-start" >}} +{{< hextra/hero-badge link="https://github.com/THectic-NL/WinDeploy#quick-start" >}} Snel starten {{< icon name="arrow-right" attributes="height=20" >}} {{< /hextra/hero-badge >}} -{{< hextra/hero-badge link="https://github.com/Thectic-NL/WinDeploy" >}} +{{< hextra/hero-badge link="https://github.com/THectic-NL/WinDeploy" >}} Bekijk op GitHub {{< icon name="github" attributes="height=20" >}} {{< /hextra/hero-badge >}} @@ -33,7 +33,7 @@ toc: false Als Administrator, in PowerShell 7: ```powershell -iex (irm "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$((irm https://api.github.com/repos/Thectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") +iex (irm "https://raw.githubusercontent.com/THectic-NL/WinDeploy/$((irm https://api.github.com/repos/THectic-NL/WinDeploy/releases/latest).tag_name)/Scripts/Start.ps1") ``` `Start.ps1` elevate't indien nodig, installeert PowerShell 7 en WinGet als die ontbreken, en geeft daarna door aan `Deploy.ps1`, dat elke deploymentstap van die release na elkaar downloadt en uitvoert. @@ -41,7 +41,7 @@ iex (irm "https://raw.githubusercontent.com/Thectic-NL/WinDeploy/$((irm https:// {{< callout type="info" >}} Elke run staat vast op één GitHub release tag, van begin tot eind. Een deployment die start tegen `v0.9.0` blijft de scripts van `v0.9.0` gebruiken, ook als `main` tijdens de run verandert. -Voor USB/offline installaties via `autounattend.xml`, en de volledige configuratiereferentie, zie de [GitHub-repository](https://github.com/Thectic-NL/WinDeploy). +Voor USB/offline installaties via `autounattend.xml`, en de volledige configuratiereferentie, zie de [GitHub-repository](https://github.com/THectic-NL/WinDeploy). {{< /callout >}} ## Wat het doet diff --git a/src/hugo.toml b/src/hugo.toml index 6c26286..baf315d 100644 --- a/src/hugo.toml +++ b/src/hugo.toml @@ -21,7 +21,7 @@ disableHugoGeneratorInject = true [[menu.main]] name = 'GitHub' weight = 1 - url = 'https://github.com/Thectic-NL/WinDeploy' + url = 'https://github.com/THectic-NL/WinDeploy' [menu.main.params] icon = 'github' [[menu.main]]