close

DEV Community

Cover image for Building a Zero-Bloat WinGet Background Auto-Updater with PowerShell
Mihai Pavelescu
Mihai Pavelescu

Posted on

Building a Zero-Bloat WinGet Background Auto-Updater with PowerShell

Before diving into the code, let me share exactly why I wrote this script and why you might find it helpful. For a long time, I tested commercial utilities like IObit Software Updater and CCleaner Software Updater. While they technically handle updates, they come with a high cost to system performance. Their underlying executables introduce unnecessary background telemetry, aggressive pop-ups for premium upgrades, and persistent processes that consume system resources.

Moving from a consumer attitude towards the computer system to a developer's perspective, I felt I needed the workstations to be lean and optimized. I realized that installing a bloated, closed-source program to manage software updates is counterproductive. By writing a custom PowerShell script that leverages native WinGet commands, we eliminate the bloat. Here is my transparent, open-source automation tool that runs silently in the background, executing exactly what we need and nothing more.

Keeping Windows applications up to date is a standard requirement for any developer's workstation. While Microsoft provides the excellent Windows Package Manager (WinGet), it currently lacks a native, silent background auto-updater.

If you search for solutions, you will likely find tools like Winget-AutoUpdate (WAU). While incredibly powerful for enterprise IT departments, it is heavily bloated for a single developer's laptop. I wanted a lightweight, "set-it-and-forget-it" solution.

Working alongside Google Gemini and Anthropic's Claude as AI pair-programming partners, I iteratively built and security-hardened a robust, zero-bloat PowerShell automation script. Here is a breakdown of the development journey, the technical hurdles we solved, and the final code.

The Technical Hurdles

We initially wrote a script that triggered at system startup using the hidden NT AUTHORITY\SYSTEM account. However, we quickly ran into several Windows architecture quirks that required refactoring.

1. The User Context Bug
Because WinGet is installed on a per-user basis (living in AppData), the SYSTEM account literally could not find the winget executable, throwing a CommandNotFoundException.

  • The Fix: We refactored the Scheduled Task principal to dynamically grab the interactive user's profile and run with highest administrative privileges.

2. Preventing Log Bloat
Since this script runs daily and logs its output silently, the text file would eventually grow massive.

  • The Fix: We implemented an automatic log-trimming function. Before running the update, PowerShell checks if the log exceeds 2 MB. If it does, it uses the highly efficient -Tail 500 parameter to keep only the most recent history.

The Enterprise Security Upgrade

Because the Scheduled Task runs invisibly (-WindowStyle Hidden) with highest privileges (-RunLevel Highest), it introduces a risk: if malicious software overwrote the background payload, the system would silently execute the virus every time you log in. Collaborating on a deep-dive threat model with Claude, we implemented enterprise-grade security hardening to mitigate this:

  • Race Condition Prevention & Forced Reset: The setup script forces an icacls /reset and recursively clears any existing automation directory on launch to ensure a clean deployment. It handles the directory creation and completely locks down the C:\Automation directory before it writes the payload, ensuring no malware can swap the file during creation.
  • Cryptographic Hash Pinning: We don't just rely on folder permissions. The setup script calculates the SHA-256 hash of the payload and pins it to the Windows Registry (HKLM). The Scheduled Task verifies this exact hash before execution; if a single byte has been altered, it aborts.
  • Path Hijacking Defense: We replaced bare commands with absolute, secure paths to both winget.exe and PowerShell.exe to prevent execution spoofing.

The Final Code

Here is the complete, bulletproof script. Paste this into an Administrator PowerShell window, and it will automatically clear old instances, generate the payload, secure the folder, pin the hash, and register the Scheduled Task.

<#
.SYNOPSIS
    Hardened setup for a scheduled WinGet auto-updater task.
#>

$ErrorActionPreference = 'Stop'

$Folder      = "C:\Automation"
$ScriptPath  = "$Folder\BackgroundUpdater.ps1"
$LogPath     = "$Folder\updater_log.txt"
$TaskName    = "Automated_WinGet_Updater"
$RegKeyPath  = "HKLM:\SOFTWARE\WinGetAutomation"
$RegHashName = "ExpectedScriptHash"

function Write-Section {
    param([string]$Message, [string]$Color = 'Gray')
    Write-Host $Message -ForegroundColor $Color
}

try {
    # -------------------------------------------------------------------
    # 0. Confirm elevation and capture the real interactive user identity
    # -------------------------------------------------------------------
    $identity  = [System.Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object System.Security.Principal.WindowsPrincipal($identity)
    if (-not $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) {
        throw "This script must be run from an elevated (Administrator) PowerShell session."
    }
    $CurrentUser = $identity.Name
    Write-Section "Running elevated as $CurrentUser." 'Gray'

    # -------------------------------------------------------------------
    # 1. Force clear any existing automation folder to ensure a fresh slate
    # -------------------------------------------------------------------
    if (Test-Path $Folder) {
        $existing = Get-Item $Folder -Force
        if ($existing.Attributes -band [IO.FileAttributes]::ReparsePoint) {
            throw "$Folder is a reparse point / junction / symlink. Refusing to reuse it."
        }
        Write-Section "Existing deployment detected. Resetting ACLs and force-deleting $Folder..." 'Yellow'
        & icacls $Folder /reset /T /C /Q | Out-Null
        Remove-Item -Path $Folder -Recurse -Force
    }

    New-Item -ItemType Directory -Path $Folder | Out-Null

    # Lock it down immediately
    $icaclsArgs = @(
        $Folder, '/inheritance:r',
        '/grant', "*S-1-5-32-544:(OI)(CI)F",
        '/grant', "*S-1-5-18:(OI)(CI)F",
        '/grant', "${CurrentUser}:(OI)(CI)RX",
        '/T', '/C', '/Q'
    )
    $icaclsOutput = & icacls @icaclsArgs 2>&1
    if ($LASTEXITCODE -ne 0) {
        throw "icacls failed to lock down $Folder (exit code $LASTEXITCODE): $icaclsOutput"
    }

    # Verify ACL using proper SID Translation
    $acl = Get-Acl $Folder
    if (-not $acl.AreAccessRulesProtected) {
        throw "$Folder still inherits parent permissions after icacls /inheritance:r - aborting."
    }

    $hasAdminFull = $acl.Access | Where-Object {
        $sid = $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
        $sid -eq 'S-1-5-32-544' -and $_.FileSystemRights -match 'FullControl'
    }

    if (-not $hasAdminFull) {
        throw "Expected Administrators FullControl ACE not found on $Folder after lockdown - aborting."
    }
    Write-Section "Folder $Folder created and locked down (verified) before any payload was written." 'Green'

    # -------------------------------------------------------------------
    # 2. Write the updater payload
    # -------------------------------------------------------------------
    $UpdaterCode = @"
`$ErrorActionPreference = 'Stop'
`$LogPath = "$LogPath"

if ((Test-Path `$LogPath) -and ((Get-Item `$LogPath).Length -gt 2097152)) {
    (Get-Content `$LogPath -Tail 500) | Set-Content `$LogPath -Encoding UTF8
}

try {
    Start-Transcript -Path `$LogPath -Append | Out-Null
    Write-Host "Starting background WinGet update sequence: `$(Get-Date -Format o)"

    # Securely resolve WinGet path to prevent PATH hijacking
    `$WinGetPath = "`$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe"

    if (-not (Test-Path `$WinGetPath)) { throw "winget.exe could not be found at secure path." }

    & `$WinGetPath upgrade --all --include-unknown --silent --accept-package-agreements --accept-source-agreements
    `$wingetExit = `$LASTEXITCODE
    if (`$wingetExit -eq 0) {
        Write-Host "Sequence completed successfully (exit code 0)."
    } else {
        Write-Host "WARNING: winget exited with code `$wingetExit."
    }
}
catch {
    Write-Host "ERROR: `$_"
}
finally {
    Stop-Transcript | Out-Null
}
"@

    Set-Content -Path $ScriptPath -Value $UpdaterCode -Encoding UTF8 -Force
    Write-Section "Payload written to $ScriptPath." 'Gray'

    # -------------------------------------------------------------------
    # 3. Pin an integrity hash in HKLM
    # -------------------------------------------------------------------
    $expectedHash = (Get-FileHash -Path $ScriptPath -Algorithm SHA256).Hash
    if (-not (Test-Path $RegKeyPath)) {
        New-Item -Path $RegKeyPath -Force | Out-Null
    }
    Set-ItemProperty -Path $RegKeyPath -Name $RegHashName -Value $expectedHash -Type String
    Write-Section "Integrity hash pinned at $RegKeyPath\$RegHashName." 'Gray'

    # -------------------------------------------------------------------
    # 4. Build a scheduled task Action that verifies the payload's hash
    # -------------------------------------------------------------------
    $VerifyAndRun = @'
$ErrorActionPreference = 'Stop'
$scriptPath  = 'REPLACE_SCRIPT_PATH'
$regKeyPath  = 'REPLACE_REG_KEY'
$regHashName = 'REPLACE_REG_NAME'
try {
    $expected = (Get-ItemProperty -Path $regKeyPath -Name $regHashName -ErrorAction Stop).$regHashName
    $actual   = (Get-FileHash -Path $scriptPath -Algorithm SHA256 -ErrorAction Stop).Hash
    if ($expected -ne $actual) {
        throw "Hash Mismatch! Aborting execution."
    }
    & $scriptPath
}
catch {
    throw "Verification Failed."
}
'@
    $VerifyAndRun = $VerifyAndRun.Replace('REPLACE_SCRIPT_PATH', $ScriptPath).
                                   Replace('REPLACE_REG_KEY', $RegKeyPath).
                                   Replace('REPLACE_REG_NAME', $RegHashName)

    $EncodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($VerifyAndRun))
    $PowerShellExe  = "$env:windir\System32\WindowsPowerShell\v1.0\powershell.exe"

    # -------------------------------------------------------------------
    # 5. Register the scheduled task
    # -------------------------------------------------------------------
    $Trigger = New-ScheduledTaskTrigger -AtLogOn
    $Trigger.Delay = "PT15M"

    $Action = New-ScheduledTaskAction -Execute $PowerShellExe `
        -Argument "-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy RemoteSigned -EncodedCommand $EncodedCommand"

    $Principal = New-ScheduledTaskPrincipal -UserId $CurrentUser -LogonType Interactive -RunLevel Highest
    $Settings  = New-ScheduledTaskSettingsSet -Compatibility Win8

    $existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    if ($existingTask) {
        Write-Section "An existing task named '$TaskName' was found and will be replaced." 'Yellow'
    }

    Register-ScheduledTask -TaskName $TaskName -Trigger $Trigger -Action $Action -Principal $Principal -Settings $Settings -Force | Out-Null

    # -------------------------------------------------------------------
    # 6. Final verification pass
    # -------------------------------------------------------------------
    $verifyTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    $verifyAcl  = Get-Acl $Folder
    $verifyHash = (Get-FileHash -Path $ScriptPath -Algorithm SHA256).Hash
    $regHash    = (Get-ItemProperty -Path $RegKeyPath -Name $RegHashName).$RegHashName

    $allGood = $true
    if (-not $verifyTask) {
        Write-Section "FAILED: scheduled task not found after registration." 'Red'
        $allGood = $false
    }
    if ($verifyHash -ne $regHash) {
        Write-Section "FAILED: pinned hash does not match the file currently on disk." 'Red'
        $allGood = $false
    }

    if ($allGood) {
        Write-Host ""
        Write-Host "Success: '$ScriptPath' created with auto-trimming logs and a hash-verifying launcher," -ForegroundColor Green
        Write-Host "         triggered 15 minutes after logon as task '$TaskName'." -ForegroundColor Green
        Write-Host "Hardening applied: ACL locked before write, integrity hash pinned in HKLM," -ForegroundColor Cyan
        Write-Host "                   winget resolved by absolute path." -ForegroundColor Cyan
    }
    else {
        throw "One or more post-install verification checks failed. See above."
    }
}
catch {
    Write-Host ""
    Write-Host "Setup failed: $_" -ForegroundColor Red
}
Enter fullscreen mode Exit fullscreen mode

Get the Code & Documentation

You can check out the full repository, including the README.md documentation, on GitHub here:
👉 MediaExpres/windows-automation


What's your approach?

How do you currently handle keeping your development environment up to date? Do you rely on third-party tools, or have you built your own custom scripts? Let me know in the comments below!

(🤖 Acknowledgment: The code and documentation in this project were developed iteratively with Google Gemini and Anthropic's Claude as AI pair-programming partners).

Top comments (0)