scripts/Install-ScanBridgeScheduledTask.ps1

<#
.SYNOPSIS
    Plans or registers a local ScanBridge task using NSP.Win.ScheduledTask.
.DESCRIPTION
    Plan-only unless -Execute is supplied. Does not run ScanBridge, install dependencies,
    provision certificates, change permissions or start the task manually. Registration
    enables future scheduled execution: provision and validate the selected task account first.
    Runner, configuration and PowerShell paths must already exist on this computer.
    Supply -ReplaceExisting explicitly to replace an existing task with the same path/name.

    -SysmonTrigger adds a second trigger on the same task: Sysmon file-create events (ID 11)
    whose RuleName is -SysmonRuleName, started -EventDelaySeconds later so the scanner can finish
    writing. The periodic trigger stays as the safety sweep. Runs are then queued rather than
    dropped (MultipleInstances Queue), so a scan that arrives during a pass gets its own pass and
    two passes never overlap on the worker lock. The task receives no filename from the event.
.EXAMPLE
    .\Install-ScanBridgeScheduledTask.ps1 -RunnerPath C:\Jobs\Run-ScanBridge.ps1 `
        -ConfigurationPath C:\Private\ScanBridge.psd1 -RunAs 'CONTOSO\svc_scan$' `
        -PowerShellPath C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
#>

[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
    [Parameter(Mandatory)][string]$RunnerPath,
    [Parameter(Mandatory)][string]$ConfigurationPath,
    [Parameter(Mandatory)][string]$PowerShellPath,
    [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$RunAs,
    [pscredential]$Credential,
    [ValidateRange(1,1440)][int]$EveryMinutes = 5,
    [datetime]$FirstRun = (Get-Date).AddMinutes(2),
    [ValidateRange(1,1440)][int]$TimeLimitMinutes = 30,
    [ValidateNotNullOrEmpty()][string]$TaskName = 'ScanBridge',
    [string]$TaskPath = '\NSP\',
    [string]$SchedulingModulePath,
    [switch]$SkipAlerts,
    [switch]$SysmonTrigger,
    [ValidateRange(0,3600)][int]$EventDelaySeconds = 50,
    [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$')][string]$SysmonRuleName = 'NSP ScanBridge',
    [switch]$Execute,
    [switch]$ReplaceExisting
)
$ErrorActionPreference = 'Stop'

function Resolve-TaskFile {
    param([string]$Path)
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw 'A required task file does not exist on this computer.' }
    $resolved = (Get-Item -LiteralPath $Path -ErrorAction Stop).FullName
    if ($resolved -match '["\r\n]') { throw 'Task file paths cannot contain quotation marks or newlines.' }
    $resolved
}
$runner = Resolve-TaskFile $RunnerPath
$configuration = Resolve-TaskFile $ConfigurationPath
$hostPath = Resolve-TaskFile $PowerShellPath
if ([IO.Path]::GetFileName($runner) -ine 'Run-ScanBridge.ps1') { throw 'RunnerPath must select Run-ScanBridge.ps1.' }
if ([IO.Path]::GetFileName($hostPath) -notin @('powershell.exe','pwsh.exe')) { throw 'PowerShellPath must select powershell.exe or pwsh.exe.' }
if ([IO.Path]::GetExtension($configuration) -ine '.psd1') { throw 'ConfigurationPath must select a PowerShell data file.' }
if ($TaskName -match '[\\/\r\n]' -or $TaskPath -match '["\r\n]') { throw 'Invalid task name or path.' }
if (-not $TaskPath.StartsWith('\')) { $TaskPath = '\' + $TaskPath }
if (-not $TaskPath.EndsWith('\')) { $TaskPath += '\' }
$isSystem = $RunAs -in @('SYSTEM','NT AUTHORITY\SYSTEM')
$needsCredential = -not $isSystem -and -not $RunAs.EndsWith('$')
if ($Credential -and $Credential.UserName -ine $RunAs) { throw 'Credential must belong to the selected RunAs account.' }
$arguments = '-NoLogo -NoProfile -NonInteractive -File "{0}" -ConfigurationPath "{1}"' -f $runner,$configuration
if ($SkipAlerts) { $arguments += ' -SkipAlerts' }
$sysmonLog = 'Microsoft-Windows-Sysmon/Operational'
$subscription = if ($SysmonTrigger) {
    "<QueryList><Query Id=`"0`" Path=`"$sysmonLog`"><Select Path=`"$sysmonLog`">*[System[(EventID=11)]] and *[EventData[Data[@Name='RuleName']='$SysmonRuleName']]</Select></Query></QueryList>"
} else { $null }
$instances = if ($SysmonTrigger) { 'Queue' } else { 'IgnoreNew' }
$triggers = @("Every $EveryMinutes minutes from $FirstRun")
if ($SysmonTrigger) { $triggers += "Sysmon file create with RuleName '$SysmonRuleName', $EventDelaySeconds s later" }
$plan = [pscustomobject]@{
    Status = 'PlanOnly'; TaskName = $TaskName; TaskPath = $TaskPath
    ComputerName = [Environment]::MachineName; RunAs = $RunAs
    PowerShellPath = $hostPath; Arguments = $arguments
    WorkingDirectory = (Split-Path -Parent $runner)
    FirstRun = $FirstRun; EveryMinutes = $EveryMinutes
    Triggers = $triggers; EventDelaySeconds = $(if ($SysmonTrigger) { $EventDelaySeconds } else { $null })
    EventSubscription = $subscription
    TimeLimitMinutes = $TimeLimitMinutes; MultipleInstances = $instances
    RunLevel = 'Limited'; StartWhenAvailable = $true
    CredentialRequired = $needsCredential
}
if (-not $Execute) { return $plan }
if (-not $PSCmdlet.ShouldProcess("$TaskPath$TaskName", "Register ScanBridge ($($triggers -join '; ')) as $RunAs on this computer")) { return $plan }
if ($FirstRun -le (Get-Date)) { throw 'FirstRun must be in the future when registering the task.' }
if ($needsCredential -and -not $Credential) { throw 'Supply -Credential for the selected user account, or select an already provisioned gMSA or SYSTEM.' }

if ($SchedulingModulePath) { Import-Module $SchedulingModulePath -ErrorAction Stop }
else { Import-Module NSP.Win.ScheduledTask -ErrorAction Stop }
$existing = Get-ScheduledTask -TaskName $TaskName -TaskPath $TaskPath -ErrorAction SilentlyContinue
if ($existing -and -not $ReplaceExisting) { throw 'The task already exists. Review it before supplying -ReplaceExisting.' }
$trigger = @(New-ScheduledTaskTrigger -Once -At $FirstRun -RepetitionInterval (New-TimeSpan -Minutes $EveryMinutes))
if ($SysmonTrigger) {
    $eventTriggerArgs = @{ Subscription = $subscription }
    if ($EventDelaySeconds) { $eventTriggerArgs.Delay = New-TimeSpan -Seconds $EventDelaySeconds }
    $trigger += New-NSPEventTaskTrigger @eventTriggerArgs
}
$definitionArgs = @{
    TaskName = $TaskName; TaskPath = $TaskPath
    Description = 'Run one ScanBridge pass; exit 2 means held jobs or worker failure, exit 3 means alert failure.'
    Execute = $hostPath; Argument = $arguments; WorkingDirectory = $plan.WorkingDirectory
    RunAs = $RunAs; Trigger = $trigger; LimitedRunLevel = $true
    ExecutionTimeLimit = (New-TimeSpan -Minutes $TimeLimitMinutes)
    MultipleInstances = $instances; StartWhenAvailable = $true
}
if ($Credential) { $definitionArgs.Credential = $Credential }
$definition = New-NSPScheduledTaskDefinition @definitionArgs
$null = Register-NSPScheduledTask -Definition $definition -Confirm:$false -ErrorAction Stop
$plan.Status = 'Registered'
$plan