fix(scripting): ScriptWorker isolation denies the worker every sensitive sibling, not just app\ (#890)
v2-ci / unit-tests (push) Waiting to run

The installer denied NT SERVICE\OtOpcUaScriptWorker only on -InstallRoot. On the wonder layout the node's keys and
secrets sit beside it (E:\ApiInstall\OtOpcUa\pki with the application certificate's private key, data\ with
secrets.db and LocalDb), inheriting BUILTIN\Users:RX. Users is a restricting SID of the worker token, so a worker
could read them.

- Install-ScriptWorkerIsolation.ps1 -DenyPaths <dir>,...: each folder gets the same inherited deny as InstallRoot
  (new SensitiveDirDeny steps). Refuses drive/share roots, script-worker\ and the PE cache; folders must exist;
  a comma/semicolon string works for powershell -File.
- The deny is read AND write (0xD0157: ReadData, WriteData, AppendData, WriteEA, DeleteChild, WriteAttributes,
  Delete, WRITE_DAC, WRITE_OWNER). Traverse/Execute, ReadAttributes, ReadControl and Synchronize stay so the
  loader still reaches script-worker\. The write half stops a worker planting a cert in pki\trusted\certs where
  a data drive lets Users create files in subfolders.
- The list is recorded under the anchor service's key (Parameters\DenyPaths, step DenyPathRecord): a re-run
  removes the deny from folders dropped from the list, -Uninstall removes every recorded one without repeating
  -DenyPaths. -WhatIf/-PlanOnly print every step.
- After an apply, WARN for each protected-DACL item under a denied folder that the inherited deny cannot reach;
  without -DenyPaths, WARN for pki/data/logs/backup/config/secrets siblings of InstallRoot.

Tests:
- Dry-run harness 46 -> 86 assertions (QA layout plan, mask bits, refusals, stale removal, record-driven
  rollback, host account never denied). Mutations (no sibling deny, deny->allow, mask back to ReadData, no
  stale removal, rollback ignoring the record, script-worker not refused) each turn it red.
- Test-ScriptWorkerIsolation.Windows.ps1 (Windows PowerShell 5.1, windev): 34/34, incl. -DenyPaths apply,
  11/11 unchanged re-apply, shorter-list re-apply, uninstall from the record, second uninstall unchanged.
- New InstallerLayoutIsolationTests (OTOPCUA_ISOLATION_INSTALLER_E2E=1, elevated Windows): a scratch wonder
  layout under the real restricted worker token. Before install and after uninstall the token reads pki/data/
  logs/backup/appsettings and plants a trust-list file; installed it can do neither, still reads script-worker\,
  a real worker launched from the layout runs a script, and the host still reads everything. Mutating the
  -DenyPaths denies to a no-op mask turns it red on pki\own\private\otopcua.pfx (reproduces #890).

QA vq03/vq04 are NOT re-applied; the command is in docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 17:31:28 -05:00
parent c80d187c18
commit f75ee7d089
8 changed files with 597 additions and 34 deletions
+6 -2
View File
@@ -105,7 +105,7 @@ dotnet muxer. See `docs/VirtualTags.md` §"Out-of-process runner".
### Worker isolation on Windows (`Install-ScriptWorkerIsolation.ps1`, design §14 #5/#6) ### Worker isolation on Windows (`Install-ScriptWorkerIsolation.ps1`, design §14 #5/#6)
`scripts/install/Install-ScriptWorkerIsolation.ps1 -InstallRoot <dir> -HostServiceAccount <account> [-ConfigureHostService]` `scripts/install/Install-ScriptWorkerIsolation.ps1 -InstallRoot <dir> -HostServiceAccount <account> [-DenyPaths <dir>,…] [-ConfigureHostService]`
(idempotent; `-WhatIf` prints every change; `-Uninstall` rolls back; `-PlanOnly` prints the plan on any OS) sets up: (idempotent; `-WhatIf` prints every change; `-Uninstall` rolls back; `-PlanOnly` prints the plan on any OS) sets up:
| Step | Change | | Step | Change |
@@ -114,10 +114,14 @@ dotnet muxer. See `docs/VirtualTags.md` §"Out-of-process runner".
| `Scripting:Worker:Identity` | With `NT SERVICE\OtOpcUaScriptWorker` set (by `-ConfigureHostService`, or in appsettings), the host starts every worker under a **restricted token** derived from its own: every privilege but `SeChangeNotifyPrivilege` removed, and restricting SIDs = the identity + `Everyone` + `BUILTIN\Users` + `RESTRICTED`. Every access the worker makes must pass for the host account **and** for that set. The token's default DACL is host + SYSTEM + identity; the worker is created suspended and put in its Job Object before it runs; `TEMP`/`TMP` point at its per-launch working directory (ACL: host + SYSTEM full, identity Modify); `DOTNET_EnableDiagnostics=0`. The host also adds the identity to its own window station and desktop DACL (enumerate/read, and create-window on the desktop), once per process. USER32 connects to them in its DllMain and the runtime imports USER32, so without the grant `coreclr.dll` fails to load (`ERROR_DLL_INIT_FAILED`, measured on windev). The grant lives on the session's kernel objects and ends with the session; nothing persistent changes. The logon SID, which those objects normally grant, is deliberately not a restricting SID: the host's process DACL gives it `PROCESS_VM_READ`. Windows cannot start an ad-hoc child process *as* a virtual account (only the SCM can, for a service), and a separate local user would need a stored password plus `SeAssignPrimaryTokenPrivilege` on the host account; a restricted copy of the host's own token needs neither. Startup refuses the key off Windows or when malformed. | | `Scripting:Worker:Identity` | With `NT SERVICE\OtOpcUaScriptWorker` set (by `-ConfigureHostService`, or in appsettings), the host starts every worker under a **restricted token** derived from its own: every privilege but `SeChangeNotifyPrivilege` removed, and restricting SIDs = the identity + `Everyone` + `BUILTIN\Users` + `RESTRICTED`. Every access the worker makes must pass for the host account **and** for that set. The token's default DACL is host + SYSTEM + identity; the worker is created suspended and put in its Job Object before it runs; `TEMP`/`TMP` point at its per-launch working directory (ACL: host + SYSTEM full, identity Modify); `DOTNET_EnableDiagnostics=0`. The host also adds the identity to its own window station and desktop DACL (enumerate/read, and create-window on the desktop), once per process. USER32 connects to them in its DllMain and the runtime imports USER32, so without the grant `coreclr.dll` fails to load (`ERROR_DLL_INIT_FAILED`, measured on windev). The grant lives on the session's kernel objects and ends with the session; nothing persistent changes. The logon SID, which those objects normally grant, is deliberately not a restricting SID: the host's process DACL gives it `PROCESS_VM_READ`. Windows cannot start an ad-hoc child process *as* a virtual account (only the SCM can, for a service), and a separate local user would need a stored password plus `SeAssignPrimaryTokenPrivilege` on the host account; a restricted copy of the host's own token needs neither. Startup refuses the key off Windows or when malformed. |
| Pipe ACL | On Windows the worker pipe always carries an explicit, protected DACL: the host account and (with an identity) the worker identity, read/write, nobody else. No SYSTEM, no Administrators, no Everyone. The owner is the host token's owner, so the worker's `CurrentUserOnly` check of the server end still holds. | | Pipe ACL | On Windows the worker pipe always carries an explicit, protected DACL: the host account and (with an identity) the worker identity, read/write, nobody else. No SYSTEM, no Administrators, no Everyone. The owner is the host token's owner, so the worker's `CurrentUserOnly` check of the server end still holds. |
| `PeCacheDir` | Created with a protected ACL: SYSTEM, Administrators and the host account full control, no Users. `pe\` grants the identity Modify (workers compile and write entries). The HMAC key and `workers\` markers are host-only. | | `PeCacheDir` | Created with a protected ACL: SYSTEM, Administrators and the host account full control, no Users. `pe\` grants the identity Modify (workers compile and write entries). The HMAC key and `workers\` markers are host-only. |
| Install folder | The identity is **denied** ReadData on `<InstallRoot>` (inherited), so the worker cannot read `appsettings*.json`, logs or host binaries even where `Users` can. `script-worker\` grants it read + execute. | | Install folder | The identity is **denied** read and write on `<InstallRoot>` (inherited: ReadData/List, WriteData/AddFile, AppendData/AddSubdirectory, write attributes/EA, Delete, DeleteChild, ChangePermissions, TakeOwnership; Traverse/Execute, ReadAttributes and ReadPermissions stay so the loader reaches `script-worker\`). The worker cannot read `appsettings*.json`, logs or host binaries even where `Users` can. `script-worker\` grants it read + execute. |
| Sensitive folders (`-DenyPaths`, lmxopcua#890) | The same inherited deny on every listed folder. **Name every folder outside `<InstallRoot>` that holds keys, secrets, stores, logs or backups**: the worker's restricting SIDs include `Everyone` and `BUILTIN\Users`, so anything Users can read is otherwise readable to it (and a data drive usually lets Users create files in subfolders, e.g. a certificate in `pki\trusted\certs`). Name the deployment folder around `<InstallRoot>` (one ACE covers `pki\`, `data\`, `logs\`, `backup\`, old `app.bak.*`) **and** `pki\` and `data\` themselves, so the deny holds even on a folder whose DACL does not inherit. Drive and share roots, `script-worker\` and the PE cache are refused; each folder must exist. The list is recorded under `HKLM\…\Services\OtOpcUaScriptWorker\Parameters` (`DenyPaths`): a re-run removes the deny from folders dropped from the list, and `-Uninstall` removes every recorded one without repeating `-DenyPaths`. After an apply the installer WARNs about any item under a denied folder whose DACL is protected and has no deny of its own (the inherited deny does not reach it); without `-DenyPaths` it WARNs about `pki`/`data`/`logs`/`backup`/`config`/`secrets` siblings of `<InstallRoot>`. Wonder layout: `-InstallRoot E:\ApiInstall\OtOpcUa\app -DenyPaths E:\ApiInstall\OtOpcUa,E:\ApiInstall\OtOpcUa\pki,E:\ApiInstall\OtOpcUa\data`. |
| Firewall | Outbound **block** rule `OtOpcUa-ScriptWorker-BlockOutbound` on `script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe`, every profile. It covers the self-contained apphost the Windows installs run. A framework-dependent worker runs under `dotnet.exe` and is not covered, so there the analyzer's `System.Net` ban is the only network control. | | Firewall | Outbound **block** rule `OtOpcUa-ScriptWorker-BlockOutbound` on `script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe`, every profile. It covers the self-contained apphost the Windows installs run. A framework-dependent worker runs under `dotnet.exe` and is not covered, so there the analyzer's `System.Net` ban is the only network control. |
The plan is pure data asserted by `scripts/install/tests/Test-ScriptWorkerIsolation.ps1` (`pwsh -File …`, any OS). The plan is pure data asserted by `scripts/install/tests/Test-ScriptWorkerIsolation.ps1` (`pwsh -File …`, any OS).
`Test-ScriptWorkerIsolation.Windows.ps1` applies it for real in a scratch folder (elevated), and
`InstallerLayoutIsolationTests` (ScriptWorker.Tests, `OTOPCUA_ISOLATION_INSTALLER_E2E=1`, elevated) proves under the real
restricted token that a worker cannot read `pki\`/`data\`/`logs\`/`backup\` of a scratch wonder layout and still runs.
Re-run the installer after a deploy that replaces the install folder: a rename/swap drops the folder ACEs, but files Re-run the installer after a deploy that replaces the install folder: a rename/swap drops the folder ACEs, but files
copied into the existing folder inherit them. Rollback: `-Uninstall -ConfigureHostService`, then restart the host. copied into the existing folder inherit them. Rollback: `-Uninstall -ConfigureHostService`, then restart the host.
+32 -1
View File
@@ -152,6 +152,36 @@ siblings `E:\ApiInstall\OtOpcUa\pki` (including `own\private\*.pfx`) and `data\`
`BUILTIN\Users:ReadAndExecute`. Users is one of the worker token's restricting SIDs, so those folders are not covered `BUILTIN\Users:ReadAndExecute`. Users is one of the worker token's restricting SIDs, so those folders are not covered
by the deny. File-level ACLs were not inspected. Follow-up: #890. by the deny. File-level ACLs were not inspected. Follow-up: #890.
**#890 fix, PENDING re-apply on vq03 and vq04.** Not run on QA yet. This is a deploy step for a later, user-requested window.
The installer now takes `-DenyPaths` and records them. Once the scripts at that commit are staged on each node, run the
following in an elevated Windows PowerShell 5.1 session. The services can stay up: only folder ACLs change for the worker
identity, and no worker runs on QA today.
```powershell
cd <staged scripts\install>
$deny = 'E:\ApiInstall\OtOpcUa','E:\ApiInstall\OtOpcUa\pki','E:\ApiInstall\OtOpcUa\data'
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -HostServiceAccount LocalSystem -ConfigureHostService -DenyPaths $deny -WhatIf
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -HostServiceAccount LocalSystem -ConfigureHostService -DenyPaths $deny
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -HostServiceAccount LocalSystem -ConfigureHostService -DenyPaths $deny
```
Before running it, check `Secrets__SqlitePath`, its key file and `LocalDb__Path` in the service environment. Add any folder
that sits outside `E:\ApiInstall\OtOpcUa` to `$deny`.
Expected results:
- **First apply: 11 steps, 5 `changed`.** They are `DenyPathRecord`, `InstallRootDeny` (mask `0x1` → `0xD0157`, read + write)
and the three `SensitiveDirDeny`. The other 6 steps are `unchanged`.
- **Second apply: 11/11 `unchanged`.**
- **Check:**
- `icacls E:\ApiInstall\OtOpcUa`, `…\pki` and `…\data` and `…\app` each show
`NT SERVICE\OtOpcUaScriptWorker:(OI)(CI)(DENY)(W,D,WDAC,WO,RD,DC)`, which is what windev's PowerShell 5.1 renders for
`0xD0157`.
- `reg query HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters /v DenyPaths` lists the three folders.
- The `coverage:` line reports the number of protected items the deny does not reach. Inspect each one WARNed (for
example a `pki\own\private` file the SDK wrote with its own DACL).
Rollback stays `… -ConfigureHostService -Uninstall`. It now also removes the deny from the recorded folders.
## Configuration set (service registry environment; no file changes, no secrets added) ## Configuration set (service registry environment; no file changes, no secrets added)
| Node | Added | | Node | Added |
@@ -165,7 +195,8 @@ by the deny. File-level ACLs were not inspected. Follow-up: #890.
- **Runner only:** set `Scripting__Runner=InProcess` (or remove the four `Scripting__Runner*` entries) and restart the - **Runner only:** set `Scripting__Runner=InProcess` (or remove the four `Scripting__Runner*` entries) and restart the
node. To remove the isolation, run `Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app node. To remove the isolation, run `Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app
-HostServiceAccount LocalSystem -ConfigureHostService -Uninstall`. -HostServiceAccount LocalSystem -ConfigureHostService -Uninstall`. After the #890 re-apply, this also removes the recorded
`-DenyPaths` denies, so there is no need to repeat them.
- **QA binaries:** run `E:\psrem-stage\otopcua-fea7de0a\rollback.ps1` on **both** nodes (stop both first), then start both. - **QA binaries:** run `E:\psrem-stage\otopcua-fea7de0a\rollback.ps1` on **both** nodes (stop both first), then start both.
The script does three things: The script does three things:
1. `app` → `app.failed-fea7de0a`, and `app.bak.20261112-pre-fea7de0a` → `app`; 1. `app` → `app.failed-fea7de0a`, and `app.bak.20261112-pre-fea7de0a` → `app`;
@@ -1301,6 +1301,23 @@ Owner decisions §14 #5 and #6, as built. Operator reference: `docs/ServiceHosti
No account, firewall rule or persistent ACL was created there. No account, firewall rule or persistent ACL was created there.
- **Not verified live:** the installer on a real node. That is the QA deploy gate on lmxopcua#780's follow-up (see - **Not verified live:** the installer on a real node. That is the QA deploy gate on lmxopcua#780's follow-up (see
that issue for the checklist). that issue for the checklist).
- **lmxopcua#890 (2026-11-12): sensitive siblings.** The QA deploy found the deny covered `InstallRoot` (`app\`) only, while the
wonder layout keeps `pki\` (application certificate private key) and `data\` (`secrets.db`, LocalDb) **beside** it, inheriting
`BUILTIN\Users:RX` from `E:\`. Users is a restricting SID, so a worker could read them. The installer now:
- takes `-DenyPaths` (the deployment folder around `InstallRoot` plus each key/secret folder) and gives each the same
inherited deny as `InstallRoot`;
- widens the deny from ReadData to read **and write** (`0xD0157`: ReadData, WriteData, AppendData, WriteEA, DeleteChild,
WriteAttributes, Delete, WRITE_DAC, WRITE_OWNER). Traverse/Execute, ReadAttributes, ReadControl and Synchronize stay
allowed, so the loader still reaches `script-worker\`. The write half closes a planted trust-list certificate, since
a data drive lets Users create files in subfolders;
- records the list under the anchor service's key, so a re-run drops stale denies and `-Uninstall` removes exactly the
recorded ones;
- WARNs about protected-DACL items under a denied folder that the inherited deny cannot reach.
Proof: `InstallerLayoutIsolationTests` on windev, a scratch wonder layout under a real restricted token. Before the install
and after the uninstall, the token reads every file and plants a trust-list file. Installed, it cannot do either, still reads
`script-worker\`, and a real worker launched from the layout runs a script. With the `-DenyPaths` denies mutated to a no-op
mask, the test goes red on `pki\own\private\*.pfx`, which reproduces #890.
### 12.6 lmxopcua#789 host CPU on Windows: blocking pipe I/O off the completion port (2026-11-11) ### 12.6 lmxopcua#789 host CPU on Windows: blocking pipe I/O off the completion port (2026-11-11)
@@ -2,7 +2,7 @@
.SYNOPSIS .SYNOPSIS
Isolates the OtOpcUa ScriptWorker processes on a Windows node (lmxopcua#780, design §6, owner decisions §14 #5/#6): Isolates the OtOpcUa ScriptWorker processes on a Windows node (lmxopcua#780, design §6, owner decisions §14 #5/#6):
the worker's virtual-account identity, the PeCacheDir ACL, an outbound firewall block on the worker executable and the worker's virtual-account identity, the PeCacheDir ACL, an outbound firewall block on the worker executable and
a deny on the install folder. Idempotent; -WhatIf prints every change; -Uninstall rolls it back. a deny on the install folder and on every other sensitive folder (-DenyPaths: pki\, data\, logs\, ...). Idempotent; -WhatIf prints every change; -Uninstall rolls it back.
.DESCRIPTION .DESCRIPTION
Only needed when the node runs Scripting:Runner = OutOfProcess or Shadow. It changes, in order: Only needed when the node runs Scripting:Runner = OutOfProcess or Shadow. It changes, in order:
@@ -17,8 +17,21 @@
account full control. No Users, no worker access (the HMAC key and launch markers live here). account full control. No Users, no worker access (the HMAC key and launch markers live here).
3. PeEntriesDir <PeCacheDir>\pe, the worker identity gets Modify (workers compile and write HMAC'd entries). 3. PeEntriesDir <PeCacheDir>\pe, the worker identity gets Modify (workers compile and write HMAC'd entries).
4. WorkerFolderAcl <InstallRoot>\script-worker, the worker identity gets read + execute. 4. WorkerFolderAcl <InstallRoot>\script-worker, the worker identity gets read + execute.
5. InstallRootDeny <InstallRoot>, the worker identity is DENIED ReadData/ListDirectory (inherited), so the 5. InstallRootDeny <InstallRoot>, the worker identity is DENIED read and write (inherited): ReadData/List,
worker cannot read appsettings*.json, the logs or the host binaries even where Users can. WriteData/AddFile, AppendData/AddSubdirectory, WriteAttributes/EA, Delete, DeleteChild,
ChangePermissions, TakeOwnership. Traverse/Execute, ReadAttributes and ReadPermissions stay,
so the loader still reaches script-worker\. The worker cannot read appsettings*.json, the logs
or the host binaries even where Users can.
SensitiveDirDeny (one per -DenyPaths folder, lmxopcua#890) the same inherited deny. The worker token's
restricting SIDs include Everyone and BUILTIN\Users, so ANY folder Users can read is readable
to the worker unless it is denied here. Name the deployment folder around InstallRoot (which
covers pki\, data\, logs\, backups and old app.bak.* swaps in one ACE) AND each folder that
holds keys or secrets explicitly (pki\, data\, the Secrets/LocalDb folder when it lives
elsewhere), so the deny also holds on a folder whose DACL does not inherit.
DenyPathRecord the -DenyPaths list, recorded under the anchor service's key
(HKLM\...\Services\<WorkerServiceName>\Parameters, value DenyPaths). A re-run removes the
deny from folders dropped from the list; -Uninstall removes it from every recorded folder
without the operator repeating -DenyPaths.
6. FirewallBlockOutbound a Windows Firewall rule blocking all outbound traffic from 6. FirewallBlockOutbound a Windows Firewall rule blocking all outbound traffic from
<InstallRoot>\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe (every profile). The <InstallRoot>\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe (every profile). The
self-contained Windows publish starts the worker through that apphost; a framework-dependent self-contained Windows publish starts the worker through that apphost; a framework-dependent
@@ -30,12 +43,22 @@
Re-run it after any deploy that REPLACES the install folder (a rename/swap drops the folder ACEs; files copied into Re-run it after any deploy that REPLACES the install folder (a rename/swap drops the folder ACEs; files copied into
an existing folder inherit them). The host must be restarted for -ConfigureHostService to take effect. an existing folder inherit them). The host must be restarted for -ConfigureHostService to take effect.
After an apply it walks every denied folder and WARNs about any file or folder whose DACL is protected (does not
inherit) and has no deny of its own; the inherited deny does not reach those. Without -DenyPaths it WARNs when the
folder around InstallRoot has a pki, data, logs, backup, config or secrets sibling.
.PARAMETER InstallRoot .PARAMETER InstallRoot
The host install folder (holds OtOpcUa.Host.exe and script-worker\). The host install folder (holds OtOpcUa.Host.exe and script-worker\).
.PARAMETER HostServiceAccount .PARAMETER HostServiceAccount
The OtOpcUaHost service account: DOMAIN\name, NT SERVICE\name, LocalSystem, or a SID. Gets full control of PeCacheDir. The OtOpcUaHost service account: DOMAIN\name, NT SERVICE\name, LocalSystem, or a SID. Gets full control of PeCacheDir.
.PARAMETER DenyPaths
More folders the worker identity is denied (read and write, inherited), lmxopcua#890. Absolute; must exist; not a
drive or share root; not script-worker\ or the PE cache. A comma- or semicolon-separated string also works (for
powershell -File). Example for the wonder layout E:\ApiInstall\OtOpcUa\{app,pki,data,logs,backup}:
-DenyPaths 'E:\ApiInstall\OtOpcUa','E:\ApiInstall\OtOpcUa\pki','E:\ApiInstall\OtOpcUa\data'
.PARAMETER PeCacheDir .PARAMETER PeCacheDir
The compiled-PE cache (Scripting:Worker:PeCacheDir). Absolute and outside InstallRoot (the host refuses otherwise). The compiled-PE cache (Scripting:Worker:PeCacheDir). Absolute and outside InstallRoot (the host refuses otherwise).
Default: $env:ProgramData\OtOpcUa\script-pe-cache. Default: $env:ProgramData\OtOpcUa\script-pe-cache.
@@ -63,6 +86,11 @@
.EXAMPLE .EXAMPLE
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService -Uninstall .\Install-ScriptWorkerIsolation.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -HostServiceAccount 'OTOPCUA\svc-otopcua' -ConfigureHostService -Uninstall
.EXAMPLE
# The wonder layout: binaries in app\, keys in pki\, secrets.db + LocalDb in data\ (lmxopcua#890).
.\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa\app -HostServiceAccount LocalSystem -ConfigureHostService `
-DenyPaths E:\ApiInstall\OtOpcUa, E:\ApiInstall\OtOpcUa\pki, E:\ApiInstall\OtOpcUa\data
#> #>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param( param(
@@ -71,6 +99,7 @@ param(
[string]$PeCacheDir, [string]$PeCacheDir,
[ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker', [ValidatePattern('^[A-Za-z0-9_.\-]{1,80}$')] [string]$WorkerServiceName = 'OtOpcUaScriptWorker',
[string]$HostServiceName = 'OtOpcUaHost', [string]$HostServiceName = 'OtOpcUaHost',
[string[]]$DenyPaths = @(),
[switch]$ConfigureHostService, [switch]$ConfigureHostService,
[switch]$Uninstall, [switch]$Uninstall,
[switch]$RemovePeCache, [switch]$RemovePeCache,
@@ -96,9 +125,13 @@ function Resolve-HostSid([string]$Account) {
} }
$hostSid = Resolve-HostSid $HostServiceAccount $hostSid = Resolve-HostSid $HostServiceAccount
# lmxopcua#890: what an earlier run denied, so a re-run drops stale denies and -Uninstall removes all of them. -PlanOnly
# reads nothing from the machine, so there it is empty.
$recorded = if ($PlanOnly) { @() } else { @(Get-OtOpcUaRecordedDenyPaths -WorkerServiceName $WorkerServiceName) }
$plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSid -PeCacheDir $PeCacheDir ` $plan = Get-ScriptWorkerIsolationPlan -InstallRoot $InstallRoot -HostSid $hostSid -PeCacheDir $PeCacheDir `
-WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName ` -WorkerServiceName $WorkerServiceName -HostServiceName $HostServiceName -DenyPaths $DenyPaths -RecordedDenyPaths $recorded `
-ConfigureHostService:$ConfigureHostService -Uninstall:$Uninstall -RemovePeCache:$RemovePeCache -ConfigureHostService:$ConfigureHostService -Uninstall:$Uninstall -RemovePeCache:$RemovePeCache
$denied = @($plan | Where-Object { $_.Id -eq 'DenyPathRecord' -and $_.Data.ContainsKey('Paths') } | ForEach-Object { $_.Data.Paths } | Where-Object { $_ })
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName $workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4})" -f ` Write-Host ("ScriptWorker isolation {0}: worker identity NT SERVICE\{1} ({2}), host account {3} ({4})" -f `
@@ -113,6 +146,21 @@ if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { throw 'Install-
if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 'script-worker'))) { if (-not $Uninstall -and -not (Test-Path -LiteralPath (Join-Path $InstallRoot 'script-worker'))) {
throw "$InstallRoot\script-worker not found - publish the Host (it carries script-worker\) before isolating it" throw "$InstallRoot\script-worker not found - publish the Host (it carries script-worker\) before isolating it"
} }
if (-not $Uninstall) {
# Checked up front so a typo stops the run before any step changes the machine.
$missing = @($denied | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Container) })
if ($missing.Count) { throw "DenyPaths folder(s) not found: $($missing -join '; ')" }
if ($denied.Count -eq 0) {
$parent = Split-Path -Parent $InstallRoot.TrimEnd('\')
if ($parent -and $parent -notmatch '^[A-Za-z]:\\?$') {
$siblings = @(Get-ChildItem -LiteralPath $parent -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^(pki|data|logs?|backups?|config|secrets?)$' } | ForEach-Object FullName)
foreach ($s in $siblings) {
Write-Warning "$s sits beside the install folder and is NOT denied to the worker (BUILTIN\Users is one of its restricting SIDs). Pass -DenyPaths '$parent' and the folders holding keys or secrets (lmxopcua#890)."
}
}
}
}
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $WhatIfPreference -and -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { if (-not $WhatIfPreference -and -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'run elevated (services, firewall rules and folder ACLs need an administrator)' throw 'run elevated (services, firewall rules and folder ACLs need an administrator)'
@@ -126,6 +174,17 @@ $results = foreach ($step in $plan) {
} }
} }
if (-not $WhatIfPreference -and -not $Uninstall) {
# The inherited deny stops at a protected DACL; list every such item that has no deny of its own (lmxopcua#890).
$workerFolder = Join-Path $InstallRoot.TrimEnd('\') 'script-worker'
$gaps = @(foreach ($p in @($InstallRoot.TrimEnd('\')) + $denied) { Find-OtOpcUaDenyCoverageGap -Path $p -WorkerSid $workerSid -Skip @($workerFolder, $PeCacheDir.TrimEnd('\')) })
$gaps = @($gaps | Sort-Object -Unique)
foreach ($g in $gaps) {
Write-Warning "$g does not inherit the worker deny (protected DACL, no deny of its own). Check its ACL grants nothing to Everyone, BUILTIN\Users or RESTRICTED, or add it to -DenyPaths."
}
Write-Host (" coverage: {0} denied folder(s); {1} protected item(s) the deny does not reach" -f (1 + $denied.Count), $gaps.Count)
}
if (-not $WhatIfPreference) { if (-not $WhatIfPreference) {
Write-Host '' Write-Host ''
if ($Uninstall) { if ($Uninstall) {
+136 -9
View File
@@ -18,6 +18,20 @@ $script:FullControl = 0x1F01FF
$script:Modify = 0x1301BF # Modify | Synchronize $script:Modify = 0x1301BF # Modify | Synchronize
$script:ReadAndExecute = 0x1200A9 # ReadAndExecute | Synchronize $script:ReadAndExecute = 0x1200A9 # ReadAndExecute | Synchronize
$script:ReadData = 0x000001 # ReadData (files) / ListDirectory (folders) $script:ReadData = 0x000001 # ReadData (files) / ListDirectory (folders)
# lmxopcua#890: the deny the worker identity gets on the install folder and on every -DenyPaths folder. Read AND write:
# ReadData/ListDirectory 0x1, WriteData/AddFile 0x2, AppendData/AddSubdirectory 0x4, WriteExtendedAttributes 0x10,
# DeleteSubdirectoriesAndFiles 0x40, WriteAttributes 0x100, Delete 0x10000, ChangePermissions 0x40000,
# TakeOwnership 0x80000.
# Not denied, so the loader can still walk to script-worker\ and the worker can still run: Traverse/Execute 0x20,
# ReadAttributes 0x80, ReadPermissions 0x20000, Synchronize 0x100000 (and ReadExtendedAttributes 0x8, which leaks nothing).
# The write half matters because a data drive commonly grants BUILTIN\Users (one of the worker's restricting SIDs)
# CreateFiles/AppendData on its subfolders: without it a worker could drop a certificate into pki\trusted\certs.
$script:DenyMask = 0x0D0157
# lmxopcua#890: where the installer records the -DenyPaths it applied, so a later re-run removes the ones dropped from
# the list and -Uninstall removes exactly what was added without the operator repeating them. The value lives under the
# anchor service's key and goes with it.
$script:DenyRecordValue = 'DenyPaths'
$script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound' $script:FirewallRuleName = 'OtOpcUa-ScriptWorker-BlockOutbound'
$script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe' $script:WorkerExeName = 'ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
@@ -77,12 +91,51 @@ function Get-OtOpcUaScriptWorkerFolderAce {
function Get-OtOpcUaInstallRootDenyAce { function Get-OtOpcUaInstallRootDenyAce {
<# <#
.SYNOPSIS .SYNOPSIS
InstallRoot\ : DENY the worker identity ReadData/ListDirectory, inherited — so appsettings*.json, the logs and InstallRoot\ and every -DenyPaths folder: DENY the worker identity read and write ($script:DenyMask), inherited —
the host binaries are unreadable to the worker even where BUILTIN\Users (one of its restricting SIDs) can read so appsettings*.json, the logs, the host binaries, pki\ (private keys, trust lists) and data\ (secrets.db,
them (Program Files grants Users read). script-worker\'s explicit allow is the nearer ACE and wins below it. LocalDb) are unreadable and unwritable to the worker even where BUILTIN\Users (one of its restricting SIDs) can
read or create files (lmxopcua#890). script-worker\'s explicit allow is the nearer ACE and wins below it.
#> #>
param([Parameter(Mandatory)] [string]$WorkerSid) param([Parameter(Mandatory)] [string]$WorkerSid)
Format-OtOpcUaAce -Type D -Flags 'OICI' -Mask $script:ReadData -Sid $WorkerSid Format-OtOpcUaAce -Type D -Flags 'OICI' -Mask $script:DenyMask -Sid $WorkerSid
}
function Test-OtOpcUaPathWithin {
# True when $Path equals $Ancestor or is below it (Windows paths, case-insensitive, no OS call).
param([string]$Path, [string]$Ancestor)
$p = $Path.TrimEnd('\'); $a = $Ancestor.TrimEnd('\')
$p -ieq $a -or $p.StartsWith($a + '\', [StringComparison]::OrdinalIgnoreCase)
}
function Resolve-OtOpcUaDenyPaths {
<#
.SYNOPSIS
Normalises -DenyPaths (lmxopcua#890): splits 'a,b' / 'a;b' (powershell -File passes an array as one string),
trims trailing '\', drops blanks, de-duplicates case-insensitively, drops InstallRoot itself (InstallRootDeny
covers it) and refuses what would break the worker or deny far too much.
#>
param([string[]]$Paths, [Parameter(Mandatory)] [string]$InstallRoot, [Parameter(Mandatory)] [string]$PeCacheDir)
$root = $InstallRoot.TrimEnd('\')
$workerFolder = "$root\script-worker"
$cache = $PeCacheDir.TrimEnd('\')
$out = [System.Collections.Generic.List[string]]::new()
foreach ($raw in @($Paths | Where-Object { $_ } | ForEach-Object { $_ -split '[,;]' })) {
$p = $raw.Trim().TrimEnd('\')
if (-not $p) { continue }
if ($p -match '^[A-Za-z]:$' -or $p -match '^\\\\[^\\]+(\\[^\\]+)?$') {
throw "DenyPaths entry '$raw' is a drive or share root; deny the deployment folder instead"
}
if ($p -notmatch '^([A-Za-z]:\\|\\\\[^\\]+\\)') { throw "DenyPaths entry '$raw' must be an absolute Windows path" }
if (Test-OtOpcUaPathWithin -Path $p -Ancestor $workerFolder) {
throw "DenyPaths entry '$raw' is (inside) $workerFolder, the worker's own binaries"
}
if (Test-OtOpcUaPathWithin -Path $p -Ancestor $cache) {
throw "DenyPaths entry '$raw' is (inside) PeCacheDir '$PeCacheDir'; the worker writes its compiled entries there"
}
if ($p -ieq $root) { continue }
if (-not ($out | Where-Object { $_ -ieq $p })) { $out.Add($p) }
}
$out.ToArray()
} }
function New-OtOpcUaPlanStep { function New-OtOpcUaPlanStep {
@@ -103,6 +156,12 @@ function Get-ScriptWorkerIsolationPlan {
[Parameter(Mandatory)] [string]$PeCacheDir, [Parameter(Mandatory)] [string]$PeCacheDir,
[string]$WorkerServiceName = 'OtOpcUaScriptWorker', [string]$WorkerServiceName = 'OtOpcUaScriptWorker',
[string]$HostServiceName = 'OtOpcUaHost', [string]$HostServiceName = 'OtOpcUaHost',
# lmxopcua#890: more folders to deny the worker identity (the deployment folder around InstallRoot, pki\, data\,
# logs\, a Secrets or LocalDb folder elsewhere). Each gets the same inherited deny as InstallRoot.
[string[]]$DenyPaths = @(),
# The -DenyPaths a previous run recorded (read by the entry script from the anchor service's key; empty under
# -PlanOnly). Install removes the deny from recorded paths no longer listed; -Uninstall removes it from all.
[string[]]$RecordedDenyPaths = @(),
[switch]$ConfigureHostService, [switch]$ConfigureHostService,
[switch]$Uninstall, [switch]$Uninstall,
[switch]$RemovePeCache [switch]$RemovePeCache
@@ -121,12 +180,26 @@ function Get-ScriptWorkerIsolationPlan {
$workerFolder = "$root\script-worker" $workerFolder = "$root\script-worker"
$workerExe = "$workerFolder\$($script:WorkerExeName)" $workerExe = "$workerFolder\$($script:WorkerExeName)"
$entries = "$cache\pe" $entries = "$cache\pe"
$deny = @(Resolve-OtOpcUaDenyPaths -Paths $DenyPaths -InstallRoot $root -PeCacheDir $cache)
# Recorded paths are not re-validated (they were when recorded); only normalised, so a removal always matches.
$recorded = @($RecordedDenyPaths | Where-Object { $_ } | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ -and $_ -ine $root } |
Sort-Object -Unique)
$denyAce = Get-OtOpcUaInstallRootDenyAce -WorkerSid $workerSid
$recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
$steps = [System.Collections.Generic.List[object]]::new() $steps = [System.Collections.Generic.List[object]]::new()
if (-not $Uninstall) { if (-not $Uninstall) {
$steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName ` $steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName `
"register the disabled anchor service '$WorkerServiceName' so the virtual account '$identity' ($workerSid) resolves by name (never started)" ` "register the disabled anchor service '$WorkerServiceName' so the virtual account '$identity' ($workerSid) resolves by name (never started)" `
@{ Name = $WorkerServiceName; Identity = $identity; BinPath = $workerExe })) @{ Name = $WorkerServiceName; Identity = $identity; BinPath = $workerExe }))
foreach ($stale in @($recorded | Where-Object { $p = $_; -not ($deny | Where-Object { $_ -ieq $p }) })) {
$steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $stale `
"remove every explicit ACE for the worker identity from '$stale' (recorded by an earlier run, no longer in -DenyPaths)" `
@{ Path = $stale; Sid = $workerSid; Remove = $true }))
}
$steps.Add((New-OtOpcUaPlanStep 'DenyPathRecord' $recordKey `
"record the denied folders ($(if ($deny.Count) { $deny -join '; ' } else { 'none beyond the install folder' })) so a re-run or -Uninstall removes exactly these" `
@{ Key = $recordKey; Name = $script:DenyRecordValue; Paths = $deny }))
$steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache ` $steps.Add((New-OtOpcUaPlanStep 'PeCacheDir' $cache `
"create the PE cache directory and set its ACL (protected; SYSTEM, Administrators, host account full control; no Users; no worker access to the key or markers)" ` "create the PE cache directory and set its ACL (protected; SYSTEM, Administrators, host account full control; no Users; no worker access to the key or markers)" `
@{ Path = $cache; Sddl = (Get-OtOpcUaPeCacheSddl -HostSid $HostSid) })) @{ Path = $cache; Sddl = (Get-OtOpcUaPeCacheSddl -HostSid $HostSid) }))
@@ -137,8 +210,13 @@ function Get-ScriptWorkerIsolationPlan {
"grant the worker identity read+execute on its own binaries" ` "grant the worker identity read+execute on its own binaries" `
@{ Path = $workerFolder; Ace = (Get-OtOpcUaScriptWorkerFolderAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:ReadAndExecute; Type = 'Allow' })) @{ Path = $workerFolder; Ace = (Get-OtOpcUaScriptWorkerFolderAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:ReadAndExecute; Type = 'Allow' }))
$steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root ` $steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root `
"deny the worker identity ReadData/ListDirectory on the install folder (appsettings, logs, host binaries); script-worker\ stays readable" ` "deny the worker identity read and write on the install folder (appsettings, logs, host binaries), inherited; script-worker\ stays readable" `
@{ Path = $root; Ace = (Get-OtOpcUaInstallRootDenyAce -WorkerSid $workerSid); Sid = $workerSid; Mask = $script:ReadData; Type = 'Deny' })) @{ Path = $root; Ace = $denyAce; Sid = $workerSid; Mask = $script:DenyMask; Type = 'Deny' }))
foreach ($p in $deny) {
$steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $p `
"deny the worker identity read and write on '$p' (certificates, secrets, stores, logs, backups), inherited" `
@{ Path = $p; Ace = $denyAce; Sid = $workerSid; Mask = $script:DenyMask; Type = 'Deny' }))
}
$steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName ` $steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName `
"add a Windows Firewall rule blocking ALL outbound traffic from '$workerExe' (every profile)" ` "add a Windows Firewall rule blocking ALL outbound traffic from '$workerExe' (every profile)" `
@{ Name = $script:FirewallRuleName; Program = $workerExe })) @{ Name = $script:FirewallRuleName; Program = $workerExe }))
@@ -156,6 +234,10 @@ function Get-ScriptWorkerIsolationPlan {
} }
$steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName ` $steps.Add((New-OtOpcUaPlanStep 'FirewallBlockOutbound' $script:FirewallRuleName `
"remove the Windows Firewall rule '$($script:FirewallRuleName)'" @{ Name = $script:FirewallRuleName; Remove = $true })) "remove the Windows Firewall rule '$($script:FirewallRuleName)'" @{ Name = $script:FirewallRuleName; Remove = $true }))
foreach ($p in @(@($deny) + @($recorded) | Sort-Object -Unique)) {
$steps.Add((New-OtOpcUaPlanStep 'SensitiveDirDeny' $p `
"remove every explicit ACE for the worker identity from '$p'" @{ Path = $p; Sid = $workerSid; Remove = $true }))
}
$steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root ` $steps.Add((New-OtOpcUaPlanStep 'InstallRootDeny' $root `
"remove every explicit ACE for the worker identity from the install folder" @{ Path = $root; Sid = $workerSid; Remove = $true })) "remove every explicit ACE for the worker identity from the install folder" @{ Path = $root; Sid = $workerSid; Remove = $true }))
$steps.Add((New-OtOpcUaPlanStep 'WorkerFolderAcl' $workerFolder ` $steps.Add((New-OtOpcUaPlanStep 'WorkerFolderAcl' $workerFolder `
@@ -170,6 +252,8 @@ function Get-ScriptWorkerIsolationPlan {
"remove every explicit ACE for the worker identity from the PE entries directory (the cache itself is kept; -RemovePeCache deletes it)" ` "remove every explicit ACE for the worker identity from the PE entries directory (the cache itself is kept; -RemovePeCache deletes it)" `
@{ Path = $entries; Sid = $workerSid; Remove = $true })) @{ Path = $entries; Sid = $workerSid; Remove = $true }))
} }
$steps.Add((New-OtOpcUaPlanStep 'DenyPathRecord' $recordKey `
"delete the record of denied folders" @{ Key = $recordKey; Name = $script:DenyRecordValue; Remove = $true }))
$steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName ` $steps.Add((New-OtOpcUaPlanStep 'AnchorService' $WorkerServiceName `
"delete the anchor service '$WorkerServiceName'" @{ Name = $WorkerServiceName; Remove = $true })) "delete the anchor service '$WorkerServiceName'" @{ Name = $WorkerServiceName; Remove = $true }))
} }
@@ -288,9 +372,22 @@ function Invoke-ScriptWorkerIsolationStep {
} }
return (Set-OtOpcUaStepAce -Data $d) return (Set-OtOpcUaStepAce -Data $d)
} }
{ $_ -in 'WorkerFolderAcl', 'InstallRootDeny' } { { $_ -in 'WorkerFolderAcl', 'InstallRootDeny', 'SensitiveDirDeny' } {
return (Set-OtOpcUaStepAce -Data $d) return (Set-OtOpcUaStepAce -Data $d)
} }
'DenyPathRecord' {
$current = @(Get-OtOpcUaRecordedDenyPaths -Key $d.Key -Name $d.Name)
if ($d.ContainsKey('Remove')) {
if (-not (Test-Path -LiteralPath $d.Key)) { return 'unchanged' }
Remove-Item -LiteralPath $d.Key -Recurse -Force
return 'changed'
}
if ((@($current) -join "`n") -ceq (@($d.Paths) -join "`n")) { return 'unchanged' }
if (-not (Test-Path -LiteralPath $d.Key)) { New-Item -Path $d.Key -Force | Out-Null }
if (@($d.Paths).Count -eq 0) { Remove-ItemProperty -LiteralPath $d.Key -Name $d.Name -ErrorAction SilentlyContinue }
else { Set-ItemProperty -LiteralPath $d.Key -Name $d.Name -Type MultiString -Value ([string[]]$d.Paths) }
return 'changed'
}
'FirewallBlockOutbound' { 'FirewallBlockOutbound' {
$rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue $rule = Get-NetFirewallRule -Name $d.Name -ErrorAction SilentlyContinue
if ($d.ContainsKey('Remove')) { if ($d.ContainsKey('Remove')) {
@@ -327,6 +424,36 @@ function Invoke-ScriptWorkerIsolationStep {
} }
} }
function Get-OtOpcUaRecordedDenyPaths {
# The -DenyPaths an earlier run recorded under the anchor service's key (lmxopcua#890); empty when there is none.
param([string]$WorkerServiceName = 'OtOpcUaScriptWorker', [string]$Key, [string]$Name = $script:DenyRecordValue)
if (-not $Key) { $Key = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters" }
if (-not (Test-Path -LiteralPath $Key)) { return }
$v = (Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction SilentlyContinue)
if ($v) { @($v.$Name | Where-Object { $_ }) }
}
function Find-OtOpcUaDenyCoverageGap {
<#
.SYNOPSIS
After the denies are applied (lmxopcua#890): the files and folders under $Path that the inherited deny does NOT
reach because their DACL is protected (inheritance switched off, e.g. a store that sets its own ACL on a private
key) and that carry no explicit deny of their own. Each one is reported as a WARN; the operator decides.
$Skip (script-worker\) is not walked.
#>
param([Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$WorkerSid, [string[]]$Skip = @())
if (-not (Test-Path -LiteralPath $Path)) { return }
$identity = [System.Security.Principal.SecurityIdentifier]::new($WorkerSid)
foreach ($item in @(Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue)) {
if (@($Skip | Where-Object { $_ -and (Test-OtOpcUaPathWithin -Path $item.FullName -Ancestor $_) }).Count) { continue }
try { $acl = Get-Acl -LiteralPath $item.FullName } catch { continue }
if (-not $acl.AreAccessRulesProtected) { continue }
$explicitDeny = @($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object { $_.IdentityReference -eq $identity -and $_.AccessControlType -eq 'Deny' })
if ($explicitDeny.Count -eq 0) { $item.FullName }
}
}
Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce, Export-ModuleMember -Function Get-OtOpcUaServiceSid, Get-OtOpcUaPeCacheSddl, Get-OtOpcUaPeEntriesAce,
Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Get-ScriptWorkerIsolationPlan, Get-OtOpcUaScriptWorkerFolderAce, Get-OtOpcUaInstallRootDenyAce, Resolve-OtOpcUaDenyPaths, Get-ScriptWorkerIsolationPlan,
Invoke-ScriptWorkerIsolationStep Invoke-ScriptWorkerIsolationStep, Get-OtOpcUaRecordedDenyPaths, Find-OtOpcUaDenyCoverageGap
@@ -1,6 +1,8 @@
<# <#
.SYNOPSIS .SYNOPSIS
Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883): apply -> re-apply -> verify -> uninstall -> uninstall. Windows-executed test of Install-ScriptWorkerIsolation.ps1 (lmxopcua#883, #890): apply -> re-apply -> verify -> shorter
-DenyPaths -> uninstall (from the record, without -DenyPaths) -> uninstall. ACL level only; the restricted-token proof that
the worker cannot read the denied folders is InstallerLayoutIsolationTests (ScriptWorker.Tests, OTOPCUA_ISOLATION_INSTALLER_E2E=1).
The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as The dry-run harness (Test-ScriptWorkerIsolation.ps1) only asserts plan data; it cannot see host-specific failures such as
DirectoryInfo.GetAccessControl missing on PowerShell 7, or StrictMode .Count on a scalar on Windows PowerShell 5.1. DirectoryInfo.GetAccessControl missing on PowerShell 7, or StrictMode .Count on a scalar on Windows PowerShell 5.1.
Run it once under EACH host: Run it once under EACH host:
@@ -38,6 +40,7 @@ function Assert([bool]$Condition, [string]$Message) {
if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red } if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red }
} }
function Invoke-Installer([string[]]$Extra) { function Invoke-Installer([string[]]$Extra) {
# $Extra: e.g. @('-DenyPaths', 'a,b') or @('-Uninstall'); -File passes an array parameter as one string, which the installer splits.
# Same PowerShell host as this test; the installer's output lines are ' [outcome ] StepId target'. # Same PowerShell host as this test; the installer's output lines are ' [outcome ] StepId target'.
$exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' } $exe = if ($PSVersionTable.PSEdition -eq 'Core') { (Get-Process -Id $PID).Path } else { 'powershell.exe' }
$out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount 'NT AUTHORITY\SYSTEM' ` $out = & $exe -NoProfile -ExecutionPolicy Bypass -File $installer -InstallRoot $root -HostServiceAccount 'NT AUTHORITY\SYSTEM' `
@@ -53,27 +56,30 @@ function Get-Explicit([string]$Path, [string]$Sid) {
$scratch = Join-Path ([IO.Path]::GetTempPath()) ("otopcua-iso-test-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) $scratch = Join-Path ([IO.Path]::GetTempPath()) ("otopcua-iso-test-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
$root = Join-Path $scratch 'install' $root = Join-Path $scratch 'install'
$cache = Join-Path $scratch 'pe-cache' $cache = Join-Path $scratch 'pe-cache'
$deny = @($scratch, (Join-Path $scratch 'pki'), (Join-Path $scratch 'data')) # lmxopcua#890: the deployment folder + keys + secrets
$recordKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$WorkerServiceName\Parameters"
$workerExe = Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe' $workerExe = Join-Path $root 'script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe'
$workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName $workerSid = Get-OtOpcUaServiceSid -Name $WorkerServiceName
$hostKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$HostServiceName" $hostKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$HostServiceName"
try { try {
New-Item -ItemType Directory -Path (Split-Path $workerExe) -Force | Out-Null New-Item -ItemType Directory -Path (Split-Path $workerExe) -Force | Out-Null
Set-Content -LiteralPath $workerExe -Value 'stub' Set-Content -LiteralPath $workerExe -Value 'stub'
foreach ($d in $deny) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
& sc.exe create $HostServiceName binPath= "$env:SystemRoot\System32\cmd.exe" start= disabled | Out-Null & sc.exe create $HostServiceName binPath= "$env:SystemRoot\System32\cmd.exe" start= disabled | Out-Null
if ($LASTEXITCODE -ne 0) { throw "sc.exe create $HostServiceName failed" } if ($LASTEXITCODE -ne 0) { throw "sc.exe create $HostServiceName failed" }
Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1')) Set-ItemProperty -Path $hostKey -Name Environment -Type MultiString -Value ([string[]]@('Keep__Me=1'))
Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch" Write-Host "Host: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion); scratch $scratch"
Write-Host 'Dry run' Write-Host 'Dry run'
$dry = Invoke-Installer @('-WhatIf') $dry = Invoke-Installer @('-WhatIf', '-DenyPaths', ($deny -join ','))
Assert ($dry.Exit -eq 0) "-WhatIf exits 0 ($($dry.Output))" Assert ($dry.Exit -eq 0) "-WhatIf exits 0 ($($dry.Output))"
Assert (-not (Get-Service $WorkerServiceName -ErrorAction SilentlyContinue) -and -not (Test-Path $cache) -and Assert (-not (Get-Service $WorkerServiceName -ErrorAction SilentlyContinue) -and -not (Test-Path $cache) -and
-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) '-WhatIf changed nothing' -not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) '-WhatIf changed nothing'
Write-Host 'Apply' Write-Host 'Apply'
$a = Invoke-Installer @() $a = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($a.Exit -eq 0) "apply exits 0 ($($a.Output))" Assert ($a.Exit -eq 0) "apply exits 0 ($($a.Output))"
Assert ($a.Steps.Count -eq 7 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 7 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" Assert ($a.Steps.Count -eq 11 -and @($a.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "apply: 11 steps, all changed ($(($a.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").StartMode -eq 'Disabled') 'anchor service is Disabled' Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").StartMode -eq 'Disabled') 'anchor service is Disabled'
Assert ((& sc.exe showsid $WorkerServiceName | Out-String) -match [regex]::Escape($workerSid)) 'anchor SID matches Get-OtOpcUaServiceSid' Assert ((& sc.exe showsid $WorkerServiceName | Out-String) -match [regex]::Escape($workerSid)) 'anchor SID matches Get-OtOpcUaServiceSid'
Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").PathName -eq "`"$workerExe`"") 'anchor binPath is stored quoted (Windows PowerShell 5.1 used to drop the quotes)' Assert ((Get-CimInstance Win32_Service -Filter "Name='$WorkerServiceName'").PathName -eq "`"$workerExe`"") 'anchor binPath is stored quoted (Windows PowerShell 5.1 used to drop the quotes)'
@@ -81,9 +87,17 @@ try {
Assert ($pe.Count -eq 1 -and $pe[0].AccessControlType -eq 'Allow') 'worker has one Allow ACE on <PeCacheDir>\pe' Assert ($pe.Count -eq 1 -and $pe[0].AccessControlType -eq 'Allow') 'worker has one Allow ACE on <PeCacheDir>\pe'
Assert (@(Get-Explicit $cache $workerSid).Count -eq 0) 'worker has no ACE on the PeCacheDir root' Assert (@(Get-Explicit $cache $workerSid).Count -eq 0) 'worker has no ACE on the PeCacheDir root'
Assert ((Get-Acl -LiteralPath $cache).AreAccessRulesProtected) 'PeCacheDir DACL is protected' Assert ((Get-Acl -LiteralPath $cache).AreAccessRulesProtected) 'PeCacheDir DACL is protected'
$deny = @(Get-Explicit $root $workerSid) $rootDeny = @(Get-Explicit $root $workerSid)
Assert ($deny.Count -eq 1 -and $deny[0].AccessControlType -eq 'Deny') 'worker has one Deny ACE on the install root' Assert ($rootDeny.Count -eq 1 -and $rootDeny[0].AccessControlType -eq 'Deny') 'worker has one Deny ACE on the install root'
Assert (@(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1) 'worker has one ACE on script-worker\' Assert (@(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 1) 'worker has one ACE on script-worker\'
foreach ($d in $deny) {
$dd = @(Get-Explicit $d $workerSid)
Assert ($dd.Count -eq 1 -and $dd[0].AccessControlType -eq 'Deny' -and $dd[0].InheritanceFlags -eq 'ContainerInherit, ObjectInherit') "worker has one inherited Deny ACE on $d"
}
Assert (((Get-ItemProperty -Path $recordKey -Name DenyPaths).DenyPaths -join '|') -eq ($deny -join '|')) 'the denied folders are recorded under the anchor key'
$hostDeny = @((Get-Acl -LiteralPath $scratch).GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) |
Where-Object { $_.AccessControlType -eq 'Deny' -and $_.IdentityReference.Value -ne $workerSid })
Assert ($hostDeny.Count -eq 0) 'no deny for anyone but the worker identity (the host account is unaffected)'
$fw = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue $fw = Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue
Assert ($null -ne $fw -and "$($fw.Direction)" -eq 'Outbound' -and "$($fw.Action)" -eq 'Block' -and (($fw | Get-NetFirewallApplicationFilter).Program -ieq $workerExe)) 'firewall rule is Outbound/Block on the worker exe' Assert ($null -ne $fw -and "$($fw.Direction)" -eq 'Outbound' -and "$($fw.Action)" -eq 'Block' -and (($fw | Get-NetFirewallApplicationFilter).Program -ieq $workerExe)) 'firewall rule is Outbound/Block on the worker exe'
$env1 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment) $env1 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment)
@@ -91,24 +105,35 @@ try {
(@($env1 | Where-Object { $_ -like 'Scripting__Worker__PeCacheDir=*' }).Count -eq 1)) 'host env has both Scripting__Worker__* entries and keeps the others' (@($env1 | Where-Object { $_ -like 'Scripting__Worker__PeCacheDir=*' }).Count -eq 1)) 'host env has both Scripting__Worker__* entries and keeps the others'
Write-Host 'Re-apply (idempotent)' Write-Host 'Re-apply (idempotent)'
$b = Invoke-Installer @() $b = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($b.Exit -eq 0) "re-apply exits 0 ($($b.Output))" Assert ($b.Exit -eq 0) "re-apply exits 0 ($($b.Output))"
Assert ($b.Steps.Count -eq 7 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 7 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" Assert ($b.Steps.Count -eq 11 -and @($b.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "re-apply: 11 steps, all unchanged ($(($b.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
Write-Host 'Uninstall, twice' Write-Host 'Re-apply with data\ dropped from -DenyPaths: its deny goes, the others stay'
$c = Invoke-Installer @('-DenyPaths', (($deny | Select-Object -First 2) -join ','))
Assert ($c.Exit -eq 0) "shorter re-apply exits 0 ($($c.Output))"
$changedIds = @($c.Steps | Where-Object Outcome -eq 'changed' | ForEach-Object Id) -join ','
Assert ($changedIds -eq 'SensitiveDirDeny,DenyPathRecord') "only the stale deny and the record change ($changedIds)"
Assert (@(Get-Explicit $deny[2] $workerSid).Count -eq 0 -and @(Get-Explicit $deny[1] $workerSid).Count -eq 1) 'data\ un-denied, pki\ still denied'
$c2 = Invoke-Installer @('-DenyPaths', ($deny -join ','))
Assert ($c2.Exit -eq 0 -and @($c2.Steps | Where-Object Outcome -eq 'changed').Count -eq 2) 'putting data\ back changes its deny and the record only'
Write-Host 'Uninstall WITHOUT -DenyPaths (the record drives it), twice'
$u1 = Invoke-Installer @('-Uninstall') $u1 = Invoke-Installer @('-Uninstall')
Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))" Assert ($u1.Exit -eq 0) "uninstall exits 0 ($($u1.Output))"
Assert ($u1.Steps.Count -eq 6 -and @($u1.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "uninstall: 6 steps, all changed ($(($u1.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" Assert ($u1.Steps.Count -eq 10 -and @($u1.Steps | Where-Object Outcome -ne 'changed').Count -eq 0) "uninstall: 10 steps, all changed ($(($u1.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
Assert (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) 'firewall rule removed' Assert (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) 'firewall rule removed'
Assert (@(Get-Explicit $root $workerSid).Count -eq 0 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 0 -and Assert (@(Get-Explicit $root $workerSid).Count -eq 0 -and @(Get-Explicit (Join-Path $root 'script-worker') $workerSid).Count -eq 0 -and
@(Get-Explicit (Join-Path $cache 'pe') $workerSid).Count -eq 0) 'no explicit worker ACEs remain' @(Get-Explicit (Join-Path $cache 'pe') $workerSid).Count -eq 0 -and
@($deny | Where-Object { @(Get-Explicit $_ $workerSid).Count -ne 0 }).Count -eq 0) 'no explicit worker ACEs remain, on the denied folders either'
Assert (-not (Test-Path $recordKey)) 'the record is gone'
Assert (Test-Path $cache) 'the PE cache is kept without -RemovePeCache' Assert (Test-Path $cache) 'the PE cache is kept without -RemovePeCache'
$env2 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment) $env2 = @((Get-ItemProperty -Path $hostKey -Name Environment).Environment)
Assert (($env2 -join '|') -eq 'Keep__Me=1') "host env restored ($($env2 -join '|'))" Assert (($env2 -join '|') -eq 'Keep__Me=1') "host env restored ($($env2 -join '|'))"
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
$u2 = Invoke-Installer @('-Uninstall') $u2 = Invoke-Installer @('-Uninstall')
Assert ($u2.Exit -eq 0) "second uninstall exits 0 ($($u2.Output))" Assert ($u2.Exit -eq 0) "second uninstall exits 0 ($($u2.Output))"
Assert ($u2.Steps.Count -eq 6 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 6 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))" Assert ($u2.Steps.Count -eq 7 -and @($u2.Steps | Where-Object Outcome -ne 'unchanged').Count -eq 0) "second uninstall: 7 steps, all unchanged ($(($u2.Steps | ForEach-Object { "$($_.Id)=$($_.Outcome)" }) -join ','))"
} }
catch { $script:failures++; Write-Host " FAIL: unexpected error: $($_ | Out-String)" -ForegroundColor Red } catch { $script:failures++; Write-Host " FAIL: unexpected error: $($_ | Out-String)" -ForegroundColor Red }
finally { finally {
@@ -18,6 +18,7 @@ $script:passes = 0
function Assert([bool]$Condition, [string]$Message) { function Assert([bool]$Condition, [string]$Message) {
if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red } if ($Condition) { $script:passes++ } else { $script:failures++; Write-Host " FAIL: $Message" -ForegroundColor Red }
} }
function Join-Targets($Steps) { (@($Steps | ForEach-Object { $_.Target }) -join '|') } # StrictMode-safe on an empty list
function Assert-Throws([scriptblock]$Block, [string]$Like, [string]$Message) { function Assert-Throws([scriptblock]$Block, [string]$Like, [string]$Message) {
try { & $Block; Assert $false "$Message (did not throw)" } try { & $Block; Assert $false "$Message (did not throw)" }
catch { Assert ($_.Exception.Message -like $Like) "$Message (threw '$($_.Exception.Message)')" } catch { Assert ($_.Exception.Message -like $Like) "$Message (threw '$($_.Exception.Message)')" }
@@ -38,7 +39,7 @@ Assert-Throws { Get-OtOpcUaServiceSid -Name 'bad name' } '*' 'a name with a spac
Write-Host 'Install plan' Write-Host 'Install plan'
$plan = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService) $plan = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService)
$ids = $plan.Id -join ',' $ids = $plan.Id -join ','
Assert ($ids -eq 'AnchorService,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)" Assert ($ids -eq 'AnchorService,DenyPathRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,FirewallBlockOutbound,HostServiceEnv') "install step order ($ids)"
$byId = @{}; foreach ($s in $plan) { $byId[$s.Id] = $s } $byId = @{}; foreach ($s in $plan) { $byId[$s.Id] = $s }
$anchor = $byId['AnchorService'].Data $anchor = $byId['AnchorService'].Data
@@ -65,8 +66,18 @@ Assert ($wf.Ace -eq "(A;OICI;0x1200a9;;;$workerSid)") "worker gets read+execute
Assert ($wf.Path -like '*\OtOpcUa\script-worker' -or $wf.Path -like '*/script-worker') "worker folder path ($($wf.Path))" Assert ($wf.Path -like '*\OtOpcUa\script-worker' -or $wf.Path -like '*/script-worker') "worker folder path ($($wf.Path))"
$deny = $byId['InstallRootDeny'].Data $deny = $byId['InstallRootDeny'].Data
Assert ($deny.Ace -eq "(D;OICI;0x1;;;$workerSid)") "install root DENIES the worker ReadData ($($deny.Ace))" Assert ($deny.Ace -eq "(D;OICI;0xd0157;;;$workerSid)") "install root DENIES the worker read and write, inherited ($($deny.Ace))"
Assert ($deny.Type -eq 'Deny' -and $deny.Path -eq $root) 'install-root ACE is a deny on the root' Assert ($deny.Type -eq 'Deny' -and $deny.Path -eq $root -and $deny.Mask -eq 0xD0157) 'install-root ACE is a deny on the root'
# lmxopcua#890: the bits that matter, and the ones that must stay off so the worker can still reach script-worker\.
foreach ($bit in @{ ReadData = 0x1; WriteData = 0x2; AppendData = 0x4; WriteAttributes = 0x100; Delete = 0x10000; DeleteChild = 0x40; WriteDac = 0x40000 }.GetEnumerator()) {
Assert (($deny.Mask -band $bit.Value) -ne 0) "the deny includes $($bit.Key)"
}
foreach ($bit in @{ TraverseExecute = 0x20; ReadAttributes = 0x80; ReadPermissions = 0x20000; Synchronize = 0x100000 }.GetEnumerator()) {
Assert (($deny.Mask -band $bit.Value) -eq 0) "the deny leaves $($bit.Key) alone (the loader walks through denied folders)"
}
$rec = $byId['DenyPathRecord'].Data
Assert ($rec.Key -eq 'HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaScriptWorker\Parameters' -and $rec.Name -eq 'DenyPaths' -and @($rec.Paths).Count -eq 0) `
"without -DenyPaths the record is empty, under the anchor service's key ($($rec.Key))"
$fw = $byId['FirewallBlockOutbound'].Data $fw = $byId['FirewallBlockOutbound'].Data
Assert ($fw.Program -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "firewall rule program ($($fw.Program))" Assert ($fw.Program -eq 'C:\Program Files\OtOpcUa\script-worker\ZB.MOM.WW.OtOpcUa.ScriptWorker.exe') "firewall rule program ($($fw.Program))"
@@ -85,12 +96,60 @@ Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostS
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $root } '*inside InstallRoot*' 'the install root itself is refused' Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $root } '*inside InstallRoot*' 'the install root itself is refused'
Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'relative\cache' } '*absolute*' 'a relative cache is refused' Assert-Throws { Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'relative\cache' } '*absolute*' 'a relative cache is refused'
$sibling = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'C:\Program Files\OtOpcUa-cache') $sibling = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir 'C:\Program Files\OtOpcUa-cache')
Assert ($sibling.Count -eq 6) 'a sibling folder sharing the prefix is NOT inside the root (positive control)' Assert ($sibling.Count -eq 7) 'a sibling folder sharing the prefix is NOT inside the root (positive control)'
Write-Host 'Sensitive siblings: -DenyPaths (lmxopcua#890, the wonder layout)'
$qaRoot = 'E:\ApiInstall\OtOpcUa'
$qaApp = "$qaRoot\app"
$qaDeny = @($qaRoot, "$qaRoot\pki", "$qaRoot\data")
$qa = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $qaDeny -ConfigureHostService)
$qaIds = $qa.Id -join ','
Assert ($qaIds -eq 'AnchorService,DenyPathRecord,PeCacheDir,PeEntriesDir,WorkerFolderAcl,InstallRootDeny,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,FirewallBlockOutbound,HostServiceEnv') `
"QA plan order ($qaIds)"
$sens = @($qa | Where-Object Id -eq 'SensitiveDirDeny')
Assert ((Join-Targets $sens) -eq ($qaDeny -join '|')) "one deny per folder, in the given order ($(Join-Targets $sens))"
foreach ($s in $sens) {
Assert ($s.Data.Ace -eq "(D;OICI;0xd0157;;;$workerSid)" -and $s.Data.Type -eq 'Deny' -and $s.Data.Sid -eq $workerSid -and $s.Data.Mask -eq 0xD0157) `
"$($s.Target): the worker identity is denied read and write, inherited ($($s.Data.Ace))"
}
Assert ((@(($qa | Where-Object Id -eq 'DenyPathRecord').Data.Paths) -join '|') -eq ($qaDeny -join '|')) 'the record lists exactly the denied folders'
# The host account is never denied anything: every deny ACE names the worker identity alone.
$allDeny = @($qa | Where-Object { $_.Data.ContainsKey('Type') -and $_.Data.Type -eq 'Deny' })
Assert ($allDeny.Count -eq 4 -and @($allDeny | Where-Object { $_.Data.Sid -ne $workerSid }).Count -eq 0) "every deny ACE is the worker identity's ($($allDeny.Count) denies)"
$asUser = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid $hostSid -PeCacheDir $cache -DenyPaths $qaDeny)
Assert (@($asUser | Where-Object { $_.Data.ContainsKey('Ace') -and $_.Data.Ace -like "(D;*;;;$hostSid)" }).Count -eq 0 -and
@($asUser | Where-Object { $_.Data.ContainsKey('Ace') -and $_.Data.Ace -like '(D;*' }).Count -eq 4) 'a domain host account is never the subject of a deny'
# script-worker\ keeps its allow, and the PE cache is untouched by the denies.
Assert (($qa | Where-Object Id -eq 'WorkerFolderAcl').Data.Path -eq "$qaApp\script-worker") 'script-worker\ allow still planned under app\'
Assert (@($sens | Where-Object { $_.Target -like "$cache*" }).Count -eq 0) 'no deny on the PE cache'
$norm = @(Resolve-OtOpcUaDenyPaths -Paths @("$qaRoot\pki\,$qaRoot\PKI;$qaRoot\data", '', $qaApp, "$qaRoot\") -InstallRoot $qaApp -PeCacheDir $cache)
Assert (($norm -join '|') -eq "$qaRoot\pki|$qaRoot\data|$qaRoot") "comma/semicolon split, trailing '\' trimmed, case-insensitive de-dup, InstallRoot dropped ($($norm -join '|'))"
$viaString = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($qaDeny -join ','))
Assert (@($viaString | Where-Object Id -eq 'SensitiveDirDeny').Count -eq 3) 'a single comma-separated string (powershell -File) gives the same three denies'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths 'E:\' -InstallRoot $qaApp -PeCacheDir $cache } '*drive or share root*' 'a drive root is refused'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths '\\srv\share' -InstallRoot $qaApp -PeCacheDir $cache } '*drive or share root*' 'a share root is refused'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$qaApp\script-worker" -InstallRoot $qaApp -PeCacheDir $cache } '*worker''s own binaries*' 'script-worker\ itself is refused'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$qaApp\SCRIPT-WORKER\runtimes" -InstallRoot $qaApp -PeCacheDir $cache } '*worker''s own binaries*' 'a folder inside script-worker\ is refused'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths "$cache\pe" -InstallRoot $qaApp -PeCacheDir $cache } '*PeCacheDir*' 'the PE cache (pe\) is refused'
Assert-Throws { Resolve-OtOpcUaDenyPaths -Paths 'pki' -InstallRoot $qaApp -PeCacheDir $cache } '*absolute*' 'a relative path is refused'
$okAncestor = @(Resolve-OtOpcUaDenyPaths -Paths @('E:\ApiInstall', "$qaApp-old\script-worker") -InstallRoot $qaApp -PeCacheDir $cache)
Assert ($okAncestor.Count -eq 2) 'an ancestor of script-worker\ and a prefix-sharing sibling are accepted (positive control)'
Write-Host 'Re-run with a shorter list: the dropped folder loses its deny (lmxopcua#890)'
$rerun = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths @($qaRoot, "$qaRoot\pki") `
-RecordedDenyPaths @($qaRoot, "$qaRoot\pki", "$qaRoot\data\"))
$staleSteps = @($rerun | Where-Object { $_.Id -eq 'SensitiveDirDeny' -and $_.Data.ContainsKey('Remove') })
Assert ((Join-Targets $staleSteps) -eq "$qaRoot\data") "only the dropped folder is un-denied ($(Join-Targets $staleSteps))"
Assert ([array]::IndexOf(@($rerun.Id), 'SensitiveDirDeny') -lt [array]::IndexOf(@($rerun.Id), 'DenyPathRecord')) 'the stale removal runs before the record is rewritten'
Assert (@($rerun | Where-Object { $_.Id -eq 'SensitiveDirDeny' -and -not $_.Data.ContainsKey('Remove') }).Count -eq 2) 'the two kept folders keep their deny'
$same = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -DenyPaths $qaDeny -RecordedDenyPaths $qaDeny)
Assert (@($same | Where-Object { $_.Data.ContainsKey('Remove') }).Count -eq 0) 'an unchanged list plans no removal (idempotent re-run)'
Write-Host 'Uninstall plan' Write-Host 'Uninstall plan'
$un = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService -Uninstall) $un = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -ConfigureHostService -Uninstall)
$unIds = $un.Id -join ',' $unIds = $un.Id -join ','
Assert ($unIds -eq 'HostServiceEnv,FirewallBlockOutbound,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,AnchorService') "rollback order ($unIds)" Assert ($unIds -eq 'HostServiceEnv,FirewallBlockOutbound,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,DenyPathRecord,AnchorService') "rollback order ($unIds)"
Assert (@($un | Where-Object { -not ($_.Data.ContainsKey('Remove')) }).Count -eq 0) 'every rollback step removes' Assert (@($un | Where-Object { -not ($_.Data.ContainsKey('Remove')) }).Count -eq 0) 'every rollback step removes'
Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -ne 'PeCacheDir' } | Sort-Object) -join ',')) ` Assert ((@($un.Id | Sort-Object) -join ',') -eq (@($plan.Id | Where-Object { $_ -ne 'PeCacheDir' } | Sort-Object) -join ',')) `
'rollback reverses every install step but the cache (kept by default)' 'rollback reverses every install step but the cache (kept by default)'
@@ -98,6 +157,16 @@ Assert (($un | Where-Object Id -eq 'HostServiceEnv').Data.Remove -contains 'Scri
foreach ($s in $un | Where-Object { $_.Id -in 'InstallRootDeny', 'WorkerFolderAcl', 'PeEntriesDir' }) { foreach ($s in $un | Where-Object { $_.Id -in 'InstallRootDeny', 'WorkerFolderAcl', 'PeEntriesDir' }) {
Assert ($s.Data.Sid -eq $workerSid) "rollback $($s.Id) targets only the worker identity's ACEs" Assert ($s.Data.Sid -eq $workerSid) "rollback $($s.Id) targets only the worker identity's ACEs"
} }
# lmxopcua#890: -Uninstall removes the deny from every recorded folder, plus any passed again, once each.
$unQa = @(Get-ScriptWorkerIsolationPlan -InstallRoot $qaApp -HostSid 'S-1-5-18' -PeCacheDir $cache -Uninstall `
-DenyPaths @("$qaRoot\PKI") -RecordedDenyPaths $qaDeny)
$unSens = @($unQa | Where-Object Id -eq 'SensitiveDirDeny')
Assert ($unSens.Count -eq 3 -and @($unSens | Where-Object { -not $_.Data.ContainsKey('Remove') -or $_.Data.Sid -ne $workerSid }).Count -eq 0) `
"rollback un-denies each recorded folder once, worker ACEs only ($(Join-Targets $unSens))"
Assert ((@(@($unSens | ForEach-Object { $_.Target }) | Sort-Object) -join '|') -eq (@($qaDeny | Sort-Object) -join '|')) 'rollback covers exactly the recorded folders'
Assert ((@($unQa.Id) -join ',') -eq 'FirewallBlockOutbound,SensitiveDirDeny,SensitiveDirDeny,SensitiveDirDeny,InstallRootDeny,WorkerFolderAcl,PeEntriesDir,DenyPathRecord,AnchorService') `
"QA rollback order ($(@($unQa.Id) -join ','))"
Assert (($unQa | Where-Object Id -eq 'DenyPathRecord').Data.ContainsKey('Remove')) 'rollback deletes the record'
$unAll = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -Uninstall -RemovePeCache) $unAll = @(Get-ScriptWorkerIsolationPlan -InstallRoot $root -HostSid $hostSid -PeCacheDir $cache -Uninstall -RemovePeCache)
Assert ($unAll.Id -contains 'PeCacheDir' -and -not ($unAll.Id -contains 'PeEntriesDir')) '-RemovePeCache deletes the cache instead of editing it' Assert ($unAll.Id -contains 'PeCacheDir' -and -not ($unAll.Id -contains 'PeEntriesDir')) '-RemovePeCache deletes the cache instead of editing it'
@@ -109,13 +178,15 @@ Assert-Throws { & $entry -InstallRoot $root -HostServiceAccount 'OTOPCUA\svc' -P
'-PlanOnly refuses an account name (no lookup without the OS)' '-PlanOnly refuses an account name (no lookup without the OS)'
$defaultCache = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PlanOnly 6>$null) $defaultCache = @(& $entry -InstallRoot $root -HostServiceAccount $hostSid -PlanOnly 6>$null)
Assert ((($defaultCache | Where-Object Id -eq 'PeCacheDir').Data.Path) -like '*OtOpcUa*script-pe-cache') 'default PeCacheDir is under ProgramData\OtOpcUa' Assert ((($defaultCache | Where-Object Id -eq 'PeCacheDir').Data.Path) -like '*OtOpcUa*script-pe-cache') 'default PeCacheDir is under ProgramData\OtOpcUa'
$qaEntry = @(& $entry -InstallRoot $qaApp -HostServiceAccount 'S-1-5-18' -PeCacheDir $cache -DenyPaths ($qaDeny -join ',') -ConfigureHostService -PlanOnly 6>$null)
Assert ((@($qaEntry.Id) -join ',') -eq $qaIds) "-PlanOnly -DenyPaths 'a,b,c' (one string, as powershell -File passes it) plans the same steps"
if ($IsWindows) { if ($IsWindows) {
Write-Host 'SDDL parses on Windows' Write-Host 'SDDL parses on Windows'
$ds = [System.Security.AccessControl.DirectorySecurity]::new() $ds = [System.Security.AccessControl.DirectorySecurity]::new()
$ds.SetSecurityDescriptorSddlForm($sddl) $ds.SetSecurityDescriptorSddlForm($sddl)
Assert ($ds.AreAccessRulesProtected) 'parsed PeCacheDir descriptor is protected' Assert ($ds.AreAccessRulesProtected) 'parsed PeCacheDir descriptor is protected'
foreach ($ace in $pe.Ace, $wf.Ace, $deny.Ace) { foreach ($ace in $pe.Ace, $wf.Ace, $deny.Ace, $sens[0].Data.Ace) {
$one = [System.Security.AccessControl.DirectorySecurity]::new() $one = [System.Security.AccessControl.DirectorySecurity]::new()
$one.SetSecurityDescriptorSddlForm("D:$ace") $one.SetSecurityDescriptorSddlForm("D:$ace")
Assert (@($one.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])).Count -eq 1) "ACE $ace parses" Assert (@($one.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])).Count -eq 1) "ACE $ace parses"
@@ -0,0 +1,229 @@
using System.Diagnostics;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>
/// lmxopcua#890 — the installer's denies, end to end, against a real restricted worker token. A scratch copy of the
/// wonder layout (<c>&lt;deploy&gt;\{app\script-worker, pki, data, logs, backup}</c>, granting <c>BUILTIN\Users</c> read
/// plus create-files on subfolders, as a data drive does) is isolated by <c>Install-ScriptWorkerIsolation.ps1</c>
/// under Windows PowerShell with <c>-DenyPaths</c>. Under the worker token the test then cannot read <c>pki\</c>,
/// <c>data\</c>, <c>logs\</c>, <c>backup\</c> or <c>app\appsettings.json</c>, nor drop a file into the trust list,
/// while a real worker launched from the layout still runs a script. Before the install and after the uninstall the
/// same token reads and writes them (the positive control: the deny, not the layout, is what refuses).
/// <para>
/// It changes machine state while it runs (a disabled scratch anchor service, the fixed-name firewall rule, ACEs
/// in its scratch folder), so it runs only when <c>OTOPCUA_ISOLATION_INSTALLER_E2E=1</c>, elevated, on Windows,
/// and refuses when the real isolation is installed. Everything is removed in <c>finally</c>.
/// </para>
/// </summary>
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class InstallerLayoutIsolationTests
{
public const string GateVariable = "OTOPCUA_ISOLATION_INSTALLER_E2E";
private const string WorkerServiceName = "OtOpcUaIsoE2EWorker";
private const string FirewallRule = "OtOpcUa-ScriptWorker-BlockOutbound";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task The_installer_denies_the_worker_every_sensitive_sibling_and_the_worker_still_runs()
{
if (!OperatingSystem.IsWindows()) Assert.Skip("Windows installer end-to-end");
else if (Environment.GetEnvironmentVariable(GateVariable) != "1") Assert.Skip($"set {GateVariable}=1 (changes machine state; elevated)");
else await RunEndToEnd();
}
[SupportedOSPlatform("windows")]
private static async Task RunEndToEnd()
{
using (var me = WindowsIdentity.GetCurrent())
new WindowsPrincipal(me).IsInRole(WindowsBuiltInRole.Administrator).ShouldBeTrue("run elevated");
Run("powershell.exe", $"-NoProfile -Command \"if (Get-NetFirewallRule -Name {FirewallRule} -ErrorAction SilentlyContinue) {{ exit 3 }}\"")
.Exit.ShouldBe(0, "the real isolation is installed on this machine (its firewall rule exists); not touching it");
Run("sc.exe", $"query {WorkerServiceName}").Exit.ShouldNotBe(0, $"scratch service {WorkerServiceName} already exists");
var installer = FindInstaller();
var workerSid = ScriptWorkerIdentity.ServiceSid(WorkerServiceName);
var deploy = Path.Combine(Path.GetPathRoot(Environment.SystemDirectory)!, "otopcua-iso-e2e-" + Guid.NewGuid().ToString("N")[..8]);
var app = Path.Combine(deploy, "app");
var workerFolder = Path.Combine(app, OutOfProcessScriptRunner.WorkerFolderName);
var cache = Path.Combine(deploy + "-pecache");
var secrets = new Dictionary<string, string>
{
["pki"] = Path.Combine(deploy, "pki", "own", "private", "otopcua.pfx"),
["data"] = Path.Combine(deploy, "data", "secrets.db"),
["logs"] = Path.Combine(deploy, "logs", "otopcua-20261112.log"),
["backup"] = Path.Combine(deploy, "backup", "data-pre", "secrets.db"),
["appsettings"] = Path.Combine(app, "appsettings.json"),
};
var trustList = Path.Combine(deploy, "pki", "trusted", "certs");
var denyPaths = $"{deploy},{Path.Combine(deploy, "pki")},{Path.Combine(deploy, "data")}";
try
{
// ---- the layout: a copy of the built worker, the secrets beside it, a data-drive ACL on the root ----
CopyDirectory(Path.Combine(AppContext.BaseDirectory, OutOfProcessScriptRunner.WorkerFolderName), workerFolder);
foreach (var file in secrets.Values)
{
Directory.CreateDirectory(Path.GetDirectoryName(file)!);
File.WriteAllText(file, "secret");
}
Directory.CreateDirectory(trustList);
var rootAcl = new DirectoryInfo(deploy).GetAccessControl();
var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
rootAcl.AddAccessRule(new FileSystemAccessRule(users, FileSystemRights.ReadAndExecute,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
rootAcl.AddAccessRule(new FileSystemAccessRule(users, FileSystemRights.CreateFiles | FileSystemRights.AppendData,
InheritanceFlags.ContainerInherit, PropagationFlags.None, AccessControlType.Allow));
new DirectoryInfo(deploy).SetAccessControl(rootAcl);
// A protected file the inherited deny cannot reach: the installer must name it.
var island = Path.Combine(deploy, "data", "island.bin");
File.WriteAllText(island, "x");
var islandAcl = new FileInfo(island).GetAccessControl();
islandAcl.SetAccessRuleProtection(true, false);
islandAcl.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
FileSystemRights.FullControl, AccessControlType.Allow));
new FileInfo(island).SetAccessControl(islandAcl);
var someWorkerFile = Directory.EnumerateFiles(workerFolder, "*.dll").First();
// ---- before: the worker token reads and writes everything (positive control) ----
WorkerView(workerSid, secrets.Values, trustList, someWorkerFile).ShouldAllBe(kv => kv.Value, "before the install, Users grants the worker token all of it");
// ---- install, and install again ----
var common = $"-InstallRoot \"{app}\" -HostServiceAccount \"{WindowsIdentity.GetCurrent().User!.Value}\" -PeCacheDir \"{cache}\" -WorkerServiceName {WorkerServiceName}";
var first = Ps(installer, $"{common} -DenyPaths \"{denyPaths}\"");
first.Exit.ShouldBe(0, first.Output);
Outcomes(first.Output).Count(o => o.Outcome == "changed").ShouldBe(10, first.Output);
first.Output.ShouldContain(island, Case.Insensitive, "the coverage audit names the protected file");
var second = Ps(installer, $"{common} -DenyPaths \"{denyPaths}\"");
second.Exit.ShouldBe(0, second.Output);
Outcomes(second.Output).ShouldAllBe(o => o.Outcome == "unchanged", second.Output);
Outcomes(second.Output).Count.ShouldBe(10);
foreach (var dir in new[] { deploy, Path.Combine(deploy, "pki"), Path.Combine(deploy, "data"), app })
ExplicitFor(dir, workerSid).ShouldHaveSingleItem().AccessControlType.ShouldBe(AccessControlType.Deny, dir);
// ---- the worker token: denied every sensitive sibling, still reaches its own binaries ----
var after = WorkerView(workerSid, secrets.Values, trustList, someWorkerFile);
foreach (var (what, file) in secrets)
after[file].ShouldBeFalse($"{what}: the worker token must not read {file}");
after[trustList].ShouldBeFalse("the worker token must not drop a file into the trust list");
after[someWorkerFile].ShouldBeTrue("the worker still reads its own binaries");
// The host account is not affected.
foreach (var file in secrets.Values) File.ReadAllText(file).ShouldBe("secret");
// ---- a real worker launched from the isolated layout still runs ----
var options = Scripts.Options(workerPath: OutOfProcessScriptRunner.ResolveWorkerEntry(workerFolder));
options.Worker.Identity = @"NT SERVICE\" + WorkerServiceName;
await using (var runner = Scripts.Runner(options))
{
runner.WorkerSid.ShouldBe(workerSid);
var outcome = await runner.RunAsync(Scripts.Vt("vt", "return (int)ctx.GetTag(\"x\").Value + 1;", inputs: Scripts.Input("x", 41)), Ct);
outcome.Status.ShouldBe(ScriptRunStatus.Ok, outcome.Reason);
outcome.Value.ShouldBe(42);
}
// ---- uninstall WITHOUT repeating -DenyPaths: the record drives it; then again ----
var un = Ps(installer, $"{common} -Uninstall");
un.Exit.ShouldBe(0, un.Output);
Outcomes(un.Output).Count(o => o.Outcome == "changed").ShouldBe(9, un.Output);
foreach (var dir in new[] { deploy, Path.Combine(deploy, "pki"), Path.Combine(deploy, "data"), app, workerFolder })
ExplicitFor(dir, workerSid).ShouldBeEmpty($"no worker ACE left on {dir}");
var un2 = Ps(installer, $"{common} -Uninstall");
un2.Exit.ShouldBe(0, un2.Output);
Outcomes(un2.Output).ShouldAllBe(o => o.Outcome == "unchanged", un2.Output);
// ---- after: back to the layout's own ACL (positive control for the negative assertions) ----
WorkerView(workerSid, secrets.Values, trustList, someWorkerFile).ShouldAllBe(kv => kv.Value, "after the uninstall the deny is gone");
}
finally
{
Run("powershell.exe", $"-NoProfile -Command \"Remove-NetFirewallRule -Name {FirewallRule} -ErrorAction SilentlyContinue\"");
Run("sc.exe", $"delete {WorkerServiceName}");
foreach (var dir in new[] { deploy, cache })
{
if (!Directory.Exists(dir)) continue;
Run("icacls.exe", $"\"{dir}\" /reset /T /C /Q");
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
}
}
}
/// <summary>For each file: can the worker token read it; for the trust-list folder: can it create a file there.</summary>
[SupportedOSPlatform("windows")]
private static Dictionary<string, bool> WorkerView(string workerSid, IEnumerable<string> files, string trustList, string workerFile)
{
var result = new Dictionary<string, bool>();
using var token = RestrictedWorkerLauncher.CreateWorkerToken(workerSid);
WindowsIdentity.RunImpersonated(token, () =>
{
foreach (var file in files.Append(workerFile)) result[file] = Try(() => File.ReadAllBytes(file));
result[trustList] = Try(() =>
{
// Creating the file is the escalation; deleting it needs rights Users never has, so the host cleans up below.
File.WriteAllText(Path.Combine(trustList, "planted-" + Guid.NewGuid().ToString("N")[..6] + ".der"), "x");
});
});
// The trust-list probe's file, if it got in: removed as the host.
foreach (var f in Directory.EnumerateFiles(trustList, "planted-*")) File.Delete(f);
return result;
static bool Try(Action a)
{
try { a(); return true; }
catch (UnauthorizedAccessException) { return false; }
}
}
[SupportedOSPlatform("windows")]
private static FileSystemAccessRule[] ExplicitFor(string dir, string sid)
=> new DirectoryInfo(dir).GetAccessControl().GetAccessRules(true, false, typeof(SecurityIdentifier))
.Cast<FileSystemAccessRule>().Where(r => r.IdentityReference.Value == sid).ToArray();
private static List<(string Outcome, string Id)> Outcomes(string output)
=> System.Text.RegularExpressions.Regex.Matches(output, @"\[(?<o>[a-z]+)\s*\]\s+(?<id>[A-Za-z]+)\s")
.Select(m => (m.Groups["o"].Value, m.Groups["id"].Value)).ToList();
private static string FindInstaller()
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "scripts", "install", "Install-ScriptWorkerIsolation.ps1");
if (File.Exists(candidate)) return candidate;
}
throw new FileNotFoundException("scripts/install/Install-ScriptWorkerIsolation.ps1 not found above the test output");
}
// Windows PowerShell 5.1, as on the wonder boxes (no pwsh there).
private static (int Exit, string Output) Ps(string script, string args)
=> Run("powershell.exe", $"-NoProfile -ExecutionPolicy Bypass -File \"{script}\" {args}");
private static (int Exit, string Output) Run(string exe, string args)
{
var psi = new ProcessStartInfo(exe, args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true };
// Windows PowerShell 5.1 started from a .NET process inherits a PSModulePath it cannot load its own modules from
// (Get-Acl: "module could not be loaded"); without it, it rebuilds the default.
psi.Environment.Remove("PSModulePath");
using var p = Process.Start(psi)!;
var stdout = p.StandardOutput.ReadToEndAsync();
var stderr = p.StandardError.ReadToEndAsync();
p.WaitForExit();
return (p.ExitCode, stdout.Result + stderr.Result);
}
private static void CopyDirectory(string from, string to)
{
Directory.CreateDirectory(to);
foreach (var dir in Directory.EnumerateDirectories(from, "*", SearchOption.AllDirectories))
Directory.CreateDirectory(Path.Combine(to, Path.GetRelativePath(from, dir)));
foreach (var file in Directory.EnumerateFiles(from, "*", SearchOption.AllDirectories))
File.Copy(file, Path.Combine(to, Path.GetRelativePath(from, file)));
}
}