Public/Invoke-LocalRemediation.ps1

function Invoke-LocalRemediation {
    <#
    .SYNOPSIS
        Executes proactive endpoint remediation checks without Intune cloud licensing.
    .DESCRIPTION
        Implements the Microsoft Proactive Remediation standard locally on any Windows
        machine or homelab hypervisor VM. Runs detection scripts using standard exit codes
        (0 = Compliant, 1 = Non-Compliant), triggers auto-remediation when specified,
        and verifies post-remediation compliance drift.
    .PARAMETER Name
        Friendly name or identifier for this remediation policy.
    .PARAMETER DetectionScriptPath
        Path to the PowerShell detection script (.ps1).
    .PARAMETER DetectionScript
        ScriptBlock containing the detection logic.
    .PARAMETER RemediationScriptPath
        Path to the PowerShell remediation script (.ps1).
    .PARAMETER RemediationScript
        ScriptBlock containing the remediation logic.
    .PARAMETER Remediate
        If specified and detection exits with 1 (Non-Compliant), triggers remediation execution.
    .PARAMETER Verify
        Re-runs detection immediately following remediation to confirm drift was resolved.
    .EXAMPLE
        Invoke-LocalRemediation -Name "Ensure-DNS" -DetectionScript { if ((Get-DnsClientServerAddress -InterfaceAlias 'Ethernet').ServerAddresses -contains '1.1.1.1') { exit 0 } else { exit 1 } }
    .EXAMPLE
        Invoke-LocalRemediation -Name "Clear-Dumps" -DetectionScriptPath ".\Detect-CrashDumps.ps1" -RemediationScriptPath ".\Clear-CrashDumps.ps1" -Remediate -Verify
    #>

    [CmdletBinding(DefaultParameterSetName = 'ScriptBlock')]
    param (
        [Parameter(Position = 0)]
        [string]$Name = 'LocalRemediation',

        [Parameter(ParameterSetName = 'FilePath')]
        [string]$DetectionScriptPath,

        [Parameter(ParameterSetName = 'FilePath')]
        [string]$RemediationScriptPath,

        [Parameter(ParameterSetName = 'ScriptBlock')]
        [scriptblock]$DetectionScript,

        [Parameter(ParameterSetName = 'ScriptBlock')]
        [scriptblock]$RemediationScript,

        [Parameter()]
        [switch]$Remediate,

        [Parameter()]
        [switch]$Verify
    )

    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
    $timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")

    function Run-PowerShellWorker {
        param (
            [string]$Path,
            [scriptblock]$Block
        )

        if ($Path) {
            if (-not (Test-Path -Path $Path)) {
                throw "Script file not found: '$Path'"
            }
            $command = "& '$Path'"
        }
        else {
            $command = $Block.ToString()
        }

        # Run in isolated child process to reliably trap standard exit code
        $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))
        $pinfo = New-Object System.Diagnostics.ProcessStartInfo
        $pinfo.FileName = (Get-Process -Id $PID).Path
        $pinfo.Arguments = "-NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded"
        $pinfo.RedirectStandardOutput = $true
        $pinfo.RedirectStandardError = $true
        $pinfo.UseShellExecute = $false
        $pinfo.CreateNoWindow = $true

        $proc = [System.Diagnostics.Process]::Start($pinfo)
        $stdout = $proc.StandardOutput.ReadToEnd()
        $stderr = $proc.StandardError.ReadToEnd()
        $proc.WaitForExit()

        $combinedOutput = $stdout
        if ($stderr) {
            $combinedOutput += "`n[STDERR]: $stderr"
        }

        return [PSCustomObject]@{
            ExitCode = $proc.ExitCode
            Output = $combinedOutput.Trim()
        }
    }

    Write-Verbose "[$Name] Executing detection pass..."
    $detectResult = Run-PowerShellWorker -Path $DetectionScriptPath -Block $DetectionScript

    $remediationExecuted = $false
    $remedResult = $null
    $finalResult = $null
    $status = if ($detectResult.ExitCode -eq 0) { 'Compliant' } else { 'NonCompliant' }

    if ($detectResult.ExitCode -ne 0 -and $Remediate) {
        Write-Verbose "[$Name] Machine is Non-Compliant. Triggering local remediation..."
        $remediationExecuted = $true
        $remedResult = Run-PowerShellWorker -Path $RemediationScriptPath -Block $RemediationScript

        if ($Verify) {
            Write-Verbose "[$Name] Verifying compliance post-remediation..."
            $finalResult = Run-PowerShellWorker -Path $DetectionScriptPath -Block $DetectionScript
            $status = if ($finalResult.ExitCode -eq 0) { 'Remediated' } else { 'RemediationFailed' }
        }
        else {
            $status = if ($remedResult.ExitCode -eq 0) { 'Remediated' } else { 'RemediationFailed' }
        }
    }

    $stopwatch.Stop()

    return [PSCustomObject]@{
        Name = $Name
        Status = $status
        Compliant = ($status -in @('Compliant', 'Remediated'))
        RemediationExecuted = $remediationExecuted
        InitialExitCode = $detectResult.ExitCode
        InitialOutput = $detectResult.Output
        RemediationExitCode = if ($remedResult) { $remedResult.ExitCode } else { $null }
        RemediationOutput = if ($remedResult) { $remedResult.Output } else { $null }
        FinalExitCode = if ($finalResult) { $finalResult.ExitCode } else { $detectResult.ExitCode }
        DurationMs = [math]::Round($stopwatch.ElapsedMilliseconds, 2)
        Timestamp = $timestamp
    }
}