Private/ScanBridgeTaskReplace.ps1

# Replacing an existing ScanBridge task after an upgrade: the old task is shown against the plan,
# confirmed, exported, then replaced. Replacing never changes whether the task runs: a Disabled
# task stays Disabled. A task in the middle of a pass is left alone.

function Get-NSPScanBridgeExistingTask {
    param([Parameter(Mandatory)][string]$TaskName,[Parameter(Mandatory)][string]$TaskPath)
    Get-ScheduledTask -TaskName $TaskName -TaskPath $TaskPath -ErrorAction SilentlyContinue
}

function ConvertTo-NSPScanBridgeEventTriggerText {
    param([string]$Subscription,[int]$DelaySeconds)
    $name=if($Subscription -match "@Name='RuleName'\]='(?<rule>[^']+)'"){"Sysmon file create '$($Matches.rule)'"}else{'Event'}
    '{0}, {1} s later' -f $name,$DelaySeconds
}

function Get-NSPScanBridgeTaskTriggerText {
    # One comparable line per trigger on a registered task, in the plan's order.
    param([object[]]$Trigger)
    $lines=foreach($item in @($Trigger)) {
        if(-not $item) { continue }
        if($item.Subscription) {
            $delay=if($item.Delay){[int][Xml.XmlConvert]::ToTimeSpan([string]$item.Delay).TotalSeconds}else{0}
            '2|'+(ConvertTo-NSPScanBridgeEventTriggerText -Subscription $item.Subscription -DelaySeconds $delay)
        } elseif($item.Repetition -and $item.Repetition.Interval) {
            '1|Every {0} minutes' -f [int][Xml.XmlConvert]::ToTimeSpan([string]$item.Repetition.Interval).TotalMinutes
        } else { '3|Other trigger' }
    }
    @($lines | Sort-Object | ForEach-Object { $_.Substring(2) })
}

function Get-NSPScanBridgeRunnerVersion {
    param([string]$RunnerPath)
    if($RunnerPath -match '\\NSP\.ScanBridge\\(?<version>\d+(\.\d+)+)\\') { $Matches.version } else { 'unversioned path' }
}

function Get-NSPScanBridgeTaskComparison {
    <#
        The registered task against a plan from Install-ScanBridgeScheduledTask.ps1. Rows carry
        Field, Current, Planned and Changed. Paths compare without case; accounts by name, so
        COMPUTER\name and .\name are the same account.
    #>

    param([Parameter(Mandatory)]$Task,[Parameter(Mandatory)]$Plan)
    $action=@($Task.Actions)[0]
    $arguments=[string]$action.Arguments
    $runnerPath=if($arguments -match '-File "(?<path>[^"]+)"'){$Matches.path}else{$null}
    $plannedRunner=if($Plan.Arguments -match '-File "(?<path>[^"]+)"'){$Matches.path}else{$null}
    $planTriggers=@("Every $($Plan.EveryMinutes) minutes")
    if($Plan.EventSubscription) { $planTriggers+=ConvertTo-NSPScanBridgeEventTriggerText -Subscription $Plan.EventSubscription -DelaySeconds $Plan.EventDelaySeconds }
    $leaf={ param($Name) ([string]$Name -split '\\')[-1] }
    $row={ param($Field,$Current,$Planned) [pscustomobject]@{Field=$Field;Current=[string]$Current;Planned=[string]$Planned;Changed=([string]$Current -ine [string]$Planned)} }
    $state=[string]$Task.State
    [pscustomobject]@{
        TaskPath=[string]$Task.TaskPath
        TaskName=[string]$Task.TaskName
        State=$state
        Busy=$state -in @('Running','Queued')
        Disabled=$state -eq 'Disabled'
        RunnerPath=$runnerPath
        RunnerMissing=[bool]$runnerPath -and -not (Test-Path -LiteralPath $runnerPath -PathType Leaf)
        Rows=@(
            & $row 'PowerShell' $action.Execute $Plan.PowerShellPath
            & $row 'ScanBridge version' $(if($runnerPath){Get-NSPScanBridgeRunnerVersion $runnerPath}else{'not found in the arguments'}) (Get-NSPScanBridgeRunnerVersion $plannedRunner)
            & $row 'Arguments' $arguments $Plan.Arguments
            & $row 'Account' (& $leaf $Task.Principal.UserId) (& $leaf $Plan.RunAs)
            & $row 'Triggers' ((Get-NSPScanBridgeTaskTriggerText -Trigger $Task.Triggers) -join '; ') ($planTriggers -join '; ')
            & $row 'Overlapping runs' $Task.Settings.MultipleInstances $Plan.MultipleInstances
        )
    }
}

function Show-NSPScanBridgeTaskComparison {
    param([Parameter(Mandatory)]$Comparison)
    Write-NSPConsoleLine ('Existing task {0}{1} ({2}), against this plan:' -f $Comparison.TaskPath,$Comparison.TaskName,$Comparison.State)
    foreach($item in $Comparison.Rows) {
        if($item.Changed) { Write-NSPConsoleLine (' - {0} [change]: {1} -> {2}' -f $item.Field,$item.Current,$item.Planned) }
        else { Write-NSPConsoleLine (' - {0}: same ({1})' -f $item.Field,$item.Current) }
    }
    if($Comparison.RunnerMissing) { Write-NSPConsoleLine "The existing task's runner no longer exists, so it cannot start until it is replaced: $($Comparison.RunnerPath)" -Role Warning }
    if($Comparison.Disabled) { Write-NSPConsoleLine 'The task is Disabled and stays Disabled after a replace.' -Role Muted }
}

function Export-NSPScanBridgeTaskDefinition {
    <#
        Saves the old definition beside the settings file, Administrators and SYSTEM only, and
        returns its path. The XML holds no password; restoring it asks for the account's password.
        A failure leaves no file and stops the replace.
    #>

    param([Parameter(Mandatory)][string]$TaskName,[Parameter(Mandatory)][string]$TaskPath,[Parameter(Mandatory)][string]$ConfigurationPath)
    $xml=Export-ScheduledTask -TaskName $TaskName -TaskPath $TaskPath -ErrorAction Stop
    if(-not $xml) { throw 'The existing task could not be exported, so it was not replaced.' }
    $stamp=[DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ')
    $path=Join-Path (Split-Path -Parent $ConfigurationPath) ("$TaskName.$stamp.task.xml")
    [IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None).Dispose()
    try {
        Set-NSPScanBridgePrivateAcl -Path $path
        [IO.File]::WriteAllText($path,[string]$xml,[Text.Encoding]::Unicode)
    } catch {
        Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
        throw
    }
    $path
}

function Invoke-NSPScanBridgeTaskRegistration {
    <#
        Registers the planned task. With an existing task ($Comparison): refuses one in the middle
        of a pass, confirms, exports the old definition, replaces, then restores Disabled. Nothing
        is replaced unless the export succeeded.
    #>

    param(
        [Parameter(Mandatory)]$Installer,
        [Parameter(Mandatory)][hashtable]$Options,
        [Parameter(Mandatory)]$Plan,
        $Comparison,
        [Parameter(Mandatory)][string]$ConfigurationPath
    )
    if($Comparison) {
        if($Comparison.Busy) { Write-NSPConsoleLine "The task is $($Comparison.State). It was not replaced; try again when the pass has finished." -Role Error; return }
        if(-not (Test-NSPScanBridgeElevated)) { Write-NSPConsoleLine 'Replacing the task needs an elevated session. Restart PowerShell as administrator.' -Role Error; return }
        if(-not (Read-NSPConfirm -Prompt 'Replace this task? The old definition is exported first.')) { return }
    }
    Write-NSPConsoleLine 'Registration enables future runs. Validate dependencies, certificate/private-key and folder access under this account first. A Sysmon trigger needs ''Set up the Sysmon scan trigger'' first; this action does not configure Sysmon or parallel workers.' -Role Warning
    $runAs=[string]$Options.RunAs
    if($runAs -notin @('SYSTEM','NT AUTHORITY\SYSTEM') -and -not $runAs.EndsWith('$')) { $Options.Credential=Get-Credential -UserName $runAs -Message 'Task Scheduler stores this credential; toolkit answers do not.' }
    try {
        if(-not $Comparison) { & $Installer @Options -Execute -Confirm | Format-List; return }
        $export=Export-NSPScanBridgeTaskDefinition -TaskName $Plan.TaskName -TaskPath $Plan.TaskPath -ConfigurationPath $ConfigurationPath
        Write-NSPConsoleLine "Old task definition saved: $export" -Role Muted
        & $Installer @Options -Execute -ReplaceExisting -Confirm:$false | Format-List
    } finally { $Options.Remove('Credential') }
    if($Comparison.Disabled) {
        try { $null=Disable-ScheduledTask -TaskName $Plan.TaskName -TaskPath $Plan.TaskPath -ErrorAction Stop }
        catch { throw "The task was replaced and is ENABLED, because it could not be disabled again: $($_.Exception.Message)" }
        Write-NSPConsoleLine 'The task was replaced and stays Disabled, as it was.' -Role Success
    }
}