mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
70dca86bc1
commit
94f5e4dd45
4 files changed
+169
-2
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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', {
|
||||
|
||||
Reference in new issue
Block a user