fix(scripting): ScriptWorker isolation denies the worker every sensitive sibling, not just app\ (#890)
v2-ci / unit-tests (push) Waiting to run
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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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><deploy>\{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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user