fix(windows): isolate the ACL verifier from the PowerShell 7 module path (#508)

Supersedes #161 by @michaelhan1208, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: michaelhan1208 <[email protected]>
This commit is contained in:
Bilawal Sidhuandmichaelhan1208 authored and GitHub committed 2026-09-14 19:38:28 -05:00
1 parent 70dca86bc1
commit 94f5e4dd45
4 files changed
+169 -2

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ jobs:
run: npm run check:boundaries
- name: Run focused onboarding tests
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs
- name: Build production bundle
run: npm run build
+10
View File
@@ -263,6 +263,16 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
reported as such instead of as "already up to date", and if the revision to
apply cannot be resolved at all the update stops without installing anything
and exits unsuccessfully. Contributed by Lob26 (#356).
- On Windows, the credential-file hardening step verifies the file's permissions
through the system PowerShell. A side-by-side PowerShell 7 install prepends its
own module directories, which the 5.1 verifier cannot load, so the check failed
and the credential was refused. The verify script now sets its module path from
the running interpreter's own home, and the environment it is launched with
carries that one value and no differently cased alias of it. The tests that
cover it drive a stubbed process launcher, so what they check is the command
and environment the code builds; the Windows onboarding CI job now also runs
this file, where its one Windows-only case exercises the real hardener against
real native tools. Contributed by michaelhan1208 (#161).
- Extract panel disclosure and hover/focus controls into a reusable module;
cancel their listeners and pending work during replacement and teardown.
+32 -1
View File
@@ -9,6 +9,12 @@ import {
/** PowerShell verification for the exact owner-only Windows credential DACL. */
const WINDOWS_ACL_VERIFY_SCRIPT = [
"$ErrorActionPreference = 'Stop'",
// Load Microsoft.PowerShell.Security (Get-Acl) from the module tree of the
// interpreter that is actually running. $PSHOME is that interpreter's own
// physical directory, so this is correct even when the executable was named
// through the Sysnative bridge, and it cannot be steered by anything the
// parent environment set.
"$env:PSModulePath = Join-Path $PSHOME 'Modules'",
'$acl = Get-Acl -LiteralPath $env:GEV_ACL_FILE',
'if (-not $acl.AreAccessRulesProtected) { exit 2 }',
"$allowed = @($env:GEV_ACL_USER_SID, 'S-1-5-18', 'S-1-5-32-544')",
@@ -143,15 +149,40 @@ export function hardenCredentialFile(filepath, {
// accept only three explicit FullControl allow principals, with inheritance
// disabled. Any unexpected rule, right, command error, or missing principal
// fails closed before the secret reaches disk.
//
// The verify process must load Microsoft.PowerShell.Security (Get-Acl)
// from the Windows PowerShell system module tree ONLY. A side-by-side
// PowerShell 7 install prepends its own module trees to PSModulePath at
// startup; inherited into a 5.1 process, the incompatible 7.x manifest
// cannot be autoloaded and the verify step fails. The script itself sets
// the path from $PSHOME; this is the same value computed ahead of time, so
// nothing inherited is in force even for the moment before it runs. The
// Sysnative spelling is a 32-bit caller's bridge and not a directory the
// launched native process can read, so the physical name is used.
const powershellModuleDirectory = path.win32.join(
path.win32
.dirname(tools.powershell)
.replace(/\\Sysnative\\/i, '\\System32\\'),
'Modules',
);
// Windows environment names are case-insensitive, and a child can end up
// carrying a differently cased alias alongside the value set here. Drop
// every spelling before setting the trusted one.
const verifyEnvironment = Object.fromEntries(
Object.entries(environment).filter(
([name]) => name.toLowerCase() !== 'psmodulepath',
),
);
const verified = spawn(tools.powershell, [
'-NoProfile',
'-NonInteractive',
'-Command', WINDOWS_ACL_VERIFY_SCRIPT,
], {
env: {
...environment,
...verifyEnvironment,
GEV_ACL_FILE: filepath,
GEV_ACL_USER_SID: sid,
PSModulePath: powershellModuleDirectory,
},
stdio: 'ignore',
windowsHide: true,
+126
View File
@@ -126,6 +126,132 @@ test('Windows hardening applies and then verifies the exact restricted DACL', ()
assert.match(calls[2].args.at(-1), /seen\.Count -ne 3/);
});
test('Windows hardening isolates the verify PowerShell from a pwsh7-polluted PSModulePath', () => {
const calls = [];
// Exactly what a side-by-side PowerShell 7 install prepends at startup:
// its own user Documents\PowerShell, Program Files\PowerShell, and
// Program Files\PowerShell\7 module trees all shadow the 5.1 system path.
const pollutedModulePath = [
'C:\\Users\\alice\\Documents\\PowerShell\\Modules',
'C:\\Program Files\\PowerShell\\Modules',
'c:\\program files\\powershell\\7\\Modules',
'C:\\Program Files\\WindowsPowerShell\\Modules',
'C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules',
].join(';');
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
environment: {
SYSTEMROOT: WINDOWS_ROOT,
PSModulePath: pollutedModulePath,
},
fileSystem: windowsFileSystem(),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"\r\n` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
assert.ok(verify, 'the ACL verify powershell must be spawned');
const modulePath = verify.options.env.PSModulePath;
// Fail closed: the verification interpreter must never inherit pwsh7's
// module trees — a 7.x Microsoft.PowerShell.Security manifest cannot be
// autoloaded by 5.1, and an untrusted tree must not shadow Get-Acl at all.
assert.ok(!/Documents\\PowerShell\\Modules/i.test(modulePath), 'user pwsh7 module tree must be stripped');
assert.ok(!/Program Files\\PowerShell\\Modules/i.test(modulePath), 'Program Files pwsh7 module tree must be stripped');
assert.ok(!/Program Files\\PowerShell\\7\\Modules/i.test(modulePath), 'pwsh7 install module tree must be stripped');
// ... but the 5.1 system module directory (where Get-Acl lives) must remain.
assert.match(modulePath, /System32\\WindowsPowerShell\\v1\.0\\Modules/i);
});
test('the verify script takes its module path from the running interpreter', () => {
const calls = [];
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
environment: { SYSTEMROOT: WINDOWS_ROOT },
fileSystem: windowsFileSystem(),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
const script = verify.args.at(-1);
// $PSHOME is the interpreter's own physical directory, so this holds even
// where the executable was named through a bridge path.
assert.match(script, /\$env:PSModulePath = Join-Path \$PSHOME 'Modules'/);
// ...and it is the first thing the script does, before Get-Acl is resolved.
assert.ok(
script.indexOf('PSModulePath') < script.indexOf('Get-Acl'),
'the module path must be set before Get-Acl is invoked',
);
});
test('a 32-bit caller passes the physical module directory, not the Sysnative bridge', () => {
const calls = [];
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
architecture: 'ia32',
environment: { SYSTEMROOT: WINDOWS_ROOT },
fileSystem: windowsFileSystem({ systemDirectory: 'Sysnative' }),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
// The executable is still named through the bridge the 32-bit caller needs.
assert.match(verify.command, /\\Sysnative\\/);
// The module directory is not: Sysnative is not a directory the launched
// native process can read.
assert.doesNotMatch(verify.options.env.PSModulePath, /Sysnative/i);
assert.match(
verify.options.env.PSModulePath,
/^C:\\Windows\\System32\\WindowsPowerShell\\v1\.0\\Modules$/i,
);
});
test('differently cased PSModulePath aliases do not survive into the verify process', () => {
const calls = [];
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
environment: {
SYSTEMROOT: WINDOWS_ROOT,
// Windows environment names are case-insensitive; a child can keep a
// differently cased alias alongside the value set for it.
PSMODULEPATH: 'C:\\Program Files\\PowerShell\\7\\Modules',
psmodulepath: 'C:\\Users\\alice\\Documents\\PowerShell\\Modules',
PsModulePath: 'C:\\attacker\\Modules',
},
fileSystem: windowsFileSystem(),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
const aliases = Object.keys(verify.options.env).filter(
(name) => name.toLowerCase() === 'psmodulepath',
);
assert.deepEqual(aliases, ['PSModulePath'], 'exactly one spelling may reach the child');
assert.doesNotMatch(verify.options.env.PSModulePath, /PowerShell\\7|Documents|attacker/i);
});
test('Windows hardening bypasses PATH-shadowed native ACL tools', () => {
const commands = [];
const result = hardenCredentialFile('D:\\GEV\\ENVIRONMENT.tmp', {