mcp-pacemaker 2.0.2 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +171 -1
- package/CONTRIBUTING.md +46 -13
- package/README.md +109 -524
- package/THIRD_PARTY_NOTICES.txt +1 -28
- package/autostart/windows/account-task.ps1 +167 -0
- package/autostart/windows/inspect-task.ps1 +42 -0
- package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
- package/autostart/windows/legacy-access.ps1 +359 -0
- package/autostart/windows/legacy-task.ps1 +301 -0
- package/autostart/windows/register-task.ps1 +11 -2
- package/bin/account-task-adapter.mjs +47 -0
- package/bin/account-upgrade-controller.mjs +198 -0
- package/bin/account-upgrade-worker.mjs +141 -0
- package/bin/account-worker-session.mjs +215 -0
- package/bin/cli-dispatch.mjs +154 -0
- package/bin/cli-main.mjs +1063 -0
- package/bin/cli-selection.mjs +43 -0
- package/bin/cli.mjs +7 -876
- package/bin/dependency-lock.json +642 -0
- package/bin/legacy-1.3.json +32 -0
- package/bin/legacy-access.mjs +73 -0
- package/bin/legacy-installation.mjs +273 -0
- package/bin/legacy-task.mjs +76 -0
- package/bin/legacy-upgrade.mjs +527 -0
- package/bin/managed-runtime.mjs +170 -0
- package/bin/managed-state.mjs +257 -0
- package/bin/managed-upgrade.mjs +581 -0
- package/bin/mcp-bridge.mjs +150 -4
- package/bin/pooling-batches.mjs +13 -0
- package/bin/pooling-writer.mjs +4 -0
- package/bin/prewarm.mjs +59 -25
- package/bin/runtime-context.mjs +100 -0
- package/bin/service-control.mjs +32 -4
- package/bin/stable-launcher.mjs +54 -0
- package/bin/task-only-controller.mjs +226 -0
- package/bin/task-scope.mjs +65 -0
- package/bin/task-transaction-protocol.mjs +173 -0
- package/bin/top.mjs +22 -10
- package/bin/update-check.mjs +13 -2
- package/bin/upgrade-autostart.mjs +47 -0
- package/bin/upgrade-capability.json +9 -0
- package/bin/upgrade-dependencies.mjs +183 -0
- package/bin/upgrade-package.mjs +205 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
- package/bin/windows-legacy/src/LegacyNative.cs +832 -0
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +961 -0
- package/bin/windows-legacy-process.mjs +248 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
- package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
- package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
- package/bin/windows-process-lifetime.mjs +218 -0
- package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
- package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
- package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
- package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
- package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
- package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
- package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
- package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
- package/bin/windows-task-channel.mjs +335 -0
- package/docs/account-upgrades.md +218 -0
- package/docs/compatibility.md +39 -0
- package/docs/configuration.md +146 -0
- package/docs/dashboards.md +125 -0
- package/docs/execution-context.md +119 -0
- package/docs/http-api.md +47 -0
- package/docs/install.md +52 -0
- package/docs/operations.md +99 -0
- package/docs/upgrades.md +324 -0
- package/package.json +19 -5
- package/supervisor/supervise.mjs +226 -40
- package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
- package/tools/windows-legacy/README.md +251 -0
- package/tools/windows-legacy/build.ps1 +122 -0
- package/tools/windows-legacy/inventory.mjs +92 -0
- package/tools/windows-process-lifetime/README.md +119 -0
- package/tools/windows-process-lifetime/build.ps1 +122 -0
- package/tools/windows-process-lifetime/inventory.mjs +86 -0
- package/tools/windows-task-channel/README.md +329 -0
- package/tools/windows-task-channel/build.ps1 +107 -0
- package/tools/windows-task-channel/inventory.mjs +117 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
- package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
- package/ui/dist/assets/index-PVcuDNrN.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/third-party-manifest.json +37 -25
- package/ui/dist/assets/index-CaLPg0c-.css +0 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Checks, or grants, the runtime user's access to one known legacy 1.3 registration and CLI file.
|
|
4
|
+
.DESCRIPTION
|
|
5
|
+
inspect is read-only. It reports whether the ordinary runtime user can make the task writes
|
|
6
|
+
and the bin\cli.mjs stage/move operations that legacy migration needs. Only allow ACEs that
|
|
7
|
+
apply to that user are counted, so a reported gap is certain; deny ACEs can still refuse later.
|
|
8
|
+
A non-elevated caller is evaluated with its own token. An elevated caller is evaluated as its
|
|
9
|
+
UAC-filtered ordinary context: its user SID and groups, without administrator groups.
|
|
10
|
+
|
|
11
|
+
grant requires an elevated administrator and the descriptors returned by inspect. It gives the
|
|
12
|
+
task's run-as user full control of that task and makes that user the owner of bin\cli.mjs,
|
|
13
|
+
only where inspect reports that gap. Task XML, run-as account, task owner/group, other ACEs,
|
|
14
|
+
audit/label data, file bytes and the file's DACL are unchanged and verified afterwards.
|
|
15
|
+
.PARAMETER Operation
|
|
16
|
+
inspect or grant.
|
|
17
|
+
.PARAMETER Port
|
|
18
|
+
The selected Pacemaker port.
|
|
19
|
+
.PARAMETER CliPath
|
|
20
|
+
The selected legacy package's bin\cli.mjs. It must belong to the selected task's launcher root.
|
|
21
|
+
.PARAMETER FunctionsOnly
|
|
22
|
+
Loads the access functions without connecting to Task Scheduler.
|
|
23
|
+
.OUTPUTS
|
|
24
|
+
JSON access report. grant reads {"expected": {...}} from standard input.
|
|
25
|
+
.EXAMPLE
|
|
26
|
+
.\legacy-access.ps1 -Operation inspect -Port 8791 -CliPath C:\src\mcp-pacemaker\bin\cli.mjs
|
|
27
|
+
#>
|
|
28
|
+
[CmdletBinding()]
|
|
29
|
+
param(
|
|
30
|
+
[Parameter(Mandatory)]
|
|
31
|
+
[ValidateSet('inspect', 'grant')]
|
|
32
|
+
[string]$Operation,
|
|
33
|
+
[Parameter(Mandatory)]
|
|
34
|
+
[ValidateRange(1, 65535)]
|
|
35
|
+
[int]$Port,
|
|
36
|
+
[Parameter(Mandatory)]
|
|
37
|
+
[string]$CliPath,
|
|
38
|
+
[switch]$FunctionsOnly
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
$ErrorActionPreference = 'Stop'
|
|
42
|
+
# legacy-task.ps1 binds its own parameters in this scope when dot-sourced.
|
|
43
|
+
$accessOperation = $Operation
|
|
44
|
+
$accessPort = $Port
|
|
45
|
+
$accessCliPath = $CliPath
|
|
46
|
+
$accessFunctionsOnly = [bool]$FunctionsOnly
|
|
47
|
+
. (Join-Path $PSScriptRoot 'legacy-task.ps1') -Operation inspect -Port $accessPort -FunctionsOnly
|
|
48
|
+
|
|
49
|
+
$fileReadData = 0x1
|
|
50
|
+
# FILE_WRITE_DATA on a file is FILE_ADD_FILE on a directory.
|
|
51
|
+
$fileWriteData = 0x2
|
|
52
|
+
$fileDeleteChild = 0x40
|
|
53
|
+
$deleteAccess = 0x10000
|
|
54
|
+
$readControl = 0x20000
|
|
55
|
+
$writeDac = 0x40000
|
|
56
|
+
$fullControl = 0x1F01FF
|
|
57
|
+
# PoolingSecurityHelper stage opens the source with READ_CONTROL | FILE_READ_DATA | FILE_WRITE_DATA.
|
|
58
|
+
$stageSource = $readControl -bor $fileReadData -bor $fileWriteData
|
|
59
|
+
$fixable = @('task-write', 'cli-owner')
|
|
60
|
+
$uacFilteredSids = @('S-1-5-32-544', 'S-1-5-32-547', 'S-1-5-32-548', 'S-1-5-32-549', 'S-1-5-32-550',
|
|
61
|
+
'S-1-5-32-551', 'S-1-5-32-552', 'S-1-5-32-556')
|
|
62
|
+
$uacFilteredDomainRids = @('-498', '-512', '-516', '-517', '-518', '-519', '-520', '-521')
|
|
63
|
+
|
|
64
|
+
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
65
|
+
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
|
66
|
+
$elevated = $principal.IsInRole([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))
|
|
67
|
+
|
|
68
|
+
function Test-UacFiltered {
|
|
69
|
+
param([string]$Sid)
|
|
70
|
+
if ($Sid -cin $uacFilteredSids) { return $true }
|
|
71
|
+
if (-not $Sid.StartsWith('S-1-5-21-', [StringComparison]::Ordinal)) { return $false }
|
|
72
|
+
foreach ($rid in $uacFilteredDomainRids) {
|
|
73
|
+
if ($Sid.EndsWith($rid, [StringComparison]::Ordinal)) { return $true }
|
|
74
|
+
}
|
|
75
|
+
return $false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
$ordinarySids = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
|
79
|
+
$null = $ordinarySids.Add($identity.User.Value)
|
|
80
|
+
foreach ($group in $identity.Groups) {
|
|
81
|
+
if (-not (Test-UacFiltered -Sid $group.Value)) { $null = $ordinarySids.Add($group.Value) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function Test-CallerSid {
|
|
85
|
+
param([string]$Sid)
|
|
86
|
+
if ($elevated) { return $ordinarySids.Contains($Sid) }
|
|
87
|
+
if ($Sid -ceq $identity.User.Value) { return $true }
|
|
88
|
+
return $principal.IsInRole([Security.Principal.SecurityIdentifier]::new($Sid))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ConvertTo-FileRights {
|
|
92
|
+
param([int]$Mask)
|
|
93
|
+
[int64]$value = [BitConverter]::ToUInt32([BitConverter]::GetBytes($Mask), 0)
|
|
94
|
+
[int64]$rights = $value -band 0x01FFFFFF
|
|
95
|
+
# GENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE and GENERIC_ALL use the file mapping.
|
|
96
|
+
if ($value -band 2147483648) { $rights = $rights -bor 0x120089 }
|
|
97
|
+
if ($value -band 1073741824) { $rights = $rights -bor 0x120116 }
|
|
98
|
+
if ($value -band 536870912) { $rights = $rights -bor 0x1200A0 }
|
|
99
|
+
if ($value -band 268435456) { $rights = $rights -bor $fullControl }
|
|
100
|
+
return $rights
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function Get-AllowedRights {
|
|
104
|
+
param([string]$Sddl)
|
|
105
|
+
$descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($Sddl)
|
|
106
|
+
$ownerApplies = Test-CallerSid -Sid $descriptor.Owner.Value
|
|
107
|
+
if ($null -eq $descriptor.DiscretionaryAcl) {
|
|
108
|
+
return [pscustomobject]@{ Allowed = [int64]$fullControl; OwnerSid = $descriptor.Owner.Value }
|
|
109
|
+
}
|
|
110
|
+
[int64]$allowed = 0
|
|
111
|
+
$ownerRightsAce = $false
|
|
112
|
+
foreach ($ace in $descriptor.DiscretionaryAcl) {
|
|
113
|
+
if ($ace.AceFlags -band [Security.AccessControl.AceFlags]::InheritOnly) { continue }
|
|
114
|
+
$common = $ace -as [Security.AccessControl.CommonAce]
|
|
115
|
+
if ($null -eq $common) { continue }
|
|
116
|
+
$sid = $common.SecurityIdentifier.Value
|
|
117
|
+
$ownerRights = $sid -ceq 'S-1-3-4'
|
|
118
|
+
if ($ownerRights) { $ownerRightsAce = $true }
|
|
119
|
+
$applies = if ($ownerRights) { $ownerApplies } else { Test-CallerSid -Sid $sid }
|
|
120
|
+
$grants = $applies -and
|
|
121
|
+
-not $common.IsCallback -and
|
|
122
|
+
$common.AceQualifier -eq [Security.AccessControl.AceQualifier]::AccessAllowed
|
|
123
|
+
if ($grants) { $allowed = $allowed -bor (ConvertTo-FileRights -Mask $common.AccessMask) }
|
|
124
|
+
}
|
|
125
|
+
# Owners implicitly hold READ_CONTROL and WRITE_DAC unless an OWNER RIGHTS ACE replaces them.
|
|
126
|
+
if ($ownerApplies -and
|
|
127
|
+
-not $ownerRightsAce) { $allowed = $allowed -bor $readControl -bor $writeDac }
|
|
128
|
+
return [pscustomobject]@{ Allowed = $allowed; OwnerSid = $descriptor.Owner.Value }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Get-AccountName {
|
|
132
|
+
param([string]$Sid)
|
|
133
|
+
try { return ([Security.Principal.SecurityIdentifier]::new($Sid)).Translate([Security.Principal.NTAccount]).Value }
|
|
134
|
+
catch { return $Sid }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Get-LegacyCliPath {
|
|
138
|
+
param($Record)
|
|
139
|
+
$arguments = [string]$Record.actions[0].arguments
|
|
140
|
+
$suffix = "`" $accessPort"
|
|
141
|
+
if (-not $arguments.StartsWith('"') -or
|
|
142
|
+
-not $arguments.EndsWith($suffix)) { throw 'Unsupported legacy launcher arguments.' }
|
|
143
|
+
$launcher = [IO.Path]::GetFullPath($arguments.Substring(1, $arguments.Length - 1 - $suffix.Length))
|
|
144
|
+
$root = [IO.Path]::GetDirectoryName([IO.Path]::GetDirectoryName([IO.Path]::GetDirectoryName($launcher)))
|
|
145
|
+
return [IO.Path]::Combine($root, 'bin', 'cli.mjs')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function Get-FileSddl {
|
|
149
|
+
param([IO.FileSystemInfo]$Item)
|
|
150
|
+
$sections = [Security.AccessControl.AccessControlSections]'Owner, Group, Access'
|
|
151
|
+
if ($Item -is [IO.DirectoryInfo]) {
|
|
152
|
+
return [IO.FileSystemAclExtensions]::GetAccessControl([IO.DirectoryInfo]$Item).GetSecurityDescriptorSddlForm($sections)
|
|
153
|
+
}
|
|
154
|
+
return [IO.FileSystemAclExtensions]::GetAccessControl([IO.FileInfo]$Item).GetSecurityDescriptorSddlForm($sections)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function Get-AccessReport {
|
|
158
|
+
param($Task, [string]$Path)
|
|
159
|
+
$record = Get-TaskRecord -Task $Task
|
|
160
|
+
$expectedCli = Get-LegacyCliPath -Record $record
|
|
161
|
+
$file = [IO.FileInfo]::new($Path)
|
|
162
|
+
if (-not [string]::Equals($expectedCli, $file.FullName, [StringComparison]::OrdinalIgnoreCase)) {
|
|
163
|
+
throw 'The CLI file does not belong to the selected legacy registration.'
|
|
164
|
+
}
|
|
165
|
+
# PowerShell reports LinkType HardLink when a file has more than one link.
|
|
166
|
+
$linkType = if ($file.Exists) { (Get-Item -LiteralPath $file.FullName -Force).LinkType } else { $null }
|
|
167
|
+
$regular = $file.Exists -and
|
|
168
|
+
-not ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -and
|
|
169
|
+
[string]::IsNullOrEmpty($linkType)
|
|
170
|
+
if (-not $regular) { throw 'The legacy CLI file must be one regular file without links.' }
|
|
171
|
+
$cliSddl = Get-FileSddl -Item $file
|
|
172
|
+
$binSddl = Get-FileSddl -Item $file.Directory
|
|
173
|
+
$taskRights = Get-AllowedRights -Sddl $record.security.sddl
|
|
174
|
+
$cliRights = Get-AllowedRights -Sddl $cliSddl
|
|
175
|
+
$binRights = Get-AllowedRights -Sddl $binSddl
|
|
176
|
+
$runtimeSid = $identity.User.Value
|
|
177
|
+
# Staging keeps the owner when it is the caller's user SID or (non-elevated) default owner.
|
|
178
|
+
$ownerRetained = $cliRights.OwnerSid -ceq $runtimeSid -or
|
|
179
|
+
(-not $elevated -and $cliRights.OwnerSid -ceq $identity.Owner.Value)
|
|
180
|
+
$canDelete = ($cliRights.Allowed -band $deleteAccess) -ne 0 -or
|
|
181
|
+
($binRights.Allowed -band $fileDeleteChild) -ne 0
|
|
182
|
+
$missing = [Collections.Generic.List[string]]::new()
|
|
183
|
+
if (($taskRights.Allowed -band $fileWriteData) -eq 0) { $missing.Add('task-write') }
|
|
184
|
+
if (-not $ownerRetained -and
|
|
185
|
+
($cliRights.Allowed -band $writeDac) -eq 0) { $missing.Add('cli-owner') }
|
|
186
|
+
if (($cliRights.Allowed -band $stageSource) -ne $stageSource) { $missing.Add('cli-write') }
|
|
187
|
+
if (-not $canDelete) { $missing.Add('cli-delete') }
|
|
188
|
+
if (($binRights.Allowed -band $fileWriteData) -eq 0) { $missing.Add('bin-add-file') }
|
|
189
|
+
return [ordered]@{
|
|
190
|
+
protocol = 1
|
|
191
|
+
elevated = $elevated
|
|
192
|
+
evaluated = if ($elevated) { 'ordinary-context-of-elevated-token' } else { 'current-token' }
|
|
193
|
+
runtimeSidMatches = $record.userSid -ceq $runtimeSid
|
|
194
|
+
task = [ordered]@{
|
|
195
|
+
name = $record.name
|
|
196
|
+
path = $record.path
|
|
197
|
+
ownerName = Get-AccountName -Sid ([Security.AccessControl.RawSecurityDescriptor]::new($record.security.sddl).Owner.Value)
|
|
198
|
+
}
|
|
199
|
+
cli = [ordered]@{
|
|
200
|
+
path = $file.FullName
|
|
201
|
+
directory = $file.DirectoryName
|
|
202
|
+
ownerName = Get-AccountName -Sid $cliRights.OwnerSid
|
|
203
|
+
}
|
|
204
|
+
missing = @($missing)
|
|
205
|
+
fixable = @($missing | Where-Object { $_ -cin $fixable })
|
|
206
|
+
expected = [ordered]@{ taskSddl = $record.security.sddl; xmlSha256 = $record.xmlSha256; cliSddl = $cliSddl }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function Get-AceKeys {
|
|
211
|
+
param([string]$Sddl, [string]$RuntimeSid)
|
|
212
|
+
$descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($Sddl)
|
|
213
|
+
if ($null -eq $descriptor.DiscretionaryAcl) { return '' }
|
|
214
|
+
$keys = foreach ($ace in $descriptor.DiscretionaryAcl) {
|
|
215
|
+
$common = $ace -as [Security.AccessControl.CommonAce]
|
|
216
|
+
$explicitRuntime = $null -ne $common -and
|
|
217
|
+
($ace.AceFlags -band [Security.AccessControl.AceFlags]::Inherited) -eq 0 -and
|
|
218
|
+
$common.SecurityIdentifier.Value -ceq $RuntimeSid
|
|
219
|
+
if ($explicitRuntime) { continue }
|
|
220
|
+
$bytes = [byte[]]::new($ace.BinaryLength)
|
|
221
|
+
$ace.GetBinaryForm($bytes, 0)
|
|
222
|
+
[Convert]::ToBase64String($bytes)
|
|
223
|
+
}
|
|
224
|
+
return (@($keys) | Sort-Object) -join '|'
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function Assert-TaskAccessChange {
|
|
228
|
+
param([string]$Before, [string]$After, [string]$RuntimeSid)
|
|
229
|
+
$old = [Security.AccessControl.RawSecurityDescriptor]::new($Before)
|
|
230
|
+
$new = [Security.AccessControl.RawSecurityDescriptor]::new($After)
|
|
231
|
+
$sections = [Security.AccessControl.AccessControlSections]::Audit
|
|
232
|
+
$unchanged = $old.Owner -eq $new.Owner -and
|
|
233
|
+
$old.Group -eq $new.Group -and
|
|
234
|
+
$old.GetSddlForm($sections) -ceq $new.GetSddlForm($sections) -and
|
|
235
|
+
(Get-AceKeys -Sddl $Before -RuntimeSid $RuntimeSid) -ceq (Get-AceKeys -Sddl $After -RuntimeSid $RuntimeSid)
|
|
236
|
+
if (-not $unchanged) {
|
|
237
|
+
throw 'LEGACY_ACCESS_DRIFT: Task security changed beyond the runtime user entry. Inspect it before retrying.'
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function Grant-TaskWrite {
|
|
242
|
+
param($Task, [string]$RuntimeSid)
|
|
243
|
+
$descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($Task.GetSecurityDescriptor(7))
|
|
244
|
+
$acl = $descriptor.DiscretionaryAcl
|
|
245
|
+
if ($null -eq $acl) { throw 'Task has no discretionary ACL; nothing was changed.' }
|
|
246
|
+
$sid = [Security.Principal.SecurityIdentifier]::new($RuntimeSid)
|
|
247
|
+
$replacement = [Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]::None,
|
|
248
|
+
[Security.AccessControl.AceQualifier]::AccessAllowed, $fullControl, $sid, $false, $null)
|
|
249
|
+
$explicitEnd = $acl.Count
|
|
250
|
+
for ($index = 0; $index -lt $acl.Count; $index++) {
|
|
251
|
+
if ($acl[$index].AceFlags -band [Security.AccessControl.AceFlags]::Inherited) { $explicitEnd = $index; break }
|
|
252
|
+
}
|
|
253
|
+
# Task Scheduler can return the principal's explicit entry after inherited ones, so search them all.
|
|
254
|
+
$updated = $false
|
|
255
|
+
for ($index = 0; $index -lt $acl.Count; $index++) {
|
|
256
|
+
$common = $acl[$index] -as [Security.AccessControl.CommonAce]
|
|
257
|
+
$runtimeAllow = $null -ne $common -and
|
|
258
|
+
-not $common.IsCallback -and
|
|
259
|
+
$common.AceQualifier -eq [Security.AccessControl.AceQualifier]::AccessAllowed -and
|
|
260
|
+
$common.AceFlags -eq [Security.AccessControl.AceFlags]::None -and
|
|
261
|
+
$common.SecurityIdentifier -eq $sid
|
|
262
|
+
if ($runtimeAllow) {
|
|
263
|
+
$acl.RemoveAce($index)
|
|
264
|
+
$acl.InsertAce($index, $replacement)
|
|
265
|
+
$updated = $true
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
# A new entry goes before inherited ones; Windows keeps explicit entries first.
|
|
270
|
+
if (-not $updated) { $acl.InsertAce($explicitEnd, $replacement) }
|
|
271
|
+
$sddl = $descriptor.GetSddlForm([Security.AccessControl.AccessControlSections]'Owner, Group, Access')
|
|
272
|
+
# 0x10 is TASK_DONT_ADD_PRINCIPAL_ACE.
|
|
273
|
+
$Task.SetSecurityDescriptor($sddl, 0x10)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function Set-CliOwner {
|
|
277
|
+
param([string]$Path, [string]$RuntimeSid)
|
|
278
|
+
# A new FileSecurity persists only the sections that were set: here, the owner.
|
|
279
|
+
$security = [Security.AccessControl.FileSecurity]::new()
|
|
280
|
+
$security.SetOwner([Security.Principal.SecurityIdentifier]::new($RuntimeSid))
|
|
281
|
+
[IO.FileSystemAclExtensions]::SetAccessControl([IO.FileInfo]::new($Path), $security)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function Get-FileDigest {
|
|
285
|
+
param([string]$Path)
|
|
286
|
+
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if ($accessFunctionsOnly) { return }
|
|
290
|
+
$script:accessChanged = [Collections.Generic.List[string]]::new()
|
|
291
|
+
|
|
292
|
+
function Invoke-LegacyAccess {
|
|
293
|
+
$service = New-Object -ComObject Schedule.Service
|
|
294
|
+
$service.Connect()
|
|
295
|
+
$folder = $service.GetFolder('\')
|
|
296
|
+
$script:folderCount = 0
|
|
297
|
+
$tasks = @(Get-MatchingTasks -TaskFolder $folder -Depth 0)
|
|
298
|
+
if ($tasks.Count -ne 1) { throw 'Exactly one selected legacy registration is required.' }
|
|
299
|
+
$task = $tasks[0]
|
|
300
|
+
$report = Get-AccessReport -Task $task -Path $accessCliPath
|
|
301
|
+
if ($accessOperation -eq 'inspect') { return $report }
|
|
302
|
+
|
|
303
|
+
if (-not $elevated) { throw 'LEGACY_ACCESS_NOT_ELEVATED: Run --fix-legacy-access from an administrator terminal. Nothing was changed.' }
|
|
304
|
+
$text = [Console]::In.ReadToEnd()
|
|
305
|
+
if ($text.Length -gt 1048576) { throw 'Access request exceeds the supported bound.' }
|
|
306
|
+
$request = $text | ConvertFrom-Json
|
|
307
|
+
$record = Get-TaskRecord -Task $task
|
|
308
|
+
$planned = $report.expected.taskSddl -ceq $request.expected.taskSddl -and
|
|
309
|
+
$report.expected.xmlSha256 -ceq $request.expected.xmlSha256 -and
|
|
310
|
+
$report.expected.cliSddl -ceq $request.expected.cliSddl
|
|
311
|
+
if (-not $planned) { throw 'LEGACY_ACCESS_CHANGED: The task or CLI file changed after the plan was shown. Nothing was changed.' }
|
|
312
|
+
$selected = $report.runtimeSidMatches -and
|
|
313
|
+
$record.logonType -eq 3 -and
|
|
314
|
+
$record.runLevel -eq 0
|
|
315
|
+
if (-not $selected) { throw 'Selected task is not a non-elevated InteractiveToken task for this account. Nothing was changed.' }
|
|
316
|
+
$manual = @($report.missing | Where-Object { $_ -cnotin $fixable })
|
|
317
|
+
if ($manual.Count) { throw "LEGACY_ACCESS_MANUAL: These need a manual permission change: $($manual -join ', '). Nothing was changed." }
|
|
318
|
+
$runtimeSid = $identity.User.Value
|
|
319
|
+
$cliDigest = Get-FileDigest -Path $accessCliPath
|
|
320
|
+
if ($report.missing -ccontains 'task-write') {
|
|
321
|
+
Grant-TaskWrite -Task $task -RuntimeSid $runtimeSid
|
|
322
|
+
$script:accessChanged.Add('task-write')
|
|
323
|
+
}
|
|
324
|
+
if ($report.missing -ccontains 'cli-owner') {
|
|
325
|
+
Set-CliOwner -Path $accessCliPath -RuntimeSid $runtimeSid
|
|
326
|
+
$script:accessChanged.Add('cli-owner')
|
|
327
|
+
}
|
|
328
|
+
$updatedTask = $folder.GetTask($task.Name)
|
|
329
|
+
$after = Get-AccessReport -Task $updatedTask -Path $accessCliPath
|
|
330
|
+
$updatedRecord = Get-TaskRecord -Task $updatedTask
|
|
331
|
+
Assert-TaskAccessChange -Before $report.expected.taskSddl -After $after.expected.taskSddl -RuntimeSid $runtimeSid
|
|
332
|
+
$beforeCli = [Security.AccessControl.RawSecurityDescriptor]::new($report.expected.cliSddl)
|
|
333
|
+
$afterCli = [Security.AccessControl.RawSecurityDescriptor]::new($after.expected.cliSddl)
|
|
334
|
+
$accessSection = [Security.AccessControl.AccessControlSections]::Access
|
|
335
|
+
$verified = $updatedRecord.xmlSha256 -ceq $record.xmlSha256 -and
|
|
336
|
+
$updatedRecord.enabled -eq $record.enabled -and
|
|
337
|
+
$updatedRecord.userSid -ceq $record.userSid -and
|
|
338
|
+
$beforeCli.Group -eq $afterCli.Group -and
|
|
339
|
+
$beforeCli.GetSddlForm($accessSection) -ceq $afterCli.GetSddlForm($accessSection) -and
|
|
340
|
+
(Get-FileDigest -Path $accessCliPath) -ceq $cliDigest -and
|
|
341
|
+
-not ($after.missing | Where-Object { $_ -cin $fixable })
|
|
342
|
+
if (-not $verified) { throw 'LEGACY_ACCESS_UNVERIFIED: The access change could not be verified. Inspect the task and CLI file.' }
|
|
343
|
+
return [ordered]@{
|
|
344
|
+
protocol = 1
|
|
345
|
+
changed = @($script:accessChanged)
|
|
346
|
+
before = $report.expected
|
|
347
|
+
after = $after.expected
|
|
348
|
+
missing = $after.missing
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
Invoke-LegacyAccess | ConvertTo-Json -Depth 6 -Compress
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
# Callers read one JSON document; changed lists any write that completed before the failure.
|
|
357
|
+
[ordered]@{ protocol = 1; error = $_.Exception.Message; changed = @($script:accessChanged) } | ConvertTo-Json -Compress
|
|
358
|
+
exit 1
|
|
359
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Inspects or updates one identity-bound per-user Pacemaker registration.
|
|
4
|
+
.DESCRIPTION
|
|
5
|
+
Reads a bounded JSON request from standard input. Mutations require the exact
|
|
6
|
+
task XML and full owner/group/DACL/SACL/integrity descriptor. Does not enable
|
|
7
|
+
privileges, repair permissions or stop processes.
|
|
8
|
+
.PARAMETER Operation
|
|
9
|
+
The scoped task operation to perform.
|
|
10
|
+
.PARAMETER Port
|
|
11
|
+
The selected Pacemaker port.
|
|
12
|
+
.PARAMETER FunctionsOnly
|
|
13
|
+
Loads shared task inspection/security functions without connecting to Task Scheduler.
|
|
14
|
+
.OUTPUTS
|
|
15
|
+
JSON task identity including its XML for protected backup and scoped restoration.
|
|
16
|
+
.EXAMPLE
|
|
17
|
+
.\legacy-task.ps1 -Operation inspect -Port 8791
|
|
18
|
+
#>
|
|
19
|
+
[CmdletBinding()]
|
|
20
|
+
param(
|
|
21
|
+
[Parameter(Mandatory)]
|
|
22
|
+
[ValidateSet('inspect', 'hold', 'repoint', 'enable', 'restore', 'start', 'compare-restored')]
|
|
23
|
+
[string]$Operation,
|
|
24
|
+
[Parameter(Mandatory)]
|
|
25
|
+
[ValidateRange(1, 65535)]
|
|
26
|
+
[int]$Port,
|
|
27
|
+
[switch]$FunctionsOnly
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
$ErrorActionPreference = 'Stop'
|
|
31
|
+
$currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
32
|
+
$ownerPolicy = (Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot '..\..\bin\legacy-1.3.json') | ConvertFrom-Json).taskOwnerPolicy
|
|
33
|
+
if ($ownerPolicy.protocol -ne 1 -or
|
|
34
|
+
$ownerPolicy.allowRuntimeUser -ne $true -or
|
|
35
|
+
$null -eq $ownerPolicy.allowedOwnerSids) { throw 'Unsupported task owner policy.' }
|
|
36
|
+
|
|
37
|
+
function Get-UserSid {
|
|
38
|
+
param([string]$User)
|
|
39
|
+
if ($User.StartsWith('S-1-', [StringComparison]::Ordinal)) { return $User }
|
|
40
|
+
$account = [Security.Principal.NTAccount]::new($User)
|
|
41
|
+
return $account.Translate([Security.Principal.SecurityIdentifier]).Value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Get-XmlHash {
|
|
45
|
+
param([string]$Xml)
|
|
46
|
+
$algorithm = [Security.Cryptography.SHA256]::Create()
|
|
47
|
+
try {
|
|
48
|
+
return [Convert]::ToHexString($algorithm.ComputeHash([Text.Encoding]::UTF8.GetBytes($Xml))).ToLowerInvariant()
|
|
49
|
+
}
|
|
50
|
+
finally { $algorithm.Dispose() }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ConvertTo-TaskSecurity {
|
|
54
|
+
param([string]$Sddl)
|
|
55
|
+
try {
|
|
56
|
+
if ([string]::IsNullOrEmpty($Sddl) -or
|
|
57
|
+
$Sddl.Length -gt 65536) { throw 'Unsupported descriptor length.' }
|
|
58
|
+
$descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($Sddl)
|
|
59
|
+
if ($null -eq $descriptor.Owner -or
|
|
60
|
+
$null -eq $descriptor.Group) { throw 'Missing resource owner/group.' }
|
|
61
|
+
return @{
|
|
62
|
+
protocol = 1
|
|
63
|
+
information = 31
|
|
64
|
+
complete = $true
|
|
65
|
+
ownerSid = $descriptor.Owner.Value
|
|
66
|
+
groupSid = $descriptor.Group.Value
|
|
67
|
+
controlFlags = [int]$descriptor.ControlFlags
|
|
68
|
+
# AccessControlSections.All omits the LABEL flag when reserializing SDDL.
|
|
69
|
+
sddl = $Sddl
|
|
70
|
+
sha256 = Get-XmlHash -Xml $Sddl
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch { throw 'TASK_SECURITY_UNVERIFIED: Full task security descriptor could not be represented. No security fallback is allowed.' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Get-TaskSecurity {
|
|
77
|
+
param($Task)
|
|
78
|
+
try {
|
|
79
|
+
# OWNER | GROUP | DACL | SACL | LABEL; omitted audit data is not an empty SACL.
|
|
80
|
+
$sddl = $Task.GetSecurityDescriptor(31)
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw 'TASK_SECURITY_UNVERIFIED: Owner/group/DACL/SACL/integrity inspection was denied or unsupported. Further operations are refused; this adapter does not enable security privilege or elevate.'
|
|
84
|
+
}
|
|
85
|
+
return ConvertTo-TaskSecurity -Sddl $sddl
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function Assert-TaskSecurity {
|
|
89
|
+
param($Actual, $Expected, [string]$RuntimeSid = $currentSid)
|
|
90
|
+
$recorded = ConvertTo-TaskSecurity -Sddl $Expected.sddl
|
|
91
|
+
if ($Expected.protocol -ne 1 -or
|
|
92
|
+
$Expected.information -ne 31 -or
|
|
93
|
+
$Expected.complete -ne $true -or
|
|
94
|
+
$Expected.sha256 -cne $recorded.sha256 -or
|
|
95
|
+
$Expected.ownerSid -cne $recorded.ownerSid -or
|
|
96
|
+
$Expected.groupSid -cne $recorded.groupSid -or
|
|
97
|
+
$Expected.controlFlags -ne $recorded.controlFlags -or
|
|
98
|
+
$Actual.sha256 -cne $recorded.sha256 -or
|
|
99
|
+
-not ($Actual.ownerSid -ceq $RuntimeSid -or
|
|
100
|
+
$Actual.ownerSid -cin $ownerPolicy.allowedOwnerSids)) {
|
|
101
|
+
throw 'TASK_SECURITY_CHANGED: Resource owner, permissions, audit or integrity no longer matches the approved task. Further operations are refused.'
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function Get-TaskRecord {
|
|
106
|
+
param($Task)
|
|
107
|
+
$definition = $Task.Definition
|
|
108
|
+
$actions = @($definition.Actions | ForEach-Object {
|
|
109
|
+
@{
|
|
110
|
+
type = $_.Type
|
|
111
|
+
path = $_.Path
|
|
112
|
+
arguments = $_.Arguments
|
|
113
|
+
workingDirectory = [string]$_.WorkingDirectory
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
$triggers = @($definition.Triggers | ForEach-Object {
|
|
117
|
+
@{
|
|
118
|
+
type = $_.Type
|
|
119
|
+
userSid = Get-UserSid -User $_.UserId
|
|
120
|
+
enabled = $_.Enabled
|
|
121
|
+
stateChange = if ($_.Type -eq 11) { $_.StateChange } else { 0 }
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
$xml = $Task.Xml
|
|
125
|
+
if ($xml.Length -gt 524288) { throw 'Selected task XML exceeds the supported bound.' }
|
|
126
|
+
return @{
|
|
127
|
+
name = $Task.Name
|
|
128
|
+
path = $Task.Path
|
|
129
|
+
enabled = $Task.Enabled
|
|
130
|
+
xml = $xml
|
|
131
|
+
xmlSha256 = Get-XmlHash -Xml $xml
|
|
132
|
+
security = Get-TaskSecurity -Task $Task
|
|
133
|
+
currentUserSid = $currentSid
|
|
134
|
+
userSid = Get-UserSid -User $definition.Principal.UserId
|
|
135
|
+
logonType = $definition.Principal.LogonType
|
|
136
|
+
runLevel = $definition.Principal.RunLevel
|
|
137
|
+
actions = $actions
|
|
138
|
+
triggers = $triggers
|
|
139
|
+
settings = @{
|
|
140
|
+
multipleInstances = $definition.Settings.MultipleInstances
|
|
141
|
+
restartCount = $definition.Settings.RestartCount
|
|
142
|
+
restartInterval = $definition.Settings.RestartInterval
|
|
143
|
+
executionTimeLimit = $definition.Settings.ExecutionTimeLimit
|
|
144
|
+
startWhenAvailable = $definition.Settings.StartWhenAvailable
|
|
145
|
+
disallowStartIfOnBatteries = $definition.Settings.DisallowStartIfOnBatteries
|
|
146
|
+
stopIfGoingOnBatteries = $definition.Settings.StopIfGoingOnBatteries
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function Get-RestorationXml {
|
|
152
|
+
param([string]$Xml, $TaskService)
|
|
153
|
+
if ([string]::IsNullOrWhiteSpace($Xml) -or
|
|
154
|
+
$Xml.Length -gt 524288) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Invalid XML bounds.' }
|
|
155
|
+
$settings = [Xml.XmlReaderSettings]::new()
|
|
156
|
+
$settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit
|
|
157
|
+
$settings.XmlResolver = $null
|
|
158
|
+
$settings.MaxCharactersInDocument = 524288
|
|
159
|
+
$text = [IO.StringReader]::new($Xml)
|
|
160
|
+
$reader = [Xml.XmlReader]::Create($text, $settings)
|
|
161
|
+
$document = [Xml.XmlDocument]::new()
|
|
162
|
+
$document.XmlResolver = $null
|
|
163
|
+
try { $document.Load($reader) }
|
|
164
|
+
finally { $reader.Dispose(); $text.Dispose() }
|
|
165
|
+
$namespace = 'http://schemas.microsoft.com/windows/2004/02/mit/task'
|
|
166
|
+
$root = $document.DocumentElement
|
|
167
|
+
if ($root.LocalName -cne 'Task' -or
|
|
168
|
+
$root.NamespaceURI -cne $namespace) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Unexpected task root.' }
|
|
169
|
+
$registrations = @($document.SelectNodes("//*[local-name()='RegistrationInfo']"))
|
|
170
|
+
$descriptors = @($document.SelectNodes("//*[local-name()='SecurityDescriptor']"))
|
|
171
|
+
if ($registrations.Count -gt 1 -or
|
|
172
|
+
$descriptors.Count -gt 1) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Duplicate registration metadata.' }
|
|
173
|
+
foreach ($registration in $registrations) {
|
|
174
|
+
if ($registration.NamespaceURI -cne $namespace -or
|
|
175
|
+
$registration.ParentNode -ne $root) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Unexpected registration metadata.' }
|
|
176
|
+
}
|
|
177
|
+
foreach ($descriptor in $descriptors) {
|
|
178
|
+
if ($descriptor.NamespaceURI -cne $namespace -or
|
|
179
|
+
$descriptor.ParentNode.LocalName -cne 'RegistrationInfo' -or
|
|
180
|
+
$descriptor.ParentNode.NamespaceURI -cne $namespace -or
|
|
181
|
+
$descriptor.ParentNode.ParentNode -ne $root) { throw 'TASK_RESTORATION_XML_UNVERIFIED: Unexpected embedded descriptor.' }
|
|
182
|
+
}
|
|
183
|
+
$definition = $TaskService.NewTask(0)
|
|
184
|
+
$definition.XmlText = $Xml
|
|
185
|
+
$definition.RegistrationInfo.SecurityDescriptor = ''
|
|
186
|
+
return $definition.XmlText
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function Get-MatchingTasks {
|
|
190
|
+
param($TaskFolder, [int]$Depth)
|
|
191
|
+
if ($Depth -gt 8) { throw 'Task folder depth exceeds the inspection bound.' }
|
|
192
|
+
$script:folderCount++
|
|
193
|
+
if ($script:folderCount -gt 512) { throw 'Task folder count exceeds the inspection bound.' }
|
|
194
|
+
foreach ($candidate in $TaskFolder.GetTasks(1)) {
|
|
195
|
+
$matching = $candidate.Name -in @("McpPacemaker-$Port", 'McpPacemaker')
|
|
196
|
+
foreach ($action in $candidate.Definition.Actions) {
|
|
197
|
+
if ($action.Type -eq 0 -and
|
|
198
|
+
$null -ne $action.Arguments -and
|
|
199
|
+
$action.Arguments.EndsWith("\autostart\windows\launcher.vbs`" $Port", [StringComparison]::OrdinalIgnoreCase)) {
|
|
200
|
+
$matching = $true
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if ($matching) { $candidate }
|
|
204
|
+
}
|
|
205
|
+
foreach ($childFolder in $TaskFolder.GetFolders(0)) {
|
|
206
|
+
Get-MatchingTasks -TaskFolder $childFolder -Depth ($Depth + 1)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if ($FunctionsOnly) { return }
|
|
210
|
+
$service = New-Object -ComObject Schedule.Service
|
|
211
|
+
$service.Connect()
|
|
212
|
+
if ($Operation -eq 'compare-restored') {
|
|
213
|
+
$text = [Console]::In.ReadToEnd()
|
|
214
|
+
if ($text.Length -gt 1048576) { throw 'Task operation input exceeds the supported bound.' }
|
|
215
|
+
$request = $text | ConvertFrom-Json
|
|
216
|
+
Assert-TaskSecurity -Actual $request.actual.security -Expected $request.original.security -RuntimeSid $request.original.userSid
|
|
217
|
+
$actualXml = Get-RestorationXml -Xml $request.actual.xml -TaskService $service
|
|
218
|
+
$originalXml = Get-RestorationXml -Xml $request.original.xml -TaskService $service
|
|
219
|
+
if ((Get-XmlHash -Xml $actualXml) -cne (Get-XmlHash -Xml $originalXml)) {
|
|
220
|
+
throw 'Restored legacy registration XML differs beyond its embedded descriptor representation.'
|
|
221
|
+
}
|
|
222
|
+
@{ equivalent = $true } | ConvertTo-Json -Compress
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
$folder = $service.GetFolder('\')
|
|
226
|
+
$script:folderCount = 0
|
|
227
|
+
$tasks = @(Get-MatchingTasks -TaskFolder $folder -Depth 0)
|
|
228
|
+
if ($tasks.Count -gt 16) { throw 'Too many matching Pacemaker registrations.' }
|
|
229
|
+
if ($Operation -eq 'inspect') {
|
|
230
|
+
ConvertTo-Json -InputObject @($tasks | ForEach-Object { Get-TaskRecord -Task $_ }) -Depth 8 -Compress
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
$text = [Console]::In.ReadToEnd()
|
|
235
|
+
if ($text.Length -gt 1048576) { throw 'Task operation input exceeds the supported bound.' }
|
|
236
|
+
$request = $text | ConvertFrom-Json
|
|
237
|
+
if ($tasks.Count -ne 1) { throw 'Exactly one selected registration is required before mutation.' }
|
|
238
|
+
$task = $tasks[0]
|
|
239
|
+
$record = Get-TaskRecord -Task $task
|
|
240
|
+
Assert-TaskSecurity -Actual $record.security -Expected $request.expected.security
|
|
241
|
+
if ($record.name -ne $request.expected.name -or
|
|
242
|
+
$record.path -ne "\$($record.name)" -or
|
|
243
|
+
$record.xmlSha256 -cne $request.expected.xmlSha256 -or
|
|
244
|
+
$record.userSid -cne $currentSid -or
|
|
245
|
+
$record.logonType -ne 3 -or
|
|
246
|
+
$record.runLevel -ne 0) {
|
|
247
|
+
throw 'Selected task identity changed; no operation was performed.'
|
|
248
|
+
}
|
|
249
|
+
switch ($Operation) {
|
|
250
|
+
'hold' { $task.Enabled = $false }
|
|
251
|
+
'enable' { $task.Enabled = $true }
|
|
252
|
+
'repoint' {
|
|
253
|
+
if ($task.Enabled) { throw 'Registration must be held before replacing its launcher.' }
|
|
254
|
+
$launcher = [IO.Path]::GetFullPath($request.launcher)
|
|
255
|
+
if ($launcher.IndexOfAny([char[]]"`"`r`n") -ge 0 -or
|
|
256
|
+
-not [IO.File]::Exists($launcher)) { throw 'Invalid stable launcher path.' }
|
|
257
|
+
$definition = $task.Definition
|
|
258
|
+
if ($definition.Actions.Count -ne 1) { throw 'Unexpected task actions.' }
|
|
259
|
+
$definition.Actions.Item(1).Arguments = "`"$launcher`""
|
|
260
|
+
# Update only, do not alter principal ACEs, and suppress registration-trigger execution.
|
|
261
|
+
$definition.RegistrationInfo.SecurityDescriptor = ''
|
|
262
|
+
$folder.RegisterTaskDefinition($task.Name, $definition, 52, $definition.Principal.UserId, $null, 3, $null) | Out-Null
|
|
263
|
+
}
|
|
264
|
+
'restore' {
|
|
265
|
+
if ($task.Enabled) { throw 'Registration must be held before restoration.' }
|
|
266
|
+
Assert-TaskSecurity -Actual $record.security -Expected $request.original.security
|
|
267
|
+
$definition = $service.NewTask(0)
|
|
268
|
+
$definition.XmlText = $request.original.xml
|
|
269
|
+
if ((Get-XmlHash -Xml $request.original.xml) -cne $request.original.xmlSha256 -or
|
|
270
|
+
(Get-UserSid -User $definition.Principal.UserId) -cne $currentSid -or
|
|
271
|
+
$definition.Principal.LogonType -ne 3 -or
|
|
272
|
+
$definition.Principal.RunLevel -ne 0 -or
|
|
273
|
+
$request.original.name -ne $task.Name) { throw 'Invalid original task identity.' }
|
|
274
|
+
$definition.RegistrationInfo.SecurityDescriptor = ''
|
|
275
|
+
$folder.RegisterTaskDefinition($task.Name, $definition, 52, $definition.Principal.UserId, $null, 3, $null) | Out-Null
|
|
276
|
+
}
|
|
277
|
+
'start' { $task.Run($null) | Out-Null }
|
|
278
|
+
}
|
|
279
|
+
$updated = Get-TaskRecord -Task ($folder.GetTask($task.Name))
|
|
280
|
+
Assert-TaskSecurity -Actual $updated.security -Expected $record.security
|
|
281
|
+
$expectedDefinition = $service.NewTask(0)
|
|
282
|
+
$expectedDefinition.XmlText = $record.xml
|
|
283
|
+
switch ($Operation) {
|
|
284
|
+
'hold' { $expectedDefinition.Settings.Enabled = $false }
|
|
285
|
+
'enable' { $expectedDefinition.Settings.Enabled = $true }
|
|
286
|
+
'repoint' { $expectedDefinition.Actions.Item(1).Arguments = "`"$launcher`"" }
|
|
287
|
+
'restore' { $expectedDefinition.XmlText = $request.original.xml }
|
|
288
|
+
}
|
|
289
|
+
$updatedDefinition = $service.NewTask(0)
|
|
290
|
+
$updatedDefinition.XmlText = $updated.xml
|
|
291
|
+
if ($Operation -in @('repoint', 'restore')) {
|
|
292
|
+
$expectedDefinition.RegistrationInfo.SecurityDescriptor = ''
|
|
293
|
+
if (-not [string]::IsNullOrEmpty($updatedDefinition.RegistrationInfo.SecurityDescriptor)) {
|
|
294
|
+
throw 'Registration changed beyond the requested scoped edit; do not stop or activate a backend.'
|
|
295
|
+
}
|
|
296
|
+
$updatedDefinition.RegistrationInfo.SecurityDescriptor = ''
|
|
297
|
+
}
|
|
298
|
+
if ((Get-XmlHash -Xml $expectedDefinition.XmlText) -cne (Get-XmlHash -Xml $updatedDefinition.XmlText)) {
|
|
299
|
+
throw 'Registration changed beyond the requested scoped edit; do not stop or activate a backend.'
|
|
300
|
+
}
|
|
301
|
+
$updated | ConvertTo-Json -Depth 8 -Compress
|