diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c1677b25..9ea5f599a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 790aa2740..6d29756ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/keySetupHardening.mjs b/src/keySetupHardening.mjs index 86b77b7c8..64d807292 100644 --- a/src/keySetupHardening.mjs +++ b/src/keySetupHardening.mjs @@ -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, diff --git a/src/keySetupHardening.test.mjs b/src/keySetupHardening.test.mjs index ec7bcdffc..1ea9b3a8c 100644 --- a/src/keySetupHardening.test.mjs +++ b/src/keySetupHardening.test.mjs @@ -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', {