diff --git a/.github/actions/prepare-native-runtime/action.yml b/.github/actions/prepare-native-runtime/action.yml index ae56feeb68e..64712b2aae7 100644 --- a/.github/actions/prepare-native-runtime/action.yml +++ b/.github/actions/prepare-native-runtime/action.yml @@ -54,7 +54,7 @@ runs: env: NATIVE_RUNTIME: ${{ inputs.native-runtime }} NODE_VERSION: ${{ inputs.node-version }} - NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js') }} + NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }} run: | scope="${ImageOS:-$RUNNER_OS}" if [ -r /etc/os-release ]; then @@ -69,6 +69,7 @@ runs: path< ${output}`) + return output +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + const arch = + process.argv.find((arg) => arg.startsWith('--arch='))?.slice('--arch='.length) ?? process.arch + buildWindowsSecurityAddon(arch) + } catch (error) { + console.error(`[windows-security] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) + } +} diff --git a/config/scripts/check-main-sync-spawn-ratchet.mjs b/config/scripts/check-main-sync-spawn-ratchet.mjs index 1b8a7ace326..33b213d7d44 100644 --- a/config/scripts/check-main-sync-spawn-ratchet.mjs +++ b/config/scripts/check-main-sync-spawn-ratchet.mjs @@ -19,13 +19,7 @@ const ROOT = path.resolve(import.meta.dirname, '..', '..') const BASELINE_PATH = 'config/main-sync-spawn-baseline.txt' const SCAN_DIRECTORIES = ['src/main', 'src/shared'] const PROCESS_ADVICE = 'use runProcess or spawnProcess from @orca/process-host' -const SECURE_ADVICE = `no async secure writer exists yet; keep the synchronous one and raise this file's count in ${BASELINE_PATH} (or add a row), never a plain write (it loses the Windows ACL hardening)` -const SECURE_WRITERS = [ - 'writeSecureFile', - 'writeSecureJsonFile', - 'writeDurableSecureJsonFile', - 'writeSecureJsonFileWithinLimit' -] +const SECURE_ADVICE = `rewrites a DACL on the calling thread, and a directory rewrite blocks on its whole subtree; use restrictWindowsDirectory for a directory, or raise this file's count in ${BASELINE_PATH} (or add a row)` // Gated modules (package names, or repo-relative paths without extension, re-exporters included) // and the advice for each gated function they export. const GATED_FAMILIES = [ @@ -34,12 +28,6 @@ const GATED_FAMILIES = [ advice: { execFileSync: PROCESS_ADVICE, spawnSync: PROCESS_ADVICE, execSync: PROCESS_ADVICE } }, { modules: ['@orca/process-host'], advice: { runProcessSync: PROCESS_ADVICE } }, - { - modules: ['src/shared/secure-file', 'src/shared/bounded-secure-json-file'], - advice: Object.fromEntries( - [...SECURE_WRITERS, 'hardenSecurePath'].map((name) => [name, SECURE_ADVICE]) - ) - }, { modules: ['src/shared/secure-path-windows-acl'], advice: { restrictWindowsPathSync: SECURE_ADVICE } @@ -70,13 +58,12 @@ const GATED_MARKERS = [ GATED_FAMILIES.flatMap((family) => family.modules.map((module) => path.posix.basename(module))) ) ] -// Only spread-free calls with no options or a literal with absent/false `sync` are exempt. -const SYNC_FLAG_SYMBOLS = new Set(['hardenSecurePath']) const FALLBACK_OPERATORS = new Set([ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken]) const BASELINE_HEADER = [ '# Synchronous child-process launches in production src/main and src/shared, per file: uses of', '# execFileSync/spawnSync/execSync, runProcessSync and the repo sync wrappers listed in', - '# config/scripts/check-main-sync-spawn-ratchet.mjs (secure writers run icacls on Windows).', + '# config/scripts/check-main-sync-spawn-ratchet.mjs (synchronous ACL rewrites and grants, which', + '# can block on a whole directory subtree).', '# Some entries run in the daemon or orcad process, or are test harnesses outside the test-path', '# conventions. This is a RATCHET: counts may only go DOWN.', '# Prune after removing launches: pnpm check:main-sync-spawn-ratchet --prune' @@ -112,36 +99,6 @@ export function gatedSymbolsFor(file, specifier) { ) } -function isAsyncOnlyCall(callee) { - const call = callee.parent - if ( - !ts.isCallExpression(call) || - call.expression !== callee || - call.arguments.some(ts.isSpreadElement) - ) { - return false - } - const options = unwrap(call.arguments[1]) - return ( - !options || - (ts.isObjectLiteralExpression(options) && - options.properties.every((property) => { - if (ts.isSpreadAssignment(property)) { - return false - } - const name = ts.isIdentifier(property.name) - ? property.name.text - : literalString(property.name) - return ( - name !== null && - (name !== 'sync' || - (ts.isPropertyAssignment(property) && - unwrap(property.initializer).kind === ts.SyntaxKind.FalseKeyword)) - ) - })) - ) -} - function isValueReference(node) { const parent = node.parent if ( @@ -227,10 +184,7 @@ export function collectSyncLaunchUses(file, contents) { } return null } - function recordUse(name, node) { - if (SYNC_FLAG_SYMBOLS.has(name) && isAsyncOnlyCall(node)) { - return - } + function recordUse(name) { used.add(name) count += 1 } @@ -339,13 +293,13 @@ export function collectSyncLaunchUses(file, contents) { for (const node of members) { const name = propertyName(node) if (moduleSymbols(node.expression)?.has(name)) { - recordUse(name, node) + recordUse(name) } } for (const node of identifiers) { const name = functions.get(node.text) if (name) { - recordUse(name, node) + recordUse(name) } } return { count, symbols: [...used].sort() } diff --git a/config/scripts/check-main-sync-spawn-ratchet.test.mjs b/config/scripts/check-main-sync-spawn-ratchet.test.mjs index de957aa6b5c..5602b2e975a 100644 --- a/config/scripts/check-main-sync-spawn-ratchet.test.mjs +++ b/config/scripts/check-main-sync-spawn-ratchet.test.mjs @@ -86,14 +86,14 @@ describe('main sync-spawn ratchet', () => { ['runProcessSync'] ], [ - "import { writeDurableSecureJsonFile, writeSecureJsonFile } from '../shared/secure-file.ts'\nconst write = durable ? writeDurableSecureJsonFile : writeSecureJsonFile\nwrite(p, v)", + "import { getWslHome, listWslDistros } from './wsl.ts'\nconst read = sync ? getWslHome : listWslDistros\nread()", 3, - ['writeDurableSecureJsonFile', 'writeSecureJsonFile'] + ['getWslHome', 'listWslDistros'] ], [ - "import * as store from '..\\\\shared\\\\bounded-secure-json-file'\nstore.writeSecureJsonFileWithinLimit(p, v, 1)", + "import * as acl from '..\\\\shared\\\\secure-path-windows-acl'\nacl.restrictWindowsPathSync(p, true)", 1, - ['writeSecureJsonFileWithinLimit'] + ['restrictWindowsPathSync'] ], [ "const { execSync } = require('child_process')\nconst run = execSync\nrun('a')", @@ -165,35 +165,6 @@ describe('main sync-spawn ratchet', () => { expect(uses(source)).toEqual({ count: 1, symbols: [symbol] }) }) - it('counts hardenSecurePath unless the call is visibly async', () => { - const imported = "import { hardenSecurePath } from '../shared/secure-file'\n" - const count = (call) => uses(`${imported}${call}`).count - expect(count('hardenSecurePath(p, { isDirectory: true, sync: true })')).toBe(1) - expect(count("hardenSecurePath(p, { isDirectory: true, 'sync': true })")).toBe(1) - expect(count('hardenSecurePath(p, options)')).toBe(1) - expect(count('hardenSecurePath(p, { ...base, isDirectory: true })')).toBe(1) - expect(count('hardenSecurePath(p, { isDirectory: true, sync })')).toBe(1) - expect(count('hardenSecurePath(p, { sync: true } as const)')).toBe(1) - expect(count('tasks.map(hardenSecurePath)')).toBe(1) - expect(count('hardenSecurePath(p, { isDirectory: true })')).toBe(0) - expect(count('hardenSecurePath(p, { isDirectory: true, sync: false })')).toBe(0) - expect(count('hardenSecurePath(p)')).toBe(0) - expect( - uses("import * as sf from '../shared/secure-file'\nsf.hardenSecurePath(p, { sync: true })") - ).toEqual({ count: 1, symbols: ['hardenSecurePath'] }) - }) - - it.each([ - 'const args = [p, { isDirectory: false, platform: "win32", sync: true }] as const\nhardenSecurePath(...args)', - 'const options = [{ isDirectory: false, platform: "win32", sync: true }] as const\nhardenSecurePath(p, ...options)', - 'hardenSecurePath(...paths, { isDirectory: true, sync: false })' - ])('counts hardenSecurePath with spread call arguments: %s', (source) => { - expect(uses(`import { hardenSecurePath } from '../shared/secure-file'\n${source}`)).toEqual({ - count: 1, - symbols: ['hardenSecurePath'] - }) - }) - it('counts re-exports in the re-exporting module and internal calls in the defining one', () => { expect( uses( @@ -209,15 +180,15 @@ describe('main sync-spawn ratchet', () => { ).toEqual({ count: 1, symbols: ['listWslDistros'] }) }) - it('resolves writer imports by the module they point at, not by name', () => { + it('resolves imports by the module they point at, not by name', () => { expect( uses( - "import { writeSecureFile } from './secure-file'\nwriteSecureFile(p, v)", + "import { restrictWindowsPathSync } from './secure-path-windows-acl'\nrestrictWindowsPathSync(p, true)", 'src/shared/store.ts' ) ).toEqual({ count: 1, - symbols: ['writeSecureFile'] + symbols: ['restrictWindowsPathSync'] }) }) @@ -259,10 +230,9 @@ describe('main sync-spawn ratchet', () => { expect(adviceFor(['execFileSync', 'runProcessSync'])).toBe( 'execFileSync, runProcessSync: use runProcess or spawnProcess from @orca/process-host.' ) - expect(adviceFor(['writeSecureFile'])).toContain( - "no async secure writer exists yet; keep the synchronous one and raise this file's count in config/main-sync-spawn-baseline.txt" + expect(adviceFor(['restrictWindowsPathSync'])).toContain( + "use restrictWindowsDirectory for a directory, or raise this file's count in config/main-sync-spawn-baseline.txt" ) - expect(adviceFor(['restrictWindowsPathSync'])).toContain('never a plain write') expect(adviceFor(['gitExecFileSync', 'grantDirAcl'])).toBe( 'gitExecFileSync: use gitExecFileAsync. grantDirAcl: use grantDirAclAsync.' ) @@ -290,12 +260,13 @@ describe('main sync-spawn ratchet', () => { }) it('fails a lowered or stale count until it is pruned, and prune never raises', () => { - const write = "import { writeSecureFile } from '../shared/secure-file'\nwriteSecureFile(a, b)\n" + const write = + "import { restrictWindowsPathSync } from '../shared/secure-path-windows-acl'\nrestrictWindowsPathSync(a, b)\n" const root = tree({ 'src/main/store.ts': write, 'src/main/clean.ts': 'export const done = true\n', 'src/main/new.ts': write, - 'src/main/grown.ts': `${write}writeSecureFile(c, d)\n`, + 'src/main/grown.ts': `${write}restrictWindowsPathSync(c, d)\n`, [BASELINE]: '# old header\n3 src/main/store.ts # tracked in #26754\n1 src/main/clean.ts\n1 src/main/grown.ts\n' }) @@ -304,7 +275,7 @@ describe('main sync-spawn ratchet', () => { const lines = error.mock.calls.map(([line]) => line) expect(lines[0]).toContain('src/main/grown.ts::2 synchronous launch use(s), baseline allows 1.') expect(lines[1]).toContain('src/main/new.ts::1 synchronous launch use(s), baseline allows 0.') - expect(lines[1]).toContain('writeSecureFile: no async secure writer exists yet') + expect(lines[1]).toContain('restrictWindowsPathSync: rewrites a DACL on the calling thread') expect(lines.slice(2)).toEqual([ '::error file=src/main/clean.ts::0 synchronous launch use(s), baseline still allows 1. Run: pnpm check:main-sync-spawn-ratchet --prune', '::error file=src/main/store.ts::1 synchronous launch use(s), baseline still allows 3. Run: pnpm check:main-sync-spawn-ratchet --prune' diff --git a/config/scripts/ci-native-cache.test.mjs b/config/scripts/ci-native-cache.test.mjs index 91a00225079..91c51b7919e 100644 --- a/config/scripts/ci-native-cache.test.mjs +++ b/config/scripts/ci-native-cache.test.mjs @@ -15,6 +15,7 @@ const inputs = [...identity.env.NATIVE_SOURCE_HASH.matchAll(/'([^']+)'/g)].map(( const paths = [ 'node_modules/.pnpm/node-pty@*/node_modules/node-pty/build', 'native/windows-registry/build', + 'native/windows-security/build', 'node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build' ] diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index d57809ad58a..f08898ea94a 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -627,11 +627,22 @@ describe('arch-aware packaging guard', () => { expect(() => packHost(OTHER_ARCH)).toThrow(otherSherpa) expect(() => packHost(OTHER_ARCH)).toThrow('pnpm install:release') expect(() => packHost(HOST_ARCH)).not.toThrow() - } else { + } else if (process.platform !== 'win32') { expect(() => packHost(OTHER_ARCH)).not.toThrow() } }) + // The host's own build is the foreign-arch case: a Windows checkout compiles these for its CPU only. + it.runIf(process.platform === 'win32')( + 'refuses a Windows workspace addon built for another architecture', + () => { + for (const binary of ['orca_windows_registry.node', 'orca_windows_security.node']) { + expect(() => packHost(OTHER_ARCH)).toThrow(`${binary} for ${OTHER_ARCH_NAME}`) + } + expect(() => packHost(HOST_ARCH)).not.toThrow() + } + ) + it('requires installed Windows addons for Windows packaging', () => { const windowsAddon = electronBuilderConfig.win.extraResources.some( (resource) => resource.to === join('node_modules', '@vscode', 'windows-process-tree') @@ -643,6 +654,8 @@ describe('arch-aware packaging guard', () => { } else { expect(packWindows).toThrow('@vscode/windows-process-tree') expect(packWindows).toThrow('Windows packaging requires a Windows host') + // A workspace link exists everywhere; only its compiled binary proves the addon will load. + expect(packWindows).toThrow('@orca/windows-security') } }) }) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 5f0e44998bc..78251f8d6a4 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -27,7 +27,7 @@ const runtime = readRuntimeArg() const NATIVE_MODULES = [ 'node-pty', ...(process.platform === 'win32' - ? ['@orca/windows-registry', '@vscode/windows-process-tree'] + ? ['@orca/windows-registry', '@orca/windows-security', '@vscode/windows-process-tree'] : []) ] const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe'] @@ -286,6 +286,11 @@ function loadNativeModule(moduleName) { registry.getRegistryKey(registry.HK.CU, 'Environment') return } + if (moduleName === '@orca/windows-security') { + // Why: the package defers loading its .node addon until the first call. + require(moduleName).currentUserSid() + return + } if (moduleName === 'node-pty') { loadNodePtyNativeModule() return diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index c65706638c9..ab55fb8811c 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -415,6 +415,9 @@ function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) { join(processTreeDir, 'index.js'), 'exports.supportedProcessDataFlags = 4; exports.getProcessCreationTime = () => 1\n' ) + const securityDir = join(projectDir, 'node_modules', '@orca', 'windows-security') + mkdirSync(securityDir, { recursive: true }) + writeFileSync(join(securityDir, 'index.js'), "exports.currentUserSid = () => 'S-1-5-18'\n") } function writeNodePtyPatchFile(projectDir) { diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 3b87ba046b4..641ef054c07 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -15,6 +15,7 @@ const BUILD_SCRIPTS = [ 'config/scripts/orcad-prebuild-smoke-child.cjs', 'config/scripts/build-windows-process-tree-addon.mjs', 'config/scripts/build-windows-cli-launcher.mjs', + 'config/scripts/build-windows-security-addon.mjs', 'config/scripts/run-node-server-tests.mjs', 'config/vitest.config.ts', 'config/scripts/happy-dom-offscreen-canvas.ts', diff --git a/config/scripts/orcad-windows-security.mjs b/config/scripts/orcad-windows-security.mjs new file mode 100644 index 00000000000..cdd2b938201 --- /dev/null +++ b/config/scripts/orcad-windows-security.mjs @@ -0,0 +1,55 @@ +import { copyFileSync, existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { runProcessSync } from '@orca/process-host' +import { ORCAD_WINDOWS_SECURITY_FILENAME } from '../../src/shared/orcad-artifacts.ts' +import { windowsSecurityAddonOutput } from './build-windows-security-addon.mjs' + +const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)( + './windows-pe-machine.cjs' +) + +/** + * A Windows slot's ACL addon. Required, not optional: orcad's data-root privacy check fails + * closed without it, so a slot missing it would never start on Windows. + */ +export function stageOrcadWindowsSecurity( + root, + outputDir, + target, + host = { platform: process.platform }, + compile = compileOrcadWindowsSecurity +) { + if (!target.startsWith('win32-')) { + return + } + const arch = target.slice('win32-'.length) + const source = windowsSecurityAddonOutput(root, arch) + if (!existsSync(source)) { + if (host.platform !== 'win32') { + throw new Error( + `Orcad ${target} requires the Windows security addon. On Windows, run: ` + + `node config/scripts/build-windows-security-addon.mjs --arch=${arch}` + ) + } + compile(root, arch) + } + const machine = readPeMachine(source) + if (machine !== PE_MACHINE[arch]) { + throw new Error(`Orcad ${target} Windows security addon has ${describePeMachine(machine)}`) + } + copyFileSync(source, join(outputDir, ORCAD_WINDOWS_SECURITY_FILENAME)) +} + +function compileOrcadWindowsSecurity(root, arch) { + const result = runProcessSync({ + program: process.execPath, + args: [join(root, 'config/scripts/build-windows-security-addon.mjs'), `--arch=${arch}`], + cwd: root, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + throw new Error(`Windows security addon build for ${arch} failed with exit ${result.code}`) + } +} diff --git a/config/scripts/orcad-windows-security.test.mjs b/config/scripts/orcad-windows-security.test.mjs new file mode 100644 index 00000000000..3965c051307 --- /dev/null +++ b/config/scripts/orcad-windows-security.test.mjs @@ -0,0 +1,69 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { windowsSecurityAddonOutput } from './build-windows-security-addon.mjs' +import { stageOrcadWindowsSecurity } from './orcad-windows-security.mjs' + +const roots = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function peBytes(machine) { + const bytes = Buffer.alloc(0x90) + bytes.write('MZ') + bytes.writeUInt32LE(0x80, 0x3c) + bytes.write('PE\0\0', 0x80) + bytes.writeUInt16LE(machine, 0x84) + return bytes +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orcad-windows-security-')) + roots.push(root) + const output = join(root, 'output') + mkdirSync(output) + const write = (arch, machine) => { + const source = windowsSecurityAddonOutput(root, arch) + mkdirSync(dirname(source), { recursive: true }) + writeFileSync(source, peBytes(machine)) + return source + } + return { root, output, write } +} + +it('stages the addon for each Windows architecture after checking its machine', () => { + const { root, output, write } = fixture() + const x64 = write('x64', 0x8664) + stageOrcadWindowsSecurity(root, output, 'win32-x64') + expect(readFileSync(join(output, 'windows-security.node'))).toEqual(readFileSync(x64)) + + const arm64 = write('arm64', 0xaa64) + stageOrcadWindowsSecurity(root, output, 'win32-arm64') + expect(readFileSync(join(output, 'windows-security.node'))).toEqual(readFileSync(arm64)) +}) + +it('rejects a binary built for the wrong architecture', () => { + const { root, output, write } = fixture() + write('arm64', 0x8664) + expect(() => stageOrcadWindowsSecurity(root, output, 'win32-arm64')).toThrow('machine 0x8664') + expect(existsSync(join(output, 'windows-security.node'))).toBe(false) +}) + +it('fails the build off Windows when the addon was never built, and compiles it on Windows', () => { + const { root, output, write } = fixture() + expect(() => stageOrcadWindowsSecurity(root, output, 'win32-x64', { platform: 'linux' })).toThrow( + 'build-windows-security-addon.mjs --arch=x64' + ) + + const compile = vi.fn((compileRoot, arch) => write(arch, 0x8664)) + stageOrcadWindowsSecurity(root, output, 'win32-x64', { platform: 'win32' }, compile) + expect(compile).toHaveBeenCalledWith(root, 'x64') + expect(existsSync(join(output, 'windows-security.node'))).toBe(true) +}) + +it('stages nothing for non-Windows slots', () => { + const { root, output } = fixture() + stageOrcadWindowsSecurity(root, output, 'linux-x64-glibc', { platform: 'linux' }) + stageOrcadWindowsSecurity(root, output, 'darwin-arm64', { platform: 'darwin' }) + expect(existsSync(join(output, 'windows-security.node'))).toBe(false) +}) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index b93094901a0..0aca09ce39e 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -45,6 +45,26 @@ describe('Electron runtime package contract', () => { } }) + // Without it the secure-file writes report unrestricted and orcad's privacy check fails closed. + it('ships the native Windows ACL addon on Windows only, built by the Windows rebuild', () => { + expect(packageJson.optionalDependencies['@orca/windows-security']).toBe('workspace:*') + expect(pnpmWorkspace.allowBuilds['@orca/windows-security']).toBe(false) + if (windowsAddonsInstalled) { + expect(packageTargets.win32).toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: join('node_modules', '@orca', 'windows-security') }) + ]) + ) + } + for (const platform of ['darwin', 'linux']) { + expect(packageTargets[platform]).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: join('node_modules', '@orca', 'windows-security') }) + ]) + ) + } + }) + it('keeps the native Windows process-table addon optional and platform-gated', () => { expect(packageJson.optionalDependencies['@vscode/windows-process-tree']).toBe('0.8.0') // Why: same rule as the registry addon -- allowBuilds stops pnpm running node-gyp at diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index bfea74cdf9e..dcf7ffde74a 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -296,16 +296,15 @@ const NATIVE_CACHE_FILES = new Set([ 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', - 'config/scripts/space-sharing-copy.mjs', - 'native/windows-registry/src/addon.cc', - 'native/windows-registry/binding.gyp', - 'native/windows-registry/package.json', - 'native/windows-registry/index.js' + 'config/scripts/space-sharing-copy.mjs' ]) const NATIVE_CACHE_PREFIXES = [ 'config/patches/node-pty@', - 'config/patches/@vscode__windows-process-tree' + 'config/patches/@vscode__windows-process-tree', + // The vendored addons' sources: build/ and bin/ beside them are ignored, so nothing else lands here. + 'native/windows-registry/', + 'native/windows-security/' ] const SHARED_PACKAGE_PREFIXES = [ @@ -386,6 +385,7 @@ const WINDOWS_PACKAGE_TESTS = [ 'config/scripts/verify-packaged-windows-agent-hook.test.mjs', 'config/scripts/script-module-dependencies.test.mjs', 'src/main/windows-registry-addon.test.ts', + 'src/shared/windows-security-addon.win32.test.ts', 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts', 'src/shared/__tests__/process-host/windows-command-line.win32.test.ts', diff --git a/config/scripts/rebuild-native-deps-node-pty.test.mjs b/config/scripts/rebuild-native-deps-node-pty.test.mjs index d1153c5f20f..7566ae58d49 100644 --- a/config/scripts/rebuild-native-deps-node-pty.test.mjs +++ b/config/scripts/rebuild-native-deps-node-pty.test.mjs @@ -16,6 +16,7 @@ import { writeFakeWindowsProcessTree, writeFakeWindowsProcessTreeWithNodeAddonApi, writeFakeWindowsRegistry, + writeFakeWindowsSecurity, writeNodePtyPatchFile, writePatchedNodePtyBuildArtifacts, writeWindowsProcessTreePatchFile @@ -33,6 +34,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) writeFakeLoadableNodePty(projectDir) writeFakeWindowsProcessTree(projectDir) + writeFakeWindowsSecurity(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) const env = { @@ -79,6 +81,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) writeFakeWindowsRegistry(projectDir) writeFakeWindowsProcessTree(projectDir) + writeFakeWindowsSecurity(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) const result = runRebuildScript(projectDir, { @@ -276,6 +279,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) writeFakeLoadableNodePty(projectDir) writeFakeWindowsProcessTree(projectDir) + writeFakeWindowsSecurity(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) const result = runRebuildScript(projectDir, { @@ -306,6 +310,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false }) writeFakeWindowsRegistry(projectDir) writeFakeWindowsProcessTree(projectDir) + writeFakeWindowsSecurity(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) const result = runRebuildScript(projectDir, { @@ -340,6 +345,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeLoadableNodePty(projectDir, { cygwinBreakawayDenied: false }) writeFakeWindowsRegistry(projectDir) writeFakeWindowsProcessTree(projectDir) + writeFakeWindowsSecurity(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) writeFakeNodePtyConptySource(projectDir) diff --git a/config/scripts/rebuild-native-deps-test-fixtures.mjs b/config/scripts/rebuild-native-deps-test-fixtures.mjs index 83223b63042..81d6202a18e 100644 --- a/config/scripts/rebuild-native-deps-test-fixtures.mjs +++ b/config/scripts/rebuild-native-deps-test-fixtures.mjs @@ -431,6 +431,12 @@ export function writeFakeWindowsRegistry(projectDir) { ) } +export function writeFakeWindowsSecurity(projectDir) { + const securityDir = join(projectDir, 'node_modules', '@orca', 'windows-security') + mkdirSync(securityDir, { recursive: true }) + writeFileSync(join(securityDir, 'index.js'), "exports.currentUserSid = () => 'S-1-5-18'\n") +} + /** * A healthy one: the addon reports CreationTime, which is what a build of the * patched source does and what the probe has required since the creation-time diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index f0f6a5352aa..ac9cf10bc96 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -84,7 +84,9 @@ if (ignoreModules.length > 0) { const NATIVE_MODULES = [ 'node-pty', 'cpu-features', - ...(rebuildPlatform === 'win32' ? ['@orca/windows-registry', '@vscode/windows-process-tree'] : []) + ...(rebuildPlatform === 'win32' + ? ['@orca/windows-registry', '@orca/windows-security', '@vscode/windows-process-tree'] + : []) ] const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) /** Whether this rebuild targets something other than the machine running it. */ @@ -608,6 +610,11 @@ function loadNativeModule(moduleName) { registry.getRegistryKey(registry.HK.CU, 'Environment') return } + if (moduleName === '@orca/windows-security') { + // Why: the package defers loading its .node addon until the first call. + projectRequire(moduleName).currentUserSid() + return + } if (moduleName === 'node-pty') { projectRequire('node-pty') const { assertNodePtyJobOwnership, nodePtyAddonPath } = projectRequire( diff --git a/native/windows-security/binding.gyp b/native/windows-security/binding.gyp new file mode 100644 index 00000000000..7379d0a81ff --- /dev/null +++ b/native/windows-security/binding.gyp @@ -0,0 +1,17 @@ +{ + "targets": [ + { + "target_name": "orca_windows_security", + "sources": ["src/addon.cc"], + "libraries": ["advapi32.lib"], + "include_dirs": [" + +/** Creates a NEW file already carrying the DACL in `sddl`, then writes `data` into it. */ +export declare function createFileWithDacl(path: string, sddl: string, data: Uint8Array): void + +/** + * Adds an allow rule for `sid` to the existing DACL, keeping its protection state; Windows orders + * the result canonically. A NULL DACL already allows everyone, so it is left untouched. + */ +export declare function grantAccess( + path: string, + sid: string, + accessMask: number, + options: GrantAccessOptions +): void +export declare function grantAccessAsync( + path: string, + sid: string, + accessMask: number, + options: GrantAccessOptions +): Promise diff --git a/native/windows-security/index.js b/native/windows-security/index.js new file mode 100644 index 00000000000..b179bf96a2d --- /dev/null +++ b/native/windows-security/index.js @@ -0,0 +1,22 @@ +'use strict' + +// Why lazy: non-Windows installs never build the addon, so requiring it at module load would +// break any import of this package on macOS/Linux — including test collection. +let addon = null +function getAddon() { + if (!addon) { + addon = require('./build/Release/orca_windows_security.node') + } + return addon +} + +module.exports = { + currentUserSid: () => getAddon().currentUserSid(), + getDaclAces: (path) => getAddon().getDaclAces(path), + setDaclSddl: (path, sddl) => getAddon().setDaclSddl(path, sddl), + setDaclSddlAsync: (path, sddl) => getAddon().setDaclSddlAsync(path, sddl), + createFileWithDacl: (path, sddl, data) => getAddon().createFileWithDacl(path, sddl, data), + grantAccess: (path, sid, mask, options) => getAddon().grantAccess(path, sid, mask, options), + grantAccessAsync: (path, sid, mask, options) => + getAddon().grantAccessAsync(path, sid, mask, options) +} diff --git a/native/windows-security/package.json b/native/windows-security/package.json new file mode 100644 index 00000000000..1e92a1e3406 --- /dev/null +++ b/native/windows-security/package.json @@ -0,0 +1,21 @@ +{ + "name": "@orca/windows-security", + "version": "1.0.0", + "private": true, + "description": "In-process Windows file DACL reads and writes for Orca", + "license": "MIT", + "files": [ + "index.js", + "index.d.ts", + "binding.gyp", + "src" + ], + "os": [ + "win32" + ], + "main": "index.js", + "types": "index.d.ts", + "dependencies": { + "node-addon-api": "^8.5.0" + } +} diff --git a/native/windows-security/src/addon.cc b/native/windows-security/src/addon.cc new file mode 100644 index 00000000000..efa0cd268c4 --- /dev/null +++ b/native/windows-security/src/addon.cc @@ -0,0 +1,407 @@ +// In-process Windows DACL reads and writes for Orca's secure files. +// +// Why native: the alternative is spawning icacls.exe, which blocked the main thread for seconds +// per write on hosts with slow process creation (#26027), and whose `/reset` + `/inheritance:r` +// shape behavioural EDR scores as ransomware (#25583). Policy (which SIDs, which flags) lives in +// JS; this file writes DACLs given as SDDL and reads them back as plain structure. + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +struct Win32Result { + DWORD code = ERROR_SUCCESS; + const char* syscall = ""; + bool ok() const { return code == ERROR_SUCCESS; } +}; + +Win32Result Fail(const char* syscall, DWORD code) { return Win32Result{code, syscall}; } + +struct LocalDeleter { + void operator()(void* pointer) const { LocalFree(pointer); } +}; +using LocalPtr = std::unique_ptr; + +Napi::Error ToError(Napi::Env env, const Win32Result& result) { + return Napi::Error::New( + env, std::string(result.syscall) + " failed with Win32 error " + std::to_string(result.code)); +} + +Win32Result ParseSddl(const std::u16string& sddl, LocalPtr* descriptor) { + PSECURITY_DESCRIPTOR raw = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + reinterpret_cast(sddl.c_str()), SDDL_REVISION_1, &raw, nullptr)) { + return Fail("ConvertStringSecurityDescriptorToSecurityDescriptorW", GetLastError()); + } + descriptor->reset(raw); + return {}; +} + +// The descriptor's own protection bit decides: a replacement DACL carries the caller's choice, and +// a grant re-reads the object's, so adding one rule never flips inheritance on or off. +Win32Result DaclInformationFor(PSECURITY_DESCRIPTOR descriptor, SECURITY_INFORMATION* information) { + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + if (!GetSecurityDescriptorControl(descriptor, &control, &revision)) { + return Fail("GetSecurityDescriptorControl", GetLastError()); + } + *information = DACL_SECURITY_INFORMATION | ((control & SE_DACL_PROTECTED) + ? PROTECTED_DACL_SECURITY_INFORMATION + : UNPROTECTED_DACL_SECURITY_INFORMATION); + return {}; +} + +Win32Result ApplyDacl(std::u16string path, const std::u16string& sddl) { + LocalPtr descriptor; + auto parsed = ParseSddl(sddl, &descriptor); + if (!parsed.ok()) { + return parsed; + } + BOOL present = FALSE; + BOOL defaulted = FALSE; + PACL dacl = nullptr; + if (!GetSecurityDescriptorDacl(descriptor.get(), &present, &dacl, &defaulted)) { + return Fail("GetSecurityDescriptorDacl", GetLastError()); + } + // A missing DACL means "everyone, full control"; never install that by accident. + if (!present || dacl == nullptr) { + return Fail("GetSecurityDescriptorDacl", ERROR_INVALID_SECURITY_DESCR); + } + SECURITY_INFORMATION information = 0; + auto control = DaclInformationFor(descriptor.get(), &information); + if (!control.ok()) { + return control; + } + DWORD status = SetNamedSecurityInfoW(reinterpret_cast(path.data()), SE_FILE_OBJECT, + information, nullptr, nullptr, dacl, nullptr); + return status == ERROR_SUCCESS ? Win32Result{} : Fail("SetNamedSecurityInfoW", status); +} + +Win32Result GrantAccess(std::u16string path, const std::u16string& sid, DWORD mask, + bool inheritable, bool replace) { + PSECURITY_DESCRIPTOR rawDescriptor = nullptr; + PACL existing = nullptr; + DWORD status = GetNamedSecurityInfoW(reinterpret_cast(path.c_str()), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, nullptr, nullptr, &existing, + nullptr, &rawDescriptor); + if (status != ERROR_SUCCESS) { + return Fail("GetNamedSecurityInfoW", status); + } + LocalPtr descriptor(rawDescriptor); + // A NULL DACL already grants everyone everything; merging into it would build a DACL holding + // only this rule and lock every other principal out. + if (existing == nullptr) { + return {}; + } + PSID rawSid = nullptr; + if (!ConvertStringSidToSidW(reinterpret_cast(sid.c_str()), &rawSid)) { + return Fail("ConvertStringSidToSidW", GetLastError()); + } + LocalPtr trusteeSid(rawSid); + + EXPLICIT_ACCESSW access{}; + access.grfAccessPermissions = mask; + access.grfAccessMode = replace ? SET_ACCESS : GRANT_ACCESS; + access.grfInheritance = inheritable ? SUB_CONTAINERS_AND_OBJECTS_INHERIT : NO_INHERITANCE; + access.Trustee.TrusteeForm = TRUSTEE_IS_SID; + access.Trustee.TrusteeType = TRUSTEE_IS_UNKNOWN; + access.Trustee.ptstrName = reinterpret_cast(rawSid); + + PACL merged = nullptr; + status = SetEntriesInAclW(1, &access, existing, &merged); + if (status != ERROR_SUCCESS) { + return Fail("SetEntriesInAclW", status); + } + LocalPtr mergedAcl(merged); + SECURITY_INFORMATION information = 0; + auto control = DaclInformationFor(descriptor.get(), &information); + if (!control.ok()) { + return control; + } + status = SetNamedSecurityInfoW(reinterpret_cast(path.data()), SE_FILE_OBJECT, + information, nullptr, nullptr, merged, nullptr); + return status == ERROR_SUCCESS ? Win32Result{} : Fail("SetNamedSecurityInfoW", status); +} + +Win32Result CreateFileWithDacl(const std::u16string& path, const std::u16string& sddl, + const uint8_t* data, size_t length) { + LocalPtr descriptor; + auto parsed = ParseSddl(sddl, &descriptor); + if (!parsed.ok()) { + return parsed; + } + if (length > MAXDWORD) { + return Fail("WriteFile", ERROR_INVALID_PARAMETER); + } + SECURITY_ATTRIBUTES attributes{sizeof(attributes), descriptor.get(), FALSE}; + // Why CREATE_NEW: the descriptor only applies to a file this call creates; opening an + // existing one would keep that file's (possibly broader) DACL. Share mode 0 keeps every other + // opener out until the contents are complete. + HANDLE file = CreateFileW(reinterpret_cast(path.c_str()), GENERIC_WRITE, 0, + &attributes, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) { + return Fail("CreateFileW", GetLastError()); + } + Win32Result result; + DWORD written = 0; + if (!WriteFile(file, data, static_cast(length), &written, nullptr)) { + result = Fail("WriteFile", GetLastError()); + } else if (written != length) { + result = Fail("WriteFile", ERROR_WRITE_FAULT); + } + // A failed close can mean lost data on a network volume, so it tears the file like a failed write. + if (!CloseHandle(file) && result.ok()) { + result = Fail("CloseHandle", GetLastError()); + } + if (!result.ok()) { + // A torn temp file is useless to the caller and must not linger beside the real one. + DeleteFileW(reinterpret_cast(path.c_str())); + } + return result; +} + +bool ReadGrantOptions(const Napi::Value& value, bool* inheritable, bool* replace) { + if (!value.IsObject()) { + return false; + } + auto options = value.As(); + auto inherit = options.Get("inheritable"); + auto replaceValue = options.Get("replace"); + if (!inherit.IsBoolean() || !replaceValue.IsBoolean()) { + return false; + } + *inheritable = inherit.As().Value(); + *replace = replaceValue.As().Value(); + return true; +} + +Napi::Value ThrowTypeError(Napi::Env env, const char* signature) { + Napi::TypeError::New(env, signature).ThrowAsJavaScriptException(); + return env.Undefined(); +} + +Napi::Value SidString(Napi::Env env, PSID sid) { + LPWSTR text = nullptr; + if (!ConvertSidToStringSidW(sid, &text)) { + ToError(env, Fail("ConvertSidToStringSidW", GetLastError())).ThrowAsJavaScriptException(); + return env.Undefined(); + } + LocalPtr owned(text); + return Napi::String::New(env, reinterpret_cast(text)); +} + +Napi::Value CurrentUserSid(const Napi::CallbackInfo& info) { + auto env = info.Env(); + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { + ToError(env, Fail("OpenProcessToken", GetLastError())).ThrowAsJavaScriptException(); + return env.Undefined(); + } + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector buffer(size); + BOOL queried = size > 0 && GetTokenInformation(token, TokenUser, buffer.data(), size, &size); + DWORD queryError = GetLastError(); + CloseHandle(token); + if (!queried) { + ToError(env, Fail("GetTokenInformation", queryError)).ThrowAsJavaScriptException(); + return env.Undefined(); + } + return SidString(env, reinterpret_cast(buffer.data())->User.Sid); +} + +// Structure only, no policy: `{ protected, aces: [{ type, flags, mask, sid }] }`, or null for a +// NULL DACL. Only plain allow/deny ACEs carry a SID; every other type comes back with sid null so +// the caller can refuse what it does not understand (object, callback and conditional ACEs). +Napi::Value GetDaclAces(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 1 || !info[0].IsString()) { + return ThrowTypeError(env, "getDaclAces(path: string)"); + } + auto path = info[0].As().Utf16Value(); + PSECURITY_DESCRIPTOR rawDescriptor = nullptr; + PACL dacl = nullptr; + DWORD status = GetNamedSecurityInfoW(reinterpret_cast(path.c_str()), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, nullptr, nullptr, &dacl, + nullptr, &rawDescriptor); + if (status != ERROR_SUCCESS) { + ToError(env, Fail("GetNamedSecurityInfoW", status)).ThrowAsJavaScriptException(); + return env.Undefined(); + } + LocalPtr descriptor(rawDescriptor); + if (dacl == nullptr) { + return env.Null(); + } + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + if (!GetSecurityDescriptorControl(rawDescriptor, &control, &revision)) { + ToError(env, Fail("GetSecurityDescriptorControl", GetLastError())).ThrowAsJavaScriptException(); + return env.Undefined(); + } + auto aces = Napi::Array::New(env, dacl->AceCount); + for (DWORD index = 0; index < dacl->AceCount; index++) { + void* raw = nullptr; + if (!GetAce(dacl, index, &raw)) { + ToError(env, Fail("GetAce", GetLastError())).ThrowAsJavaScriptException(); + return env.Undefined(); + } + auto* header = static_cast(raw); + auto ace = Napi::Object::New(env); + ace.Set("type", Napi::Number::New(env, header->AceType)); + ace.Set("flags", Napi::Number::New(env, header->AceFlags)); + // ACCESS_ALLOWED_ACE and ACCESS_DENIED_ACE share this layout: Mask, then SidStart. + if (header->AceType == ACCESS_ALLOWED_ACE_TYPE || header->AceType == ACCESS_DENIED_ACE_TYPE) { + auto* plain = static_cast(raw); + ace.Set("mask", Napi::Number::New(env, plain->Mask)); + auto sid = SidString(env, &plain->SidStart); + if (env.IsExceptionPending()) { + return env.Undefined(); + } + ace.Set("sid", sid); + } else { + ace.Set("mask", Napi::Number::New(env, 0)); + ace.Set("sid", env.Null()); + } + aces.Set(index, ace); + } + auto result = Napi::Object::New(env); + result.Set("protected", Napi::Boolean::New(env, (control & SE_DACL_PROTECTED) != 0)); + result.Set("aces", aces); + return result; +} + +Napi::Value SetDaclSddl(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) { + return ThrowTypeError(env, "setDaclSddl(path: string, sddl: string)"); + } + auto result = ApplyDacl(info[0].As().Utf16Value(), + info[1].As().Utf16Value()); + if (!result.ok()) { + ToError(env, result).ThrowAsJavaScriptException(); + } + return env.Undefined(); +} + +Napi::Value CreateFileWithDaclJs(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 3 || !info[0].IsString() || !info[1].IsString() || + !info[2].IsTypedArray()) { + return ThrowTypeError(env, "createFileWithDacl(path: string, sddl: string, data: Uint8Array)"); + } + auto data = info[2].As(); + auto result = CreateFileWithDacl(info[0].As().Utf16Value(), + info[1].As().Utf16Value(), data.Data(), + data.ByteLength()); + if (!result.ok()) { + ToError(env, result).ThrowAsJavaScriptException(); + } + return env.Undefined(); +} + +bool ReadGrantArguments(const Napi::CallbackInfo& info, std::u16string* path, std::u16string* sid, + DWORD* mask, bool* inheritable, bool* replace) { + if (info.Length() < 4 || !info[0].IsString() || !info[1].IsString() || !info[2].IsNumber() || + !ReadGrantOptions(info[3], inheritable, replace)) { + return false; + } + *path = info[0].As().Utf16Value(); + *sid = info[1].As().Utf16Value(); + *mask = info[2].As().Uint32Value(); + return true; +} + +constexpr const char* kGrantSignature = + "grantAccess(path: string, sid: string, accessMask: number, " + "options: { inheritable: boolean, replace: boolean })"; + +Napi::Value GrantAccessJs(const Napi::CallbackInfo& info) { + auto env = info.Env(); + std::u16string path, sid; + DWORD mask = 0; + bool inheritable = false, replace = false; + if (!ReadGrantArguments(info, &path, &sid, &mask, &inheritable, &replace)) { + return ThrowTypeError(env, kGrantSignature); + } + auto result = GrantAccess(path, sid, mask, inheritable, replace); + if (!result.ok()) { + ToError(env, result).ThrowAsJavaScriptException(); + } + return env.Undefined(); +} + +// Why a worker: a directory DACL change walks every descendant (seconds on a large userData). +class DaclWorker : public Napi::AsyncWorker { + public: + DaclWorker(Napi::Env env, std::function work) + : Napi::AsyncWorker(env), deferred_(Napi::Promise::Deferred::New(env)), + work_(std::move(work)) {} + + Napi::Promise Promise() { return deferred_.Promise(); } + + void Execute() override { result_ = work_(); } + void OnOK() override { + if (result_.ok()) { + deferred_.Resolve(Env().Undefined()); + } else { + deferred_.Reject(ToError(Env(), result_).Value()); + } + } + + private: + Napi::Promise::Deferred deferred_; + std::function work_; + Win32Result result_; +}; + +Napi::Value SetDaclSddlAsync(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) { + return ThrowTypeError(env, "setDaclSddlAsync(path: string, sddl: string)"); + } + auto path = info[0].As().Utf16Value(); + auto sddl = info[1].As().Utf16Value(); + auto* worker = new DaclWorker(env, [path, sddl]() { return ApplyDacl(path, sddl); }); + auto promise = worker->Promise(); + worker->Queue(); + return promise; +} + +Napi::Value GrantAccessAsync(const Napi::CallbackInfo& info) { + auto env = info.Env(); + std::u16string path, sid; + DWORD mask = 0; + bool inheritable = false, replace = false; + if (!ReadGrantArguments(info, &path, &sid, &mask, &inheritable, &replace)) { + return ThrowTypeError(env, kGrantSignature); + } + auto* worker = new DaclWorker(env, [=]() { + return GrantAccess(path, sid, mask, inheritable, replace); + }); + auto promise = worker->Promise(); + worker->Queue(); + return promise; +} + +} // namespace + +Napi::Object Init(Napi::Env env, Napi::Object exports) { + exports.Set("currentUserSid", Napi::Function::New(env, CurrentUserSid)); + exports.Set("getDaclAces", Napi::Function::New(env, GetDaclAces)); + exports.Set("setDaclSddl", Napi::Function::New(env, SetDaclSddl)); + exports.Set("setDaclSddlAsync", Napi::Function::New(env, SetDaclSddlAsync)); + exports.Set("createFileWithDacl", Napi::Function::New(env, CreateFileWithDaclJs)); + exports.Set("grantAccess", Napi::Function::New(env, GrantAccessJs)); + exports.Set("grantAccessAsync", Napi::Function::New(env, GrantAccessAsync)); + return exports; +} + +NODE_API_MODULE(orca_windows_security, Init) diff --git a/package.json b/package.json index 4a79ce6e2b8..11e93ca6390 100644 --- a/package.json +++ b/package.json @@ -335,6 +335,7 @@ }, "optionalDependencies": { "@orca/windows-registry": "workspace:*", + "@orca/windows-security": "workspace:*", "@vscode/windows-process-tree": "0.8.0", "sherpa-onnx-darwin-arm64": "1.12.37", "sherpa-onnx-darwin-x64": "1.12.37", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afbb844aa39..b8b7d4f438a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -633,6 +633,9 @@ importers: '@orca/windows-registry': specifier: workspace:* version: link:native/windows-registry + '@orca/windows-security': + specifier: workspace:* + version: link:native/windows-security '@vscode/windows-process-tree': specifier: 0.8.0 version: 0.8.0(patch_hash=9da74aa3d17243aa53dcdc95c9f06e97437e7fbccf098aeb017579e2d24cbac2) @@ -658,6 +661,12 @@ importers: specifier: ^8.5.0 version: 8.9.2 + native/windows-security: + dependencies: + node-addon-api: + specifier: ^8.5.0 + version: 8.9.2 + src/packages/process-host: devDependencies: '@types/node': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2ebd6f7f548..addfd6f599d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,12 +3,13 @@ # package graph. Without this file `pnpm -r` auto-discovers mobile/ and its # nested package, and the root's patchedDependencies fail as ERR_PNPM_UNUSED_PATCH. # -# native/windows-registry is listed explicitly rather than reached by a glob: as a `file:` +# native/windows-registry and native/windows-security are listed explicitly rather than reached by a glob: as a `file:` # dependency pnpm re-linked it on every install (including --frozen-lockfile), and that # virtual-store churn raced node-gyp reading the same tree, which lost @vscode/windows-process-tree's # binding.gyp mid-rebuild on Windows CI. A workspace link is resolved once instead. packages: - native/windows-registry + - native/windows-security - src/packages/* minimumReleaseAge: 4320 @@ -49,6 +50,7 @@ allowBuilds: sherpa-onnx: true ssh2: false '@orca/windows-registry': false + '@orca/windows-security': false overrides: monaco-editor>dompurify: 3.4.16 diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 7d5b0b2c13e..ed0be0743a3 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -286,7 +286,7 @@ function writeScriptWithAclRetry(scriptPath: string, content: string | Buffer): writeFileSync(scriptPath, content, 'utf-8') return } catch { - // icacls failure is not actionable; re-throw the original EPERM + // a failed grant is not actionable; re-throw the original EPERM } } throw error diff --git a/src/main/artifacts/artifact-create-intent-store.test.ts b/src/main/artifacts/artifact-create-intent-store.test.ts index bb1a5d2596f..79bdea1badb 100644 --- a/src/main/artifacts/artifact-create-intent-store.test.ts +++ b/src/main/artifacts/artifact-create-intent-store.test.ts @@ -15,8 +15,15 @@ import { getOrCreateArtifactCreateIntent, removeArtifactCreateIntent } from './artifact-create-intent-store' -import { runProcessSync } from '@orca/process-host' -import { __resetSecureFileWindowsUserSidForTests } from '../../shared/secure-file' +import { runProcess, runProcessSync } from '@orca/process-host' +import { + resetWindowsSecurityLoadForTests, + setWindowsSecurityForTests +} from '../../shared/windows-security-addon' +import { + createFakeWindowsSecurity, + FAKE_USER_SID +} from '../../shared/windows-security-addon.test-fixture' import type { ArtifactShareScope } from './artifact-share-record-store' vi.mock('@orca/process-host', () => ({ @@ -172,59 +179,52 @@ describe('artifact create intent store', () => { expect((await readdir(directory)).some((name) => name.endsWith('.tmp'))).toBe(false) }) - it('hardens one Windows journal directory without per-file ACL launches', async () => { + it('writes each Windows intent born restricted and relocks the directory once, off the write', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const ok = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } - // Earlier cases in this file already resolved (and cached) the SID against an unstubbed mock. - __resetSecureFileWindowsUserSidForTests() - vi.mocked(runProcessSync).mockImplementation((spec) => { - if (spec.program.endsWith('whoami.exe')) { - return { ...ok, stdout: '"USER","S-1-5-21-1000"' } - } - const args = spec.args ?? [] - if (args.length > 1) { - return ok // /reset and the /grant:r pass - } - // The verify pass re-reads the DACL; answer with the three protected inheritable rules. - const rules = ['host\\me', 'NT AUTHORITY\\SYSTEM', 'BUILTIN\\Administrators'].map( - (name, index) => (index === 0 ? `${args[0]} ${name}:(OI)(CI)(F)` : ` ${name}:(OI)(CI)(F)`) - ) - return { ...ok, stdout: `${rules.join('\r\n')}\r\n\r\nSuccessfully processed 1 files\r\n` } - }) + const fake = createFakeWindowsSecurity() + setWindowsSecurityForTests(fake) try { const userDataPath = await createUserDataPath() - getOrCreateArtifactCreateIntent( - 'local-profile', - userDataPath, - '/repo/report.html', - scope, - 'key-a', - body - ) - getOrCreateArtifactCreateIntent( - 'local-profile', - userDataPath, - '/repo/other.html', - scope, - 'key-b', - body - ) + const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents') + for (const [sourceKey, key] of [ + ['/repo/report.html', 'key-a'], + ['/repo/other.html', 'key-b'] + ]) { + getOrCreateArtifactCreateIntent('local-profile', userDataPath, sourceKey, scope, key, body) + } + await new Promise((resolve) => setTimeout(resolve, 0)) - // One harden across both intents: counted by its /reset pass, which opens each harden. - const aclCalls = vi - .mocked(runProcessSync) - .mock.calls.map(([spec]) => spec) - .filter((spec) => spec.program.endsWith('icacls.exe')) - expect(aclCalls.filter((spec) => spec.args?.includes('/reset'))).toHaveLength(1) - // The child intent files rely on inheritance, so the directory rules must carry (OI)(CI). - const grant = aclCalls.find((spec) => spec.args?.includes('/grant:r')) - expect(grant?.args?.filter((arg) => arg.endsWith(':(OI)(CI)(F)'))).toHaveLength(3) + // Each intent is created already carrying the protected file DACL, then renamed into place. + const creates = fake.callsFor('create') + expect(creates).toHaveLength(2) + for (const create of creates) { + expect(create.path.startsWith(directory)).toBe(true) + expect(create.path.endsWith('.tmp')).toBe(true) + expect(create).toMatchObject({ + sddl: `D:P(A;;FA;;;S-1-5-18)(A;;FA;;;S-1-5-32-544)(A;;FA;;;${FAKE_USER_SID})` + }) + } + // The directory is relocked once, on the thread pool; nothing is rewritten synchronously. + expect(fake.callsFor('setAsync')).toMatchObject([ + { + op: 'setAsync', + path: directory, + sddl: `D:P(A;OICI;FA;;;S-1-5-18)(A;OICI;FA;;;S-1-5-32-544)(A;OICI;FA;;;${FAKE_USER_SID})` + } + ]) + expect(fake.callsFor('set')).toEqual([]) + expect( + getArtifactCreateIntent('local-profile', userDataPath, '/repo/other.html', scope) + ).toMatchObject({ idempotencyKey: 'key-b' }) + expect((await readdir(directory)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + expect(runProcess).not.toHaveBeenCalled() + expect(runProcessSync).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } - vi.mocked(runProcessSync).mockReset() + resetWindowsSecurityLoadForTests() } }) diff --git a/src/main/artifacts/artifact-create-intent-store.ts b/src/main/artifacts/artifact-create-intent-store.ts index 02869245ebd..27396f7e59c 100644 --- a/src/main/artifacts/artifact-create-intent-store.ts +++ b/src/main/artifacts/artifact-create-intent-store.ts @@ -1,14 +1,5 @@ -import { createHash, randomBytes } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - statSync, - writeFileSync -} from 'node:fs' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs' import { join } from 'node:path' import { ARTIFACT_MAX_CONTENT_BYTES, @@ -16,11 +7,7 @@ import { artifactContentByteLength, artifactWriteRequestByteLength } from '../../shared/artifacts' -import { - bestEffortFsyncDirectorySync, - fsyncFileSync, - hardenSecurePath -} from '../../shared/secure-file' +import { bestEffortFsyncDirectorySync, writeSecureFile } from '../../shared/secure-file' import { getOrcaProfileDirectory } from '../orca-profiles/profile-storage-paths' import type { ArtifactWriteBody } from './artifact-cloud-request' import type { ArtifactShareScope } from './artifact-share-record-store' @@ -28,9 +15,6 @@ import type { ArtifactShareScope } from './artifact-share-record-store' export const MAX_PENDING_ARTIFACT_CREATES = 32 export const MAX_ARTIFACT_CREATE_INTENT_BYTES = ARTIFACT_MAX_REQUEST_BYTES + 128 * 1024 -const MAX_HARDENED_INTENT_DIRECTORIES = 64 -const hardenedIntentDirectories = new Set() - export type ArtifactCreateIntent = { version: 1 sourceKey: string @@ -46,20 +30,6 @@ function intentDirectory(profileId: string, userDataPath: string): string { function ensureIntentDirectory(profileId: string, userDataPath: string): string { const directory = intentDirectory(profileId, userDataPath) mkdirSync(directory, { recursive: true, mode: 0o700 }) - if (!hardenedIntentDirectories.has(directory)) { - hardenSecurePath(directory, { - isDirectory: true, - platform: process.platform, - sync: true - }) - if (hardenedIntentDirectories.size >= MAX_HARDENED_INTENT_DIRECTORIES) { - const oldest = hardenedIntentDirectories.values().next().value - if (oldest !== undefined) { - hardenedIntentDirectories.delete(oldest) - } - } - hardenedIntentDirectories.add(directory) - } return directory } @@ -71,23 +41,6 @@ function removeTemporaryIntents(directory: string): void { } } -function writeIntent(path: string, directory: string, serializedIntent: string): void { - // Child files inherit the once-hardened directory ACL, avoiding per-create PowerShell launches. - const temporaryPath = `${path}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp` - try { - writeFileSync(temporaryPath, serializedIntent, { - encoding: 'utf8', - mode: 0o600 - }) - fsyncFileSync(temporaryPath) - renameSync(temporaryPath, path) - bestEffortFsyncDirectorySync(directory) - } catch (error) { - rmSync(temporaryPath, { force: true }) - throw error - } -} - function intentPath( profileId: string, userDataPath: string, @@ -233,7 +186,9 @@ export function getOrCreateArtifactCreateIntent( if (Buffer.byteLength(serializedIntent, 'utf8') > MAX_ARTIFACT_CREATE_INTENT_BYTES) { throw new Error('Artifact create recovery record exceeds the supported size.') } - writeIntent(intentPath(profileId, userDataPath, sourceKey, scope), directory, serializedIntent) + writeSecureFile(intentPath(profileId, userDataPath, sourceKey, scope), serializedIntent, { + durable: true + }) return intent } diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 965bde775cd..4dca553c4c0 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -40,7 +40,7 @@ export function writeFileAtomically( rmSync(retryTmpPath, { force: true }) } } catch { - // icacls failure is not actionable; re-throw the original EPERM + // a failed grant is not actionable; re-throw the original EPERM } } throw error diff --git a/src/main/orcad/orcad-data-root-privacy.ts b/src/main/orcad/orcad-data-root-privacy.ts index a3f99fc7a3c..7d0ec24b3c3 100644 --- a/src/main/orcad/orcad-data-root-privacy.ts +++ b/src/main/orcad/orcad-data-root-privacy.ts @@ -39,7 +39,7 @@ export function assertOrcadDataRootIsPrivate( hooks: OrcadDataRootPrivacyHooks ): void { // Windows ACLs are not expressible as a POSIX mode, and `statSync().mode` there reports a - // synthesized one, so Windows restricts and verifies the ACL instead (icacls, no PowerShell). + // synthesized one, so Windows restricts and verifies the ACL instead, in-process. if ((hooks.platform ?? process.platform) === 'win32') { const restrict = hooks.restrictWindowsDataRoot ?? ((path: string) => restrictWindowsPathSync(path, true)) diff --git a/src/main/orcad/orcad-managed-stop.test.ts b/src/main/orcad/orcad-managed-stop.test.ts index f331054faf3..8e34c39b568 100644 --- a/src/main/orcad/orcad-managed-stop.test.ts +++ b/src/main/orcad/orcad-managed-stop.test.ts @@ -35,24 +35,12 @@ vi.mock('../../shared/secure-path-windows-acl', async (importOriginal) => { const acl = await importOriginal() return { ...acl, - bestEffortRestrictWindowsPath: ( - targetPath: string, - isDirectory: boolean, - onSettled?: (restricted: boolean) => void - ): void => { - let finish!: () => void - const pending = new Promise((resolve) => { - finish = resolve - }) + restrictWindowsDirectory: (dirPath: string): Promise => { + const restricted = acl.restrictWindowsDirectory(dirPath) + const pending = restricted.then(() => undefined) hardening.pending.add(pending) - acl.bestEffortRestrictWindowsPath(targetPath, isDirectory, (restricted) => { - try { - onSettled?.(restricted) - } finally { - hardening.pending.delete(pending) - finish() - } - }) + void pending.then(() => hardening.pending.delete(pending)) + return restricted } } }) diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index cf312bc79d7..0a76f52b88f 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -56,7 +56,7 @@ export class DeviceRegistry { constructor(userDataPath: string) { this.registryPath = join(userDataPath, DEVICE_REGISTRY_FILENAME) - // Why: a write killed between writeFile and rename (e.g. a hung icacls, #20497) orphans its temp forever. + // Why: a write killed between writeFile and rename (e.g. a crash mid-write, #20497) orphans its temp forever. void removeStaleDurableWriteTempFiles(this.registryPath, { minimumAgeMs: STALE_WRITE_TEMP_AGE_MS }) diff --git a/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts b/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts index edce8592523..c0d53a0796e 100644 --- a/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts +++ b/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts @@ -1,74 +1,33 @@ // Why: the E2EE auth handshake used to persist `lastSeenAt` inline, and on Windows every secure-file -// write blocks the main thread on synchronous icacls ACL spawns (~1-1.5s cold each). These tests -// pin the spawn count on the auth critical path, not wall-clock, so they are deterministic under load. -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +// write blocked the main thread on synchronous icacls spawns (~1-1.5s cold each). The ACL now +// rides on the file's creation in-process; these tests pin how many secure writes land on the auth +// critical path, not wall-clock, so they are deterministic under load. +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WebSocket } from 'ws' -import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import { runProcess, runProcessSync } from '@orca/process-host' +import { + resetWindowsSecurityLoadForTests, + setWindowsSecurityForTests +} from '../../../shared/windows-security-addon' +import { createFakeWindowsSecurity } from '../../../shared/windows-security-addon.test-fixture' import { DEVICE_REGISTRY_FILENAME } from '../mobile-pairing-files' import { DeviceRegistry, type DeviceEntry } from '../device-registry' import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './e2ee-crypto' import { MobileSocketWiring, type MobileSocketTransport } from './mobile-socket-wiring' -// Why this module and not `node:child_process`: hardening reaches the OS only through -// runProcess/runProcessSync, and a hand-written child_process factory silently omitted the one -// function they call — so every spawn threw, hardening no-opped, and the test double hid it. vi.mock('@orca/process-host', () => ({ runProcess: vi.fn(), runProcessSync: vi.fn() })) -// Why: stands in for the icacls cold start; long enough that a gated response would be obvious, -// short enough that the suite stays fast. Assertions use the recorded ordering, never this number. -const INJECTED_SPAWN_LATENCY_MS = 5 -const USER_SID = 'S-1-5-21-1000' -const OK: ProcessResult = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } -/** - * One secure write hardens two paths: the staged temp file, fresh and still on the inherited DACL, - * costs the full verify/reset/grant/verify pass; the published file, whose protected DACL came - * along with the rename, costs only its verify. - */ -const BLOCKING_SPAWNS_PER_WRITE = 5 +/** One secure write is one born-restricted create: no verify-then-rewrite passes around it. */ +const RESTRICTED_CREATES_PER_WRITE = 1 -/** Paths the fake icacls has granted a protected DACL, keyed to the ACE flags the grant used. */ -const hardenedByFake = new Map() - -/** - * Stands in for icacls. `/save` really writes a UTF-16LE SDDL file, because the code under test - * reads that file back off disk to decide whether a rewrite is needed at all — which is what makes - * the spawn count per write a property of the real ACL path rather than of this double. - */ -function fakeIcacls(spec: ProcessSpec): ProcessResult { - const args = spec.args ?? [] - const path = args[0] ?? '' - const grantIndex = args.indexOf('/grant:r') - if (grantIndex !== -1) { - hardenedByFake.set(path, args[grantIndex + 1]!.includes('(OI)(CI)') ? 'OICI' : '') - return OK - } - const saveIndex = args.indexOf('/save') - if (saveIndex === -1) { - return OK // /reset - } - writeFileSync(args[saveIndex + 1]!, fakeSddl(path), 'utf16le') - return OK -} - -function fakeSddl(path: string): string { - const aceFlags = hardenedByFake.get(path) - if (aceFlags === undefined) { - // Never hardened: the inherited DACL a fresh file carries, so the first verify must fail. - return `name\r\nD:(A;ID;FA;;;SY)(A;ID;FA;;;BA)(A;ID;FA;;;${USER_SID})\r\n` - } - const ace = (sid: string): string => `(A;${aceFlags};FA;;;${sid})` - return `name\r\nD:PAI${ace('BA')}${ace('SY')}${ace(USER_SID)}\r\n` -} - -type TimelineEntry = 'acl-spawn' | 'e2ee_ready' | 'e2ee_authenticated' | 'other-frame' +type TimelineEntry = 'restricted-create' | 'e2ee_ready' | 'e2ee_authenticated' | 'other-frame' class FakeSocket { readonly OPEN = 1 @@ -99,44 +58,33 @@ class FakeTransport implements MobileSocketTransport { describe('mobile auth critical path', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - const originalSystemRoot = process.env.SystemRoot const timeline: TimelineEntry[] = [] let userDataPath = '' beforeEach(() => { timeline.length = 0 - process.env.SystemRoot = 'C:\\Windows' Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) userDataPath = mkdtempSync(join(tmpdir(), 'orca-auth-acl-')) - hardenedByFake.clear() vi.mocked(runProcessSync).mockReset() vi.mocked(runProcess).mockReset() - vi.mocked(runProcessSync).mockImplementation((spec) => { - // Matched by suffix, not by the whole path: `windowsSystem32Binary` joins with the host - // separator, so the literal only matches when the tests happen to run on Windows. - if (spec.program.endsWith('whoami.exe')) { - return { ...OK, stdout: `"USER","${USER_SID}"` } - } - timeline.push('acl-spawn') - // Why: the real spawn blocks the main thread, so the fake must too — and via Atomics, not a - // Date.now() spin, which would never terminate under fake timers. - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, INJECTED_SPAWN_LATENCY_MS) - return fakeIcacls(spec) - }) - // The directory harden stays on the async lane, so it never lands on the timeline. - vi.mocked(runProcess).mockImplementation((spec) => Promise.resolve(fakeIcacls(spec))) + const fake = createFakeWindowsSecurity() + const create = fake.createFileWithDacl + fake.createFileWithDacl = (path, sddl, data) => { + timeline.push('restricted-create') + create(path, sddl, data) + } + setWindowsSecurityForTests(fake) }) afterEach(() => { + // The ACL work never starts a process, on or off the critical path. + expect(runProcess).not.toHaveBeenCalled() + expect(runProcessSync).not.toHaveBeenCalled() vi.useRealTimers() + resetWindowsSecurityLoadForTests() if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } - if (originalSystemRoot === undefined) { - delete process.env.SystemRoot - } else { - process.env.SystemRoot = originalSystemRoot - } rmSync(userDataPath, { recursive: true, force: true }) }) @@ -188,7 +136,7 @@ describe('mobile auth critical path', () => { ) as DeviceEntry[] } - it('emits e2ee_authenticated without spawning a single ACL process', () => { + it('emits e2ee_authenticated without a single secure write on the critical path', () => { const registry = new DeviceRegistry(userDataPath) const device = registry.addDevice('Phone', 'runtime') // Why: a re-connecting device is the measured case; the first sighting is covered below. @@ -198,15 +146,12 @@ describe('mobile auth critical path', () => { authenticate(registry, device) expect(timeline).toEqual(['e2ee_ready', 'e2ee_authenticated']) - expect(timeline.indexOf('acl-spawn')).toBe(-1) registry.flushPendingLastSeen() - // Hardening is deferred, never dropped: tmp file + published file, exactly as the inline path did. - expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength( - BLOCKING_SPAWNS_PER_WRITE + // The write is deferred, never dropped, and still born restricted. + expect(timeline.filter((entry) => entry === 'restricted-create')).toHaveLength( + RESTRICTED_CREATES_PER_WRITE ) - // The blocking work is the ACL tool itself, not some other spawn on the same lane. - expect(vi.mocked(runProcessSync).mock.lastCall?.[0].program).toMatch(/icacls\.exe$/) expect(readPersistedDevices()[0]?.lastSeenAt).toBe( registry.getDevice(device.deviceId)?.lastSeenAt ) @@ -222,7 +167,7 @@ describe('mobile auth critical path', () => { // Why: rotatePendingDevice drops entries disk says were never scanned, so this write stays inline. expect(timeline).toEqual([ 'e2ee_ready', - ...Array(BLOCKING_SPAWNS_PER_WRITE).fill('acl-spawn'), + ...Array(RESTRICTED_CREATES_PER_WRITE).fill('restricted-create'), 'e2ee_authenticated' ]) expect(readPersistedDevices()[0]?.lastSeenAt).toBeGreaterThan(0) @@ -239,10 +184,10 @@ describe('mobile auth critical path', () => { authenticate(registry, device) } - expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength(0) + expect(timeline.filter((entry) => entry === 'restricted-create')).toHaveLength(0) vi.advanceTimersByTime(250) - expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength( - BLOCKING_SPAWNS_PER_WRITE + expect(timeline.filter((entry) => entry === 'restricted-create')).toHaveLength( + RESTRICTED_CREATES_PER_WRITE ) }) @@ -255,13 +200,13 @@ describe('mobile auth critical path', () => { registry.updateLastSeenDeferred(device.deviceId) registry.addDevice('Other client', 'runtime') - expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength( - BLOCKING_SPAWNS_PER_WRITE + expect(timeline.filter((entry) => entry === 'restricted-create')).toHaveLength( + RESTRICTED_CREATES_PER_WRITE ) vi.advanceTimersByTime(250) - expect(timeline.filter((entry) => entry === 'acl-spawn')).toHaveLength( - BLOCKING_SPAWNS_PER_WRITE + expect(timeline.filter((entry) => entry === 'restricted-create')).toHaveLength( + RESTRICTED_CREATES_PER_WRITE ) }) }) diff --git a/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts b/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts index 8e6026a8349..97bbbdad34a 100644 --- a/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts +++ b/src/main/runtime/unreadable-secret-store-preservation.win32.test.ts @@ -1,9 +1,8 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync, type Stats } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { runProcessSync } from '@orca/process-host' -import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' +import { loadWindowsSecurity, type WindowsSecurity } from '../../shared/windows-security-addon' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' /** @@ -12,7 +11,7 @@ import { removeTreeSync } from '../../shared/windows-transient-lock-removal' * Path hardening writes a protected DACL granting only the SIDs the running process holds. Where * the data root came from somewhere else — a relocated `ORCA_USER_DATA_PATH`, a share, a roaming * profile, a backup restored under a recreated local account, or a harden whose `/reset` landed - * and whose `/grant` did not — the file ends up granting a SID this process does not have. It then + * and whose grant did not — the file ends up granting a SID this process does not have. It then * reads as `EPERM` while its *directory* stays writable, because file hardening is synchronous on * the write path and directory hardening is fire-and-forget. * @@ -21,7 +20,7 @@ import { removeTreeSync } from '../../shared/windows-transient-lock-removal' * parent, not `DELETE` on the file. So the healing path destroyed the thing it could not read. * Before hardening actually applied, this failed open — the file was simply readable. * - * These assert the file still holds its original bytes afterwards. Runs only on win32, where a + * These assert the file is still the same, untouched inode afterwards. Runs only on win32, where a * DACL is the mechanism; skipped elsewhere. */ @@ -50,12 +49,9 @@ function canDenyReads(): boolean { const probe = join(probeRoot, 'probe.json') try { writeFileSync(probe, '{}') - icacls(probe, '/inheritance:r', '/q') - icacls(probe, '/grant:r', `*${FOREIGN_SID}:(F)`, '/q') + lockAgainstThisProcess(probe) return readDenied(probe) } finally { - icacls(probe, '/reset', '/q') - icacls(probeRoot, '/reset', '/t', '/q') removeTreeSync(probeRoot) } } @@ -64,27 +60,41 @@ function canDenyReads(): boolean { * BUILTIN\Guests: a real, always-resolvable group that no interactive token is a member of. * `S-1-5-32-544` looks foreign only until the suite meets a host that is elevated AND logged * in as the built-in Administrator -- a CI runner -- where it grants the reader full control - * and every assertion below goes vacuous. An unresolvable SID is not an option: icacls - * rejects one with ERROR_NONE_MAPPED (1332). + * and every assertion below goes vacuous. */ const FOREIGN_SID = 'S-1-5-32-546' -function icacls(...args: string[]): number | null { - return runProcessSync({ - program: windowsSystem32Binary('icacls.exe'), - args, - timeoutMs: 10_000 - }).code +function addon(): WindowsSecurity['addon'] { + const loaded = loadWindowsSecurity() + if ('error' in loaded) { + throw new Error(`the windows-security addon did not load: ${loaded.error}`) + } + return loaded.addon +} + +/** + * Protected, so nothing inherited from %TEMP% keeps the file readable. The OWNER RIGHTS rule + * withdraws the owner's implicit READ_CONTROL and WRITE_DAC: this process created the file, but + * the case being modelled is one owned by another account (a restored backup, a share), which + * Orca's read-path hardening cannot simply take back. Its parent stays ours, so it still deletes. + */ +function lockAgainstThisProcess(filePath: string): void { + addon().setDaclSddl(filePath, `D:P(A;;FA;;;${FOREIGN_SID})(A;;0x100000;;;OW)`) } /** The on-disk state a successful harden leaves for a SID this process does not hold. */ -function makeUnreadable(filePath: string): void { - // Two invocations: the combined `/inheritance:r /grant:r` form keeps %TEMP%'s inherited - // [SYSTEM, Administrators, user] as *explicit* ACEs on Windows Server, which left the file - // readable and every assertion below vacuous. Remove inheritance first, then grant. - expect(icacls(filePath, '/inheritance:r', '/q')).toBe(0) - expect(icacls(filePath, '/grant:r', `*${FOREIGN_SID}:(F)`, '/q')).toBe(0) +function makeUnreadable(filePath: string): Stats { + lockAgainstThisProcess(filePath) expect(readDenied(filePath), 'fixture should be unreadable').toBe(true) + return statSync(filePath) +} + +/** The same file, never replaced: a regenerated one would be a new, readable inode. */ +function expectUntouched(filePath: string, before: Stats): void { + const after = statSync(filePath) + expect(after.ino).toBe(before.ino) + expect(after.size).toBe(before.size) + expect(readDenied(filePath)).toBe(true) } const describeOnWindows = process.platform === 'win32' && canDenyReads() ? describe : describe.skip @@ -97,8 +107,6 @@ describeOnWindows('a secure store that exists but cannot be read', () => { }) afterAll(() => { - // Reset first: the tree is not removable while its files grant only Administrators. - icacls(root, '/reset', '/t', '/q') removeTreeSync(root) }) @@ -114,13 +122,12 @@ describeOnWindows('a secure store that exists but cannot be read', () => { secretKeyB64: Buffer.alloc(32, 9).toString('base64') }) writeFileSync(filePath, original) - makeUnreadable(filePath) + const locked = makeUnreadable(filePath) expect(() => loadOrCreateE2EEKeypair(dir)).toThrow(/Refusing to (regenerate|overwrite)/) // The point: the secret key is still the one every paired phone derived its shared secret from. - icacls(filePath, '/reset', '/q') - expect(readFileSync(filePath, 'utf8')).toBe(original) + expectUntouched(filePath, locked) }) it('does not erase the device registry, which would revoke every paired token', async () => { @@ -140,7 +147,7 @@ describeOnWindows('a secure store that exists but cannot be read', () => { } ]) writeFileSync(filePath, original) - makeUnreadable(filePath) + const locked = makeUnreadable(filePath) const registry = new DeviceRegistry(dir) // Any mutator reaches save(); it must refuse rather than write the empty list it loaded. @@ -148,8 +155,7 @@ describeOnWindows('a secure store that exists but cannot be read', () => { /Refusing to (regenerate|overwrite)/ ) - icacls(filePath, '/reset', '/q') - expect(readFileSync(filePath, 'utf8')).toBe(original) + expectUntouched(filePath, locked) }) it('does not blank the plugin secret vault on write', async () => { @@ -173,13 +179,12 @@ describeOnWindows('a secure store that exists but cannot be read', () => { ciphertexts: { existing: Buffer.from('enc:keep-me').toString('base64') } }) writeFileSync(filePath, original) - makeUnreadable(filePath) + const locked = makeUnreadable(filePath) expect(store.set('added', 'value')).toEqual({ ok: false, error: expect.any(String) }) store.delete('existing') - icacls(filePath, '/reset', '/q') - expect(readFileSync(filePath, 'utf8')).toBe(original) + expectUntouched(filePath, locked) vi.doUnmock('electron') }) it('does not blank the plugin KV store on write', async () => { @@ -191,13 +196,12 @@ describeOnWindows('a secure store that exists but cannot be read', () => { mkdirSync(join(filePath, '..'), { recursive: true }) const original = JSON.stringify({ keep: 'me' }) writeFileSync(filePath, original) - makeUnreadable(filePath) + const locked = makeUnreadable(filePath) expect(store.set('added', 'value')).toEqual({ ok: false, error: expect.any(String) }) store.delete('keep') - icacls(filePath, '/reset', '/q') - expect(readFileSync(filePath, 'utf8')).toBe(original) + expectUntouched(filePath, locked) }) it('does not drop pending relay revocations', async () => { @@ -215,7 +219,7 @@ describeOnWindows('a secure store that exists but cannot be read', () => { } ]) writeFileSync(filePath, original) - makeUnreadable(filePath) + const locked = makeUnreadable(filePath) const outbox = new RelayRevokeOutbox(dir) expect(() => @@ -226,8 +230,7 @@ describeOnWindows('a secure store that exists but cannot be read', () => { }) ).toThrow(/Refusing to (regenerate|overwrite)/) - icacls(filePath, '/reset', '/q') - expect(readFileSync(filePath, 'utf8')).toBe(original) + expectUntouched(filePath, locked) }) /** @@ -250,13 +253,12 @@ describeOnWindows('a secure store that exists but cannot be read', () => { mkdirSync(join(filePath, '..'), { recursive: true }) const original = JSON.stringify({ version: 1, format: 'dev-plaintext-v1', savedAt: 1 }) writeFileSync(filePath, original) - makeUnreadable(filePath) + const locked = makeUnreadable(filePath) // The status the delete path keys off: `unreadable`, never `decrypt-failed`. expect(readOrcaCloudSession('profile-1', dir).status).toBe('unreadable') - icacls(filePath, '/reset', '/q') - expect(readFileSync(filePath, 'utf8')).toBe(original) + expectUntouched(filePath, locked) vi.doUnmock('electron') }) }) diff --git a/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts b/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts index cbcea64045d..72821c8f9f3 100644 --- a/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts +++ b/src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts @@ -32,7 +32,7 @@ vi.mock('@electron-toolkit/utils', () => ({ electronApp: { setAppUserModelId: vi.fn() } })) -import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' +import { grantsFrom } from './windows-install-dir-acl.test-fixture' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -77,7 +77,7 @@ function recoveryOptions(userDataPath?: string): { } } -/** icacls hangs until `finishRepair` — the in-flight window is when the GPU children die. */ +/** The grant hangs until `finishRepair` — the in-flight window is when the GPU children die. */ function reportProbePoisoned(): { finishRepair: () => Promise } { let release = (): void => undefined const walkingTheTree = new Promise((resolve) => { @@ -87,16 +87,10 @@ function reportProbePoisoned(): { finishRepair: () => Promise } { { status: 'ok', matchesPoisonSignature: true }, { ...recoveryOptions(), - runProcessFn: (async () => { + grants: grantsFrom(async () => { await walkingTheTree - return { - code: 0, - signal: null, - stdout: 'Successfully processed 3200 files; Failed processing 0 files', - stderr: '', - timedOut: false - } - }) as unknown as (spec: ProcessSpec) => Promise + return { ok: true, failedFileCount: 0 } + }) } ) return { @@ -111,20 +105,18 @@ function reportProbePoisoned(): { finishRepair: () => Promise } { /** A repair that settles, so `poison.stage` leaves 'pending' for a terminal verdict. */ async function reportProbePoisonedWithSettledRepair( - exitCode: number, + granted: boolean, userDataPath?: string ): Promise { startWindowsInstallDirAclRepairIfPoisoned( { status: 'ok', matchesPoisonSignature: true }, { ...recoveryOptions(userDataPath), - runProcessFn: (async () => ({ - code: exitCode, - signal: null, - stdout: 'Successfully processed 3200 files; Failed processing 0 files', - stderr: exitCode === 0 ? '' : 'access denied', - timedOut: false - })) as unknown as (spec: ProcessSpec) => Promise + grants: grantsFrom(async () => + granted + ? { ok: true, failedFileCount: 0 } + : { ok: false, failedFileCount: 1, reason: 'access denied' } + ) } ) for (let i = 0; i < 200 && isInstallDirAclRepairPending(); i += 1) { @@ -134,7 +126,7 @@ async function reportProbePoisonedWithSettledRepair( /** * The pre-window gate meeting a spent repair budget: the tree is still marked poisoned and - * Orca has no repair left to try. icacls must never be reached, so the runner throws. + * Orca has no repair left to try. The grant must never be reached, so it throws. */ async function gateFindsRepairBudgetSpent(): Promise { const options = recoveryOptions() @@ -152,9 +144,9 @@ async function gateFindsRepairBudgetSpent(): Promise { writeInstallDirAclPoisonMarker(options.userDataPath, INSTALL_DIR, options.appVersion) const mode = await repairKnownPoisonedInstallDirBeforeWindow({ ...options, - runProcessFn: (() => { - throw new Error('the spent budget must not spawn icacls') - }) as never + grants: grantsFrom(() => { + throw new Error('the spent budget must not grant') + }) }) expect(mode).toBe('marker-hit') } @@ -242,8 +234,8 @@ describe('handleGpuChildCrash vs the install-dir ACL verdict', () => { expect(showMessageBox).not.toHaveBeenCalled() }) - // The gate's 'repaired' is icacls's exit claim, not a reading of the tree, and an icacls - // that silently no-opped exits 0 on a tree it left poisoned. The GPU children die in the + // The gate's 'repaired' is the grant's own claim, not a reading of the tree, and a grant + // that silently no-opped succeeds on a tree it left poisoned. The GPU children die in the // interval before this launch's probe answers, so a claim that un-suspects the tree there // engages --in-process-gpu on a tree safe graphics cannot rescue — and a "keep it" answer // then pins a userConfirmed marker no later repair may clear. @@ -251,13 +243,7 @@ describe('handleGpuChildCrash vs the install-dir ACL verdict', () => { writeInstallDirAclPoisonMarker(userData.path, INSTALL_DIR, '1.4.184') const mode = await repairKnownPoisonedInstallDirBeforeWindow({ ...recoveryOptions(userData.path), - runProcessFn: (async () => ({ - code: 0, - signal: null, - stdout: 'Successfully processed 3200 files; Failed processing 0 files', - stderr: '', - timedOut: false - })) as unknown as (spec: ProcessSpec) => Promise + grants: grantsFrom(async () => ({ ok: true, failedFileCount: 0 })) }) expect(mode).toBe('repaired') noteWindowsInstallDirAclProbePending() @@ -310,7 +296,7 @@ describe('handleGpuChildCrash vs the install-dir ACL verdict', () => { }) it('still withdraws the marker once the verdict is terminal rather than a pending repair', async () => { - await reportProbePoisonedWithSettledRepair(1) + await reportProbePoisonedWithSettledRepair(false) await crashUpToThreshold() await handleGpuChildCrash('crashed', null, 600) @@ -335,7 +321,7 @@ describe('handleGpuChildCrash vs the install-dir ACL verdict', () => { // spent no repair is coming on this launch or any later one, so pinning the tree as the // suspect forever denied safe graphics on EVERY launch for the life of that version — and // deleted the marker each time, so the machine also relaunched hardware accelerated. The - // victims are a standard-user install icacls can never fix and, via the probe's flag-blind + // victims are a standard-user install the grant can never fix and, via the probe's flag-blind // ACE match, healthy installs whose driver genuinely is broken. it('offers safe graphics on every launch once the ACL repair budget is spent', async () => { for (let launch = 1; launch <= 3; launch += 1) { @@ -357,7 +343,7 @@ describe('handleGpuChildCrash vs the install-dir ACL verdict', () => { // Still withheld while the budget has an attempt left: the repair is the better answer, // and this is the launch a next one can be rescued on. it('still withholds while the repair has an attempt left to spend', async () => { - await reportProbePoisonedWithSettledRepair(1) + await reportProbePoisonedWithSettledRepair(false) await crashUpToThreshold() await handleGpuChildCrash('crashed', null, 600) expect(showMessageBox).not.toHaveBeenCalled() @@ -422,7 +408,7 @@ describe('presentGpuFallbackRecoveredLaunchPrompt vs a marker retired since it w }) it('stays silent once the install-DACL repair has cleared it', async () => { - await reportProbePoisonedWithSettledRepair(0, userData.path) + await reportProbePoisonedWithSettledRepair(true, userData.path) expect(readGpuFallbackMarker(userData.path)).toBeNull() await presentGpuFallbackRecoveredLaunchPrompt(window) @@ -433,7 +419,7 @@ describe('presentGpuFallbackRecoveredLaunchPrompt vs a marker retired since it w // tree a live suspect, the window the prompt lands on is blank, and Keep is both defaultId // and cancelId — so asking invites a userConfirmed pin no later repair may clear. it('stays silent while the install DACL is still the suspect', async () => { - await reportProbePoisonedWithSettledRepair(1, userData.path) + await reportProbePoisonedWithSettledRepair(false, userData.path) expect(readGpuFallbackMarker(userData.path)).not.toBeNull() await presentGpuFallbackRecoveredLaunchPrompt(window) diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts index eb9a3babf57..fb284af6722 100644 --- a/src/main/startup/main-window-actions.ts +++ b/src/main/startup/main-window-actions.ts @@ -37,7 +37,7 @@ export function focusExistingWindow(): void { app, getWindow: () => state.mainWindow, openWindow, - // Why: a 20s blank launch invites a second double-click, and icacls is rewriting + // Why: a 20s blank launch invites a second double-click, and the repair is rewriting // the per-file DACLs a fresh renderer would read. The gated launch opens the window. canOpenWindow: () => !isBlockingInstallDirAclRepairInFlight(), warn: console.warn diff --git a/src/main/startup/windows-install-dir-acl-poison-marker.ts b/src/main/startup/windows-install-dir-acl-poison-marker.ts index 46035e04e74..895f27af56a 100644 --- a/src/main/startup/windows-install-dir-acl-poison-marker.ts +++ b/src/main/startup/windows-install-dir-acl-poison-marker.ts @@ -72,6 +72,6 @@ export function clearInstallDirAclPoisonMarker(userDataPath: string): void { try { rmSync(markerPath(userDataPath), { force: true }) } catch { - // Best effort; a stale marker only costs one redundant icacls pass. + // Best effort; a stale marker only costs one redundant grant pass. } } diff --git a/src/main/startup/windows-install-dir-acl-probe.test.ts b/src/main/startup/windows-install-dir-acl-probe.test.ts index 5e1d67b12d5..1ce224a4a1f 100644 --- a/src/main/startup/windows-install-dir-acl-probe.test.ts +++ b/src/main/startup/windows-install-dir-acl-probe.test.ts @@ -1,8 +1,10 @@ -import { existsSync } from 'node:fs' -import { tmpdir } from 'node:os' import { join } from 'node:path' import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' -import { describe, expect, it, beforeEach, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + resetWindowsSecurityLoadForTests, + setWindowsSecurityForTests +} from '../../shared/windows-security-addon' import { probeWindowsInstallDirAcl, resetWindowsInstallDirAclProbeForTest, @@ -12,7 +14,7 @@ import { import { isInstallDirAclPoisonVerdict } from './windows-install-dir-package-acl-repair' import { ALL_PACKAGES_ACE, - fakeIcaclsSpawn, + installFakeDacls, ORPHAN_PACKAGE_ACE, RESTRICTED_PACKAGES_ACE } from './windows-install-dir-acl.test-fixture' @@ -21,9 +23,7 @@ const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca' const ORPHAN = ORPHAN_PACKAGE_ACE const RESTRICTED_GRANT = RESTRICTED_PACKAGES_ACE -const fakeSpawn = fakeIcaclsSpawn - -function probe(options: WindowsInstallDirAclProbeOptions): Promise> { +function probe(options: WindowsInstallDirAclProbeOptions = {}): Promise> { return new Promise((resolve) => { probeWindowsInstallDirAcl({ platform: 'win32', @@ -40,7 +40,8 @@ function probe(options: WindowsInstallDirAclProbeOptions): Promise> { - return probe({ spawnFn: fakeSpawn(() => aces).spawnFn }) + installFakeDacls(() => aces) + return probe() } describe('probeWindowsInstallDirAcl', () => { @@ -48,10 +49,13 @@ describe('probeWindowsInstallDirAcl', () => { resetWindowsInstallDirAclProbeForTest() }) + afterEach(() => { + resetWindowsSecurityLoadForTests() + }) + it('reports a clean DACL as unpoisoned', async () => { - const data = await probe({ - spawnFn: fakeSpawn(() => []).spawnFn - }) + installFakeDacls(() => []) + const data = await probe() expect(data.name).toBe(WINDOWS_INSTALL_DIR_ACL_BREADCRUMB) expect(data.status).toBe('ok') expect(data.orphanPackageSidCount).toBe(0) @@ -102,31 +106,20 @@ describe('probeWindowsInstallDirAcl', () => { }) it('does not let a grant on one target mask its absence on another', async () => { - const data = await probe({ - spawnFn: fakeSpawn((target) => - target.endsWith('ffmpeg.dll') ? [ORPHAN] : [RESTRICTED_GRANT, ORPHAN] - ).spawnFn - }) + installFakeDacls((target) => + target.endsWith('ffmpeg.dll') ? [ORPHAN] : [RESTRICTED_GRANT, ORPHAN] + ) + const data = await probe() expect(data.hasWellKnownPackageGrant).toBe(true) expect(data.matchesPoisonSignature).toBe(true) }) - // zh-CN/ja-JP/ko-KR icacls keeps "NT AUTHORITY" English but translates the package - // names and summary. A tree the repair just fixed read as poisoned AND reliable there, - // re-arming the pre-window repair and blaming the install on every launch. - it('reads a repaired tree as clean when icacls translates package names', async () => { - const zhDisplay = (target: string): string => - [ - `${target} S-1-15-2-999-999-999:(OI)(CI)(RX)`, - ' APPLICATION PACKAGE AUTHORITY\\所有受限制的应用程序包:(OI)(CI)(RX)', - ' NT AUTHORITY\\SYSTEM:(I)(OI)(CI)(F)', - ' BUILTIN\\Administrators:(I)(OI)(CI)(F)', - '', - '已成功处理 1 个文件; 处理 0 个文件时失败' - ].join('\r\n') + // Display listings localize package names (zh/ja/ko); SDDL never does, so a repaired + // tree must read clean whatever the UI language. + it('reads a repaired tree as clean from SIDs alone', async () => { let verdict: CrashReportBreadcrumbData = {} + installFakeDacls(() => [ORPHAN, RESTRICTED_GRANT]) const data = await probe({ - spawnFn: fakeSpawn(() => [ORPHAN, RESTRICTED_GRANT], zhDisplay).spawnFn, onDone: (done) => (verdict = done) }) expect(isInstallDirAclPoisonVerdict(verdict)).toBe(false) @@ -149,43 +142,31 @@ describe('probeWindowsInstallDirAcl', () => { }) it('probes a content file, not just the directory object', async () => { - const fake = fakeSpawn(() => [ORPHAN]) - await probe({ spawnFn: fake.spawnFn }) - expect(fake.calls.map((c) => c.args[0])).toEqual([INSTALL_DIR, join(INSTALL_DIR, 'ffmpeg.dll')]) + const fake = installFakeDacls(() => [ORPHAN]) + await probe() + expect(fake.calls).toEqual([INSTALL_DIR, join(INSTALL_DIR, 'ffmpeg.dll')]) }) - it('only saves the DACL to a temp file: no recursive or ACL-writing flag', async () => { - const fake = fakeSpawn(() => [ORPHAN]) - await probe({ spawnFn: fake.spawnFn }) - for (const call of fake.calls) { - expect(call.args).toHaveLength(3) - expect(call.args[0]).not.toMatch(/^\//) - expect(call.args[1]).toBe('/save') - expect(call.args[2].startsWith(tmpdir())).toBe(true) - expect(existsSync(call.args[2])).toBe(false) - } - }) - - // Verbatim `icacls /save` / `icacls /save` output from win32 10.0.26200. + // Real DACLs captured on win32 10.0.26200, as the addon reads them (DACL-only SDDL). it.each([ [ 'a repaired module file', - 'ffmpeg.dll\r\nD:AI(A;;0x1200a9;;;S-1-15-2-2)(A;;0x1200a9;;;S-1-15-2-999-999-999)' + + 'D:AI(A;;0x1200a9;;;S-1-15-2-2)(A;;0x1200a9;;;S-1-15-2-999-999-999)' + '(A;ID;0x1200a9;;;S-1-15-2-999-999-999)(A;ID;FA;;;SY)(A;ID;FA;;;BA)' + - '(A;ID;FA;;;S-1-5-21-432636774-4279371817-3971399515-1001)\r\n', + '(A;ID;FA;;;S-1-5-21-432636774-4279371817-3971399515-1001)', false ], [ 'a poisoned dir whose AC ACE denies', - 'scan21acl\r\nD:AI(D;;0x100116;;;AC)(A;OICI;0x1200a9;;;S-1-15-2-999-999-999)' + + 'D:AI(D;;0x100116;;;AC)(A;OICI;0x1200a9;;;S-1-15-2-999-999-999)' + '(A;OICIID;FA;;;SY)(A;OICIID;FA;;;BA)' + - '(A;OICIID;FA;;;S-1-5-21-432636774-4279371817-3971399515-1001)\r\n', + '(A;OICIID;FA;;;S-1-5-21-432636774-4279371817-3971399515-1001)', true ] - ])('reads a real icacls /save capture: %s', async (_label, saved, poisoned) => { + ])('reads a real captured DACL: %s', async (_label, saved, poisoned) => { + installFakeDacls(() => saved) const data = await probe({ - fileExists: () => false, - spawnFn: fakeSpawn(() => Buffer.from(saved, 'utf16le')).spawnFn + fileExists: () => false }) expect(data.orphanPackageSids).toBe('S-1-15-2-999-999-999') expect(data.matchesPoisonSignature).toBe(poisoned) @@ -193,13 +174,13 @@ describe('probeWindowsInstallDirAcl', () => { it('keeps a conditional DACL unreadable instead of hiding a later package grant', async () => { const saved = - 'orca\r\nD:AI(A;OICI;0x1200a9;;;S-1-15-2-999-999-999)' + + 'D:AI(A;OICI;0x1200a9;;;S-1-15-2-999-999-999)' + '(XA;OICI;FA;;;WD;(@User.Department == "Finance"))' + - '(A;OICIID;0x1200a9;;;S-1-15-2-2)\r\n' + '(A;OICIID;0x1200a9;;;S-1-15-2-2)' let verdict: CrashReportBreadcrumbData = {} + installFakeDacls(() => saved) const data = await probe({ fileExists: () => false, - spawnFn: fakeSpawn(() => Buffer.from(saved, 'utf16le')).spawnFn, onDone: (done) => (verdict = done) }) expect(data).toMatchObject({ status: 'failed', reason: 'all-targets-unreadable' }) @@ -207,45 +188,60 @@ describe('probeWindowsInstallDirAcl', () => { expect(isInstallDirAclPoisonVerdict(verdict)).toBe(false) }) - it.each([1, null])('ignores a saved DACL when icacls exits with %s', async (exitCode) => { - const fake = fakeSpawn(() => [ORPHAN], undefined, exitCode) + // A NULL DACL grants everyone everything, so it says nothing about which package grant applies. + it('keeps a NULL DACL unreadable rather than reading it as clean', async () => { + installFakeDacls(() => 'D:NO_ACCESS_CONTROL') + const data = await probe({ fileExists: () => false }) + expect(data).toMatchObject({ status: 'failed', reason: 'all-targets-unreadable' }) + }) + + it('treats a read that fails as unreadable, never as a clean DACL', async () => { let verdict: CrashReportBreadcrumbData = {} + installFakeDacls(() => null) const data = await probe({ fileExists: () => false, - spawnFn: fake.spawnFn, onDone: (done) => (verdict = done) }) expect(data).toMatchObject({ status: 'failed', reason: 'all-targets-unreadable' }) expect(isInstallDirAclPoisonVerdict(verdict)).toBe(false) - expect(fake.calls.every((call) => !existsSync(call.args[2]))).toBe(true) }) it('does not report malformed saved output as a clean DACL', async () => { + installFakeDacls(() => 'orca') const data = await probe({ - fileExists: () => false, - spawnFn: fakeSpawn(() => Buffer.from('orca\r\n', 'utf16le')).spawnFn + fileExists: () => false }) expect(data).toMatchObject({ status: 'failed', reason: 'all-targets-unreadable' }) expect(data.matchesPoisonSignature).toBeUndefined() }) - it('records a failure instead of throwing when icacls cannot be read', async () => { - const data = await probe({ spawnFn: fakeSpawn(() => null).spawnFn }) + it('records a failure instead of throwing when no DACL can be read', async () => { + installFakeDacls(() => null) + const data = await probe() expect(data.status).toBe('failed') expect(data.reason).toBe('all-targets-unreadable') expect(data.matchesPoisonSignature).toBeUndefined() }) + // A missing addon is a packaging fault, not a clean tree: the probe records it and moves on. + it('records a failure, not a verdict, when the addon cannot load', async () => { + setWindowsSecurityForTests(null) + let verdict: CrashReportBreadcrumbData = {} + const data = await probe({ onDone: (done) => (verdict = done) }) + expect(data.status).toBe('failed') + expect(String(data.reason)).toContain('windows-security addon unavailable') + expect(isInstallDirAclPoisonVerdict(verdict)).toBe(false) + }) + it.each([ ['darwin', { platform: 'darwin' as NodeJS.Platform }], ['serve mode', { platform: 'win32' as NodeJS.Platform, isServeMode: true }] ])('does no work on %s', async (_label, options) => { - const fake = fakeSpawn(() => [ORPHAN]) + const fake = installFakeDacls(() => [ORPHAN]) const record = vi.fn() const fileExists = vi.fn(() => true) probeWindowsInstallDirAcl({ installDir: INSTALL_DIR, - spawnFn: fake.spawnFn, fileExists, recordBreadcrumb: record, ...options @@ -257,10 +253,10 @@ describe('probeWindowsInstallDirAcl', () => { }) it('runs once per process', async () => { - const fake = fakeSpawn(() => [ORPHAN]) - await probe({ spawnFn: fake.spawnFn }) + const fake = installFakeDacls(() => [ORPHAN]) + await probe() const before = fake.calls.length - probeWindowsInstallDirAcl({ platform: 'win32', installDir: INSTALL_DIR, spawnFn: fake.spawnFn }) + probeWindowsInstallDirAcl({ platform: 'win32', installDir: INSTALL_DIR }) await new Promise((resolve) => setImmediate(resolve)) expect(fake.calls).toHaveLength(before) }) diff --git a/src/main/startup/windows-install-dir-acl-probe.ts b/src/main/startup/windows-install-dir-acl-probe.ts index a717c87c241..095949baa4f 100644 --- a/src/main/startup/windows-install-dir-acl-probe.ts +++ b/src/main/startup/windows-install-dir-acl-probe.ts @@ -1,14 +1,17 @@ -import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { existsSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' import { sanitizeCrashReportString, type CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' -import { getIcaclsExePath } from '../win32-utils' +import { loadWindowsSecurity, type WindowsDacl } from '../../shared/windows-security-addon' +import { + ACCESS_ALLOWED_ACE_TYPE, + ACCESS_DENIED_ACE_TYPE, + INHERIT_ONLY_ACE +} from '../../shared/windows-secure-dacl' +import { RESTRICTED_APP_PACKAGES_SID } from './windows-install-dir-package-acl-grant' /** * Read-only DACL probe for the win32 install directory. @@ -36,25 +39,18 @@ export const WINDOWS_INSTALL_DIR_ACL_BREADCRUMB = 'windows_install_dir_acl' // one shipped module answers it, and existsSync costs nothing. const MODULE_SHORTLIST = ['ffmpeg.dll', 'libGLESv2.dll', 'libEGL.dll', 'icudtl.dat'] -const PROBE_BUDGET_MS = 5_000 - -// Why SDDL (`icacls /save`) rather than icacls's display: the display localizes -// the well-known package names — and on zh/ja/ko even keeps "NT AUTHORITY" English -// while doing so — so a repaired tree read as poisoned. SDDL prints SIDs on every locale. +// Why raw SIDs rather than a display listing: displays localize the well-known package +// names — and on zh/ja/ko even keep "NT AUTHORITY" English while doing so — so a +// repaired tree read as poisoned. A SID reads the same on every locale. const PACKAGE_SID = /^S-1-15-2-[0-9-]+$/i -/** ALL RESTRICTED APPLICATION PACKAGES: the grant the reproduced remedy added. */ -const RESTRICTED_PACKAGES_SID = 'S-1-15-2-2' -/** `AC` is SDDL's alias for ALL APPLICATION PACKAGES (S-1-15-2-1). */ -const WELL_KNOWN_PACKAGE_SIDS = new Set(['AC', 'S-1-15-2-1', RESTRICTED_PACKAGES_SID]) -/** (type;flags;rights;object;inheritedObject;sid[;condition]) */ -const SDDL_ACE = /\(([A-Z]+);([A-Z]*);[^;()]*;[^;()]*;[^;()]*;([^;()]+)[;)]/gi +/** ALL APPLICATION PACKAGES and ALL RESTRICTED APPLICATION PACKAGES. */ +const WELL_KNOWN_PACKAGE_SIDS = new Set(['S-1-15-2-1', RESTRICTED_APP_PACKAGES_SID]) export type WindowsInstallDirAclProbeOptions = { platform?: NodeJS.Platform isServeMode?: boolean installDir?: string /** Test seams. */ - spawnFn?: typeof spawn fileExists?: (path: string) => boolean recordBreadcrumb?: typeof recordDurableCrashBreadcrumb onDone?: (data: CrashReportBreadcrumbData) => void @@ -66,74 +62,49 @@ type AclFacts = { hasRestrictedPackageGrant: boolean } -function readSavedDacl(spawnFn: typeof spawn, target: string, deadlineMs: number): Promise { - const saveFile = join(tmpdir(), `orca-install-acl-${randomUUID()}.txt`) - return new Promise((resolve) => { - // /save only reads the target (it writes the temp file). Never /T — a recursive - // walk on a real profile measured 62s and timed out (see windows-user-data-acl.ts). - const child = spawnFn(getIcaclsExePath(), [target, '/save', saveFile], { - stdio: ['ignore', 'ignore', 'ignore'], - windowsHide: true - }) - let settled = false - const settle = (read: boolean): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - let out = '' - try { - // An empty read parses as a clean DACL, so a failed /save must stay ''. - out = read ? readFileSync(saveFile, 'utf16le') : '' - } catch { - out = '' - } - try { - // A killed icacls may still hold the file; a leftover temp file is harmless. - rmSync(saveFile, { force: true }) - } catch { - // Nothing to do. - } - resolve(out) - } - const timer = setTimeout(() => { - child.kill() - settle(false) - }, deadlineMs) - timer.unref?.() - child.on('error', () => settle(false)) - child.on('close', (code) => settle(code === 0)) - }) +/** The target's DACL, or null when it cannot be read (an unreadable target stays unreadable). */ +function readDacl(target: string): WindowsDacl | null { + const security = loadWindowsSecurity() + if ('error' in security) { + throw new Error(`windows-security addon unavailable: ${security.error}`) + } + try { + return security.addon.getDaclAces(target) + } catch { + return null + } } -function collectAclFacts(savedDacl: string): AclFacts | null { +function collectAclFacts(dacl: WindowsDacl | null): AclFacts | null { + // Fails closed: an unreadable target, a NULL DACL, or any ACE that is not a plain allow/deny + // gives no facts, since a skipped ACE could be the grant that satisfies an orphan. + if ( + !dacl || + dacl.aces.some( + (ace) => ace.type !== ACCESS_ALLOWED_ACE_TYPE && ace.type !== ACCESS_DENIED_ACE_TYPE + ) + ) { + return null + } const orphanPackageSids: string[] = [] let hasWellKnownPackageGrant = false let hasRestrictedPackageGrant = false - // A partial DACL can hide a later grant; unsupported ACEs must stay unreadable. - const dacl = /^D:[A-Z]*((?:\([^()]*\))*)(?:S:.*)?$/im.exec(savedDacl) - if (!dacl) { - return null - } - for (const [, type, flags, rawSid] of dacl[1].matchAll(SDDL_ACE)) { - const sid = rawSid.toUpperCase() + for (const ace of dacl.aces) { + const sid = ace.sid ?? '' if (!WELL_KNOWN_PACKAGE_SIDS.has(sid)) { if (PACKAGE_SID.test(sid)) { - orphanPackageSids.push(rawSid) + orphanPackageSids.push(sid) } continue } // Why: an ACE that denies, or only propagates to children (IO), grants nothing // on this object — so it cannot satisfy an orphan the way the reproduced fix did. - const flagTokens: string[] = flags.toUpperCase().match(/../g) ?? [] - const inheritOnly = flagTokens.includes('IO') - if (type.toUpperCase() !== 'A' || inheritOnly) { + if (ace.type !== ACCESS_ALLOWED_ACE_TYPE || (ace.flags & INHERIT_ONLY_ACE) !== 0) { continue } hasWellKnownPackageGrant = true // Reported, never the verdict: narrows which grant is present for triage. - if (sid === RESTRICTED_PACKAGES_SID) { + if (sid === RESTRICTED_APP_PACKAGES_SID) { hasRestrictedPackageGrant = true } } @@ -145,20 +116,13 @@ function resolveTargets(installDir: string, fileExists: (path: string) => boolea return moduleFile ? [installDir, moduleFile] : [installDir] } -async function runProbe(options: WindowsInstallDirAclProbeOptions): Promise { +function runProbe(options: WindowsInstallDirAclProbeOptions): void { const record = options.recordBreadcrumb ?? recordDurableCrashBreadcrumb let data: CrashReportBreadcrumbData try { const installDir = options.installDir ?? dirname(process.execPath) const targets = resolveTargets(installDir, options.fileExists ?? existsSync) - const spawnFn = options.spawnFn ?? spawn - const startedAt = Date.now() - const outputs: string[] = [] - for (const target of targets) { - const remaining = PROBE_BUDGET_MS - (Date.now() - startedAt) - // Why one shared budget: two targets must never cost two full timeouts. - outputs.push(remaining > 0 ? await readSavedDacl(spawnFn, target, remaining) : '') - } + const outputs = targets.map(readDacl) const facts = outputs.map(collectAclFacts).filter((fact) => fact !== null) const orphans = [...new Set(facts.flatMap((f) => f.orphanPackageSids))] const hasWellKnownPackageGrant = facts.some((f) => f.hasWellKnownPackageGrant) @@ -201,7 +165,7 @@ export function resetWindowsInstallDirAclProbeForTest(): void { } /** - * Fire-and-forget; returns before any spawn. win32 only — no spawn and no fs I/O + * Fire-and-forget; returns before any read. win32 only — no ACL read and no fs I/O * anywhere else. Called from openMainWindow, which runs after initObservability, * so the durable record also emits a span into the diagnostics bundle. * @@ -220,7 +184,11 @@ export function probeWindowsInstallDirAcl(options: WindowsInstallDirAclProbeOpti // propagates into window creation. A diagnostic must never be able to do that. try { setImmediate(() => { - void runProbe(options).catch(() => undefined) + try { + runProbe(options) + } catch { + // A diagnostic, and a throwing recorder or onDone has nowhere left to report. + } }) } catch { // Nothing left to report to that would not throw again. diff --git a/src/main/startup/windows-install-dir-acl-recovery.test.ts b/src/main/startup/windows-install-dir-acl-recovery.test.ts index bc358d92c34..2f99bed5958 100644 --- a/src/main/startup/windows-install-dir-acl-recovery.test.ts +++ b/src/main/startup/windows-install-dir-acl-recovery.test.ts @@ -1,9 +1,9 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetWindowsSecurityLoadForTests } from '../../shared/windows-security-addon' -import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { readActiveGpuFallbackMarker, writeGpuFallbackMarker } from './gpu-fallback-marker' import { @@ -30,43 +30,48 @@ import { WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE, WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION } from './windows-install-dir-package-acl-repair' +import type { InstallDirPackageGrantOutcome } from './windows-install-dir-package-acl-grant' import { ALL_PACKAGES_ACE, - fakeIcaclsSpawn, + grantsFrom, + installFakeDacls, ORPHAN_PACKAGE_ACE, - RESTRICTED_PACKAGES_ACE + RESTRICTED_PACKAGES_ACE, + type InstallDirPackageGrant, + type InstallDirPackageGrantPass } from './windows-install-dir-acl.test-fixture' const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca' const APP_VERSION = '1.4.184' -type Runner = (spec: ProcessSpec) => Promise +type Runner = InstallDirPackageGrant + +const OK: InstallDirPackageGrantOutcome = { ok: true, failedFileCount: 0 } +const DENIED: InstallDirPackageGrantOutcome = { + ok: false, + failedFileCount: 1, + reason: 'Access is denied.' +} /** * Drives the production path: the real probe hands its verdict to the real gate, - * which decides whether icacls ever runs. Only the two process seams are faked. + * which decides whether a grant ever runs. Only the DACL read and the grant are faked. */ function probeThenRecover( savedAces: (target: string) => string[], options: { failRepair?: boolean } = {} -): Promise { - const specs: ProcessSpec[] = [] - const runProcessFn = async (spec: ProcessSpec): Promise => { - specs.push(spec) - return { - code: options.failRepair === true ? 5 : 0, - signal: null, - stdout: 'Successfully processed 81 files; Failed processing 0 files', - stderr: '', - timedOut: false - } +): Promise { + const specs: InstallDirPackageGrantPass[] = [] + const grantFn: Runner = async (_installDir, pass) => { + specs.push(pass) + return options.failRepair === true ? DENIED : OK } + installFakeDacls(savedAces) return new Promise((resolve) => { probeWindowsInstallDirAcl({ platform: 'win32', installDir: INSTALL_DIR, fileExists: (path) => path.endsWith('ffmpeg.dll'), - spawnFn: fakeIcaclsSpawn(savedAces).spawnFn, recordBreadcrumb: () => undefined, onDone: (data) => { startWindowsInstallDirAclRepairIfPoisoned(data, { @@ -74,11 +79,11 @@ function probeThenRecover( installDir: INSTALL_DIR, appVersion: APP_VERSION, userDataPath: mkdtempSync(join(tmpdir(), 'orca-acl-recovery-')), - runProcessFn: runProcessFn as never, + grants: grantsFrom(grantFn), recordBreadcrumb: () => undefined }) // Longer than the repair's own setImmediate hop, so a repair that was - // started has always spawned by the time this resolves. + // started has always granted by the time this resolves. setTimeout(() => resolve(specs), 25) } }) @@ -94,10 +99,7 @@ describe('startWindowsInstallDirAclRepairIfPoisoned', () => { it('repairs when the probe sees an orphan package ACE and no well-known grant', async () => { const specs = await probeThenRecover(() => [ORPHAN_PACKAGE_ACE]) - expect(specs.map((spec) => spec.args?.[2])).toEqual([ - '*S-1-15-2-2:(OI)(CI)(RX)', - '*S-1-15-2-2:(RX)' - ]) + expect(specs).toEqual(['root', 'tree']) }) // Orphan + the Program Files ALL APPLICATION PACKAGES default launched clean on @@ -116,13 +118,13 @@ describe('startWindowsInstallDirAclRepairIfPoisoned', () => { }) it('does not act when the probe could not read the DACL', async () => { - const specs = await new Promise((resolve) => { - const collected: ProcessSpec[] = [] + installFakeDacls(() => null) + const specs = await new Promise((resolve) => { + const collected: InstallDirPackageGrantPass[] = [] probeWindowsInstallDirAcl({ platform: 'win32', installDir: INSTALL_DIR, fileExists: () => false, - spawnFn: fakeIcaclsSpawn(() => null).spawnFn, recordBreadcrumb: () => undefined, onDone: (data) => { startWindowsInstallDirAclRepairIfPoisoned(data, { @@ -130,10 +132,10 @@ describe('startWindowsInstallDirAclRepairIfPoisoned', () => { installDir: INSTALL_DIR, appVersion: APP_VERSION, userDataPath: mkdtempSync(join(tmpdir(), 'orca-acl-recovery-')), - runProcessFn: (async (spec: ProcessSpec) => { - collected.push(spec) + grants: grantsFrom(async (_installDir, pass) => { + collected.push(pass) throw new Error('unreachable') - }) as never, + }), recordBreadcrumb: () => undefined }) setTimeout(() => resolve(collected), 25) @@ -172,7 +174,7 @@ describe('describeInstallDirAclPoison', () => { expect(failed?.detail).toContain(`icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(RX)" /T /C`) }) - it('reports the repair as in flight before icacls has answered', () => { + it('reports the repair as in flight before the grant has answered', () => { startWindowsInstallDirAclRepairIfPoisoned( { status: 'ok', matchesPoisonSignature: true }, { @@ -180,7 +182,7 @@ describe('describeInstallDirAclPoison', () => { installDir: INSTALL_DIR, appVersion: APP_VERSION, userDataPath: mkdtempSync(join(tmpdir(), 'orca-acl-recovery-')), - runProcessFn: (() => new Promise(() => undefined)) as never, + grants: grantsFrom(() => new Promise(() => undefined)), recordBreadcrumb: () => undefined } ) @@ -200,19 +202,12 @@ function recoveryOptions(userDataPath: string, run: Runner): WindowsInstallDirAc installDir: INSTALL_DIR, appVersion: APP_VERSION, userDataPath, - runProcessFn: run as never, + grants: grantsFrom(run), recordBreadcrumb: () => undefined } } -/** icacls' real success summary, as the repair's parser expects it. */ -const okRun: Runner = async () => ({ - code: 0, - signal: null, - stdout: 'Successfully processed 3200 files; Failed processing 0 files', - stderr: '', - timedOut: false -}) +const okRun: Runner = async () => OK describe('install-dir ACL repair vs the GPU safe-graphics marker', () => { beforeEach(() => { @@ -235,7 +230,7 @@ describe('install-dir ACL repair vs the GPU safe-graphics marker', () => { await new Promise((resolve) => { startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, { ...recoveryOptions(userDataPath, okRun), - // Settles after the repair's own setImmediate hop and its two icacls passes. + // Settles after the repair's own setImmediate hop and its two grant passes. recordBreadcrumb: () => { setTimeout(resolve, 0) return undefined @@ -305,13 +300,7 @@ describe('isInstallDirAclSuspect', () => { }) it('holds through a repair that failed, and releases once one succeeds', async () => { - const failing: Runner = async () => ({ - code: 5, - signal: null, - stdout: '', - stderr: 'Access is denied.', - timedOut: false - }) + const failing: Runner = async () => DENIED const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-suspect-')) startWindowsInstallDirAclRepairIfPoisoned( POISON_VERDICT, @@ -339,11 +328,11 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { resetWindowsInstallDirAclRecoveryForTest() }) - it('costs a healthy machine one absent-file read and no icacls', async () => { - const specs: ProcessSpec[] = [] - const run: Runner = async (spec) => { - specs.push(spec) - return okRun(spec) + it('costs a healthy machine one absent-file read and no grant', async () => { + const specs: InstallDirPackageGrantPass[] = [] + const run: Runner = async (_installDir, pass) => { + specs.push(pass) + return OK } const mode = await repairKnownPoisonedInstallDirBeforeWindow( recoveryOptions(mkdtempSync(join(tmpdir(), 'orca-acl-gate-')), run) @@ -353,13 +342,13 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { }) // The crash this fixes: launch 1 detects the poison but createMainWindow already - // ran, so the renderer is dead before icacls is spawned. Launch 2 must not repeat it. + // ran, so the renderer is dead before the grant runs. Launch 2 must not repeat it. it('repairs a launch that a previous one recorded as poisoned, before returning', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-')) // Launch 1: the probe reports poison and the app dies mid-repair. startWindowsInstallDirAclRepairIfPoisoned( POISON_VERDICT, - recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + recoveryOptions(userDataPath, () => new Promise(() => undefined)) ) expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) @@ -371,18 +360,15 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { { engagedAt: Date.now(), crashesInWindow: 3, userConfirmed: false }, GPU_ENV ) - const specs: ProcessSpec[] = [] - const run: Runner = async (spec) => { - specs.push(spec) - return okRun(spec) + const specs: InstallDirPackageGrantPass[] = [] + const run: Runner = async (_installDir, pass) => { + specs.push(pass) + return OK } const mode = await repairKnownPoisonedInstallDirBeforeWindow(recoveryOptions(userDataPath, run)) expect(mode).toBe('repaired') // Both passes have already run by the time the window may be created. - expect(specs.map((spec) => spec.args?.[2])).toEqual([ - '*S-1-15-2-2:(OI)(CI)(RX)', - '*S-1-15-2-2:(RX)' - ]) + expect(specs).toEqual(['root', 'tree']) expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(false) expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)).toBeNull() }) @@ -391,13 +377,13 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-')) startWindowsInstallDirAclRepairIfPoisoned( POISON_VERDICT, - recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + recoveryOptions(userDataPath, () => new Promise(() => undefined)) ) resetWindowsInstallDirAclRecoveryForTest() resetWindowsInstallDirAclRepairForTest() const mode = await repairKnownPoisonedInstallDirBeforeWindow({ - ...recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner), + ...recoveryOptions(userDataPath, () => new Promise(() => undefined)), timeoutMs: 20 }) expect(mode).toBe('timeout') @@ -407,7 +393,7 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-')) startWindowsInstallDirAclRepairIfPoisoned( POISON_VERDICT, - recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + recoveryOptions(userDataPath, () => new Promise(() => undefined)) ) resetWindowsInstallDirAclRecoveryForTest() resetWindowsInstallDirAclRepairForTest() @@ -430,7 +416,7 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-')) startWindowsInstallDirAclRepairIfPoisoned( POISON_VERDICT, - recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + recoveryOptions(userDataPath, () => new Promise(() => undefined)) ) expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) @@ -448,7 +434,7 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-')) startWindowsInstallDirAclRepairIfPoisoned( POISON_VERDICT, - recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + recoveryOptions(userDataPath, () => new Promise(() => undefined)) ) resetWindowsInstallDirAclRecoveryForTest() startWindowsInstallDirAclRepairIfPoisoned( @@ -459,8 +445,8 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { }) // The gate-timed-out ordering. The gate's budget is 20s while the tree grant's own cap is - // 120s, so icacls routinely outlives the gate: the window opens, and this launch's probe - // reads the tree POISONED while that repair is still in flight. When the orphaned icacls + // 120s, so the grant routinely outlives the gate: the window opens, and this launch's probe + // reads the tree POISONED while that repair is still in flight. When the orphaned grant // then claims success -- exit 0, and on a localized Windows no parsable failure summary to // contradict it -- the claim must not outrank a reading taken after it was dispatched. // Otherwise this launch deletes the poison marker that arms every later gate, un-suspects @@ -485,9 +471,9 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { }) const mode = await repairKnownPoisonedInstallDirBeforeWindow({ - ...recoveryOptions(userDataPath, async (spec) => { + ...recoveryOptions(userDataPath, async () => { await stalled - return okRun(spec) + return OK }), recordBreadcrumb: () => { setTimeout(repairReported, 0) @@ -510,7 +496,7 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { expect(readActiveGpuFallbackMarker(userDataPath, GPU_ENV)).not.toBeNull() }) - // The other ordering of the same two events: the orphaned icacls exits 0 and clears the + // The other ordering of the same two events: the orphaned grant succeeds and clears the // safe-graphics marker BEFORE the probe reads the tree still poisoned. The disproof must // give the marker back, or the two orderings disagree about the same launch. it('restores the safe-graphics marker when the probe disproves a timed-out gate repair', async () => { @@ -532,9 +518,9 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => { }) const mode = await repairKnownPoisonedInstallDirBeforeWindow({ - ...recoveryOptions(userDataPath, async (spec) => { + ...recoveryOptions(userDataPath, async () => { await stalled - return okRun(spec) + return OK }), recordBreadcrumb: () => { setTimeout(repairReported, 0) @@ -581,13 +567,13 @@ describe('a repair marker recording a completed repair', () => { } // The three-launch shape the gate exists for, and the one it used to disarm itself on: - // launch 1 repairs; the tree is re-poisoned (an installer, AV, or an icacls run that + // launch 1 repairs; the tree is re-poisoned (an installer, AV, or a grant that // silently no-opped); launch 2's probe records the poison but Chromium FATALs before the // repair can write its marker. Launch 3's gate then meets a poison marker and a repair - // marker claiming success. Treating that as 'repaired' ran no icacls, deleted the poison + // marker claiming success. Treating that as 'repaired' ran no grant, deleted the poison // marker so no later gate ever fires again, un-suspected the tree so --in-process-gpu // could engage, and told the user their permissions were fixed. - it('re-runs icacls when a poison marker outlives it', async () => { + it('re-runs the grant when a poison marker outlives it', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-repaired-hit-')) // Launch 1: the gate repairs the tree and retires the poison marker. @@ -601,21 +587,18 @@ describe('a repair marker recording a completed repair', () => { resetWindowsInstallDirAclRepairForTest() startWindowsInstallDirAclRepairIfPoisoned( POISON_VERDICT, - recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + recoveryOptions(userDataPath, () => new Promise(() => undefined)) ) expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) // Launch 3: the gate must repair, not congratulate itself on launch 1's work. - const spent: ProcessSpec[] = [] - const mode = await gateLaunch(userDataPath, async (spec) => { - spent.push(spec) - return { code: 5, signal: null, stdout: '', stderr: 'Access is denied.', timedOut: false } + const spent: InstallDirPackageGrantPass[] = [] + const mode = await gateLaunch(userDataPath, async (_installDir, pass) => { + spent.push(pass) + return DENIED }) expect(mode).toBe('failed') - expect(spent.map((spec) => spec.args?.[2])).toEqual([ - '*S-1-15-2-2:(OI)(CI)(RX)', - '*S-1-15-2-2:(RX)' - ]) + expect(spent).toEqual(['root', 'tree']) expect(isInstallDirAclSuspect()).toBe(true) expect(describeInstallDirAclPoison()?.detail).toContain('could not repair them') expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) @@ -637,10 +620,10 @@ describe('a repair marker recording a completed repair', () => { }) ) writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) - const spent: ProcessSpec[] = [] - const mode = await gateLaunch(userDataPath, async (spec) => { - spent.push(spec) - return okRun(spec) + const spent: InstallDirPackageGrantPass[] = [] + const mode = await gateLaunch(userDataPath, async (_installDir, pass) => { + spent.push(pass) + return OK }) expect(mode).toBe('marker-hit') expect(spent).toHaveLength(0) @@ -652,9 +635,9 @@ describe('a repair marker recording a completed repair', () => { }) // The probe reads the tree AFTER the pre-window gate has finished with it, so a signature - // still matching means the repair never landed however icacls exited. + // still matching means the repair never landed however the grant reported. it('is overruled by a probe that reads the tree poisoned after the gate repaired it', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-noop-icacls-')) + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-noop-grant-')) writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) expect( await repairKnownPoisonedInstallDirBeforeWindow(recoveryOptions(userDataPath, okRun)) @@ -669,7 +652,7 @@ describe('a repair marker recording a completed repair', () => { expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) }) - // The gate's 'repaired' is icacls's exit claim, not a reading of the tree — and the GPU + // The gate's 'repaired' is the grant's own claim, not a reading of the tree — and the GPU // children die 48-1373ms after window creation while the probe answers 0.9-3.0s in. // Un-suspecting the tree on the claim alone opens exactly that interval to // --in-process-gpu on a tree safe graphics cannot rescue. @@ -718,11 +701,11 @@ describe('a repair marker recording a completed repair', () => { }) // The opposite evidence: the probe has just READ this tree and found it poisoned, so a - // marker claiming success describes a tree that was re-poisoned, or an icacls run that - // silently no-opped. Reporting 'repaired' there runs no icacls, deletes the poison marker + // marker claiming success describes a tree that was re-poisoned, or a grant that + // silently no-opped. Reporting 'repaired' there runs no grant, deletes the poison marker // that arms the next launch's gate, un-suspects the tree so --in-process-gpu can engage, // and tells the user their permissions are fixed. - it('re-runs icacls when a fresh probe verdict contradicts the repaired marker', async () => { + it('re-runs the grant when a fresh probe verdict contradicts the repaired marker', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-repoisoned-')) writeInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION) expect( @@ -732,10 +715,10 @@ describe('a repair marker recording a completed repair', () => { // Next launch: the gate is disarmed, and the probe reads the same tree as poisoned. resetWindowsInstallDirAclRecoveryForTest() resetWindowsInstallDirAclRepairForTest() - const spent: ProcessSpec[] = [] - const failing: Runner = async (spec) => { - spent.push(spec) - return { code: 5, signal: null, stdout: '', stderr: 'Access is denied.', timedOut: false } + const spent: InstallDirPackageGrantPass[] = [] + const failing: Runner = async (_installDir, pass) => { + spent.push(pass) + return DENIED } await new Promise((resolve) => { startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, { @@ -747,17 +730,14 @@ describe('a repair marker recording a completed repair', () => { }) }) - expect(spent.map((spec) => spec.args?.[2])).toEqual([ - '*S-1-15-2-2:(OI)(CI)(RX)', - '*S-1-15-2-2:(RX)' - ]) + expect(spent).toEqual(['root', 'tree']) expect(isInstallDirAclSuspect()).toBe(true) expect(hasInstallDirAclPoisonMarker(userDataPath, INSTALL_DIR, APP_VERSION)).toBe(true) expect(describeInstallDirAclPoison()?.detail).toContain('could not repair them') }) // The contradiction re-opens the budget, it does not remove it: a tree that has spent - // every attempt must not re-spawn icacls on every launch forever. + // every attempt must not re-walk the tree on every launch forever. it('still stops at the attempt budget when the probe keeps reporting poison', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-repoisoned-budget-')) writeFileSync( @@ -771,10 +751,10 @@ describe('a repair marker recording a completed repair', () => { attempts: 3 }) ) - const spent: ProcessSpec[] = [] - const run: Runner = async (spec) => { - spent.push(spec) - return okRun(spec) + const spent: InstallDirPackageGrantPass[] = [] + const run: Runner = async (_installDir, pass) => { + spent.push(pass) + return OK } await new Promise((resolve) => { startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, { @@ -818,7 +798,7 @@ describe('a clean probe verdict', () => { ) startWindowsInstallDirAclRepairIfPoisoned( POISON_VERDICT, - recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + recoveryOptions(userDataPath, () => new Promise(() => undefined)) ) resetWindowsInstallDirAclRecoveryForTest() resetWindowsInstallDirAclRepairForTest() @@ -840,13 +820,7 @@ describe('a clean probe verdict', () => { // repair with nothing left to fix must not re-accuse an install just read clean. it('outranks a repair verdict that lands after it', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-clean-')) - const failing: Runner = async () => ({ - code: 5, - signal: null, - stdout: '', - stderr: 'Access is denied.', - timedOut: false - }) + const failing: Runner = async () => DENIED let repairSettled = false startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, { ...recoveryOptions(userDataPath, failing), @@ -876,11 +850,11 @@ describe('the probe-pending grace window', () => { // once-per-process, so a re-arm would wait 15s on a verdict that already landed // and drop every GPU child crash in between. it('is armed by a dispatched probe only, so a reopen cannot re-arm it', async () => { + installFakeDacls(() => [RESTRICTED_PACKAGES_ACE]) const probeArgs = { platform: 'win32' as const, installDir: INSTALL_DIR, fileExists: () => false, - spawnFn: fakeIcaclsSpawn(() => [RESTRICTED_PACKAGES_ACE]).spawnFn, recordBreadcrumb: () => undefined } let settleVerdict: () => void = () => undefined @@ -928,16 +902,16 @@ describe('isBlockingInstallDirAclRepairInFlight', () => { startWindowsInstallDirAclRepairIfPoisoned( POISON_VERDICT, - recoveryOptions(userDataPath, (() => new Promise(() => undefined)) as Runner) + recoveryOptions(userDataPath, () => new Promise(() => undefined)) ) resetWindowsInstallDirAclRecoveryForTest() resetWindowsInstallDirAclRepairForTest() let inFlightDuringRepair = false const gate = repairKnownPoisonedInstallDirBeforeWindow({ - ...recoveryOptions(userDataPath, async (spec) => { + ...recoveryOptions(userDataPath, async () => { inFlightDuringRepair = isBlockingInstallDirAclRepairInFlight() - return okRun(spec) + return OK }), timeoutMs: 5_000 }) @@ -961,3 +935,7 @@ describe('isBlockingInstallDirAclRepairInFlight', () => { expect(isBlockingInstallDirAclRepairInFlight()).toBe(false) }) }) + +afterEach(() => { + resetWindowsSecurityLoadForTests() +}) diff --git a/src/main/startup/windows-install-dir-acl-recovery.ts b/src/main/startup/windows-install-dir-acl-recovery.ts index c251bda9267..6aca0229ff1 100644 --- a/src/main/startup/windows-install-dir-acl-recovery.ts +++ b/src/main/startup/windows-install-dir-acl-recovery.ts @@ -110,7 +110,7 @@ export function isInstallDirAclSuspect(now: number = Date.now()): boolean { if (poison && poison.stage !== 'repaired') { return true } - // A 'repaired' stage is icacls's exit claim, not a reading of the tree — and the GPU + // A 'repaired' stage is the grant's own claim, not a reading of the tree — and the GPU // children die 48-1373ms after window creation while the probe answers 0.9-3.0s in. So // the claim stays provisional while this launch's probe is still out: the grace check // below keeps the suspicion until the reading corroborates it or the window lapses. @@ -157,12 +157,12 @@ function startRepair( installDir, // Every caller here holds outstanding poison evidence — this launch's probe reading, or // the persisted marker that armed the gate — so a marker recording a completed repair - // describes a re-poisoned tree, or an icacls run that silently no-opped. It must not + // describes a re-poisoned tree, or a grant that silently no-opped. It must not // stand in for a repair. `marker-hit` therefore only ever means the budget is spent. poisonEvidenceOutstanding: true, onDone: (result) => { // A tree read poisoned AFTER this repair was dispatched disproves its success claim, - // whatever icacls exited: the gate's budget can expire while the child runs on under + // whatever the grant reported: the gate's budget can expire while the grant runs on under // its own, so the probe's reading is the later evidence. A clean reading since then // retires it — there was nothing left to repair. const claimDisproved = @@ -241,7 +241,7 @@ function applyInstallDirAclProbeVerdict( return } // This reading was taken after the gate finished, so it outranks the gate's own verdict: - // a tree that still matches the signature was never repaired, whatever icacls exited. + // a tree that still matches the signature was never repaired, whatever the grant reported. if (poison?.stage === 'repaired') { poison = { installDir: poison.installDir, stage: 'failed' } // The claim also cleared the safe-graphics marker; disproved, it owes that back, or @@ -257,7 +257,7 @@ function applyInstallDirAclProbeVerdict( } } // Re-writes the poison marker — re-arming the next launch's gate — even when the - // once-per-process latch means no icacls can run again this launch. + // once-per-process latch means no grant can run again this launch. startRepair(options.installDir ?? dirname(process.execPath), options) } diff --git a/src/main/startup/windows-install-dir-acl-repair.win32.test.ts b/src/main/startup/windows-install-dir-acl-repair.win32.test.ts index 0c15195792f..c724bf2aaf8 100644 --- a/src/main/startup/windows-install-dir-acl-repair.win32.test.ts +++ b/src/main/startup/windows-install-dir-acl-repair.win32.test.ts @@ -2,9 +2,13 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { runProcess } from '@orca/process-host' -import { getIcaclsExePath } from '../win32-utils' +import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { + loadWindowsSecurity, + WINDOWS_FILE_READ_EXECUTE, + type WindowsSecurity +} from '../../shared/windows-security-addon' import { probeWindowsInstallDirAcl, resetWindowsInstallDirAclProbeForTest @@ -17,12 +21,8 @@ import { import { resetWindowsInstallDirAclRepairForTest } from './windows-install-dir-package-acl-repair' /** - * The other half of the ACL proof: the unit tests fake icacls, and this one runs - * the real binary against a real poisoned tree on a real Windows box. - * - * Both are needed. `icacls /grant "*S-1-15-2-2:(OI)(CI)(RX)"` exits 0 and - * prints "Failed processing 0 files" while writing no ACE at all — a model of - * icacls cannot catch that, and it is the exact mistake that leaves the app dead. + * The other half of the ACL proof: the unit tests fake the grant, and this one runs the real + * in-process grant against a real poisoned tree on a real Windows box, reading the result back. * * Runs only on win32; skipped elsewhere. */ @@ -30,69 +30,72 @@ const describeOnWindows = process.platform === 'win32' ? describe : describe.ski /** An unresolvable AppContainer SID, the shape the field hosts carry. */ const ORPHAN_SID = - '*S-1-15-2-1111111111-2222222222-3333333333-4444444444-5555555555-6666666666-7777777777' -const RESTRICTED_PACKAGES_NAME = /ALL RESTRICTED APPLICATION PACKAGES/i + 'S-1-15-2-1111111111-2222222222-3333333333-1234567890-2345678901-3456789012-4012345678' +const RESTRICTED_SID = 'S-1-15-2-2' +const READ_EXECUTE = WINDOWS_FILE_READ_EXECUTE +const FULL_CONTROL = 0x1f01ff +const INHERITED_ACE = 0x10 -async function icacls(...args: string[]): Promise<{ code: number | null; out: string }> { - const result = await runProcess({ program: getIcaclsExePath(), args, timeoutMs: 30_000 }) - return { code: result.code, out: `${result.stdout}\n${result.stderr}` } +function requireSecurity(): WindowsSecurity { + const loaded = loadWindowsSecurity() + if ('error' in loaded) { + throw new Error(`the windows-security addon must load on a Windows test host: ${loaded.error}`) + } + return loaded } -/** Explicit DACL, inheritance off: what a shipped module carries, and why a root grant alone is not enough. */ -async function createProtectedFile(path: string): Promise { - writeFileSync(path, 'binary') - await icacls(path, '/inheritance:d') -} - -describeOnWindows('install-dir package ACL repair against the real icacls', () => { +describeOnWindows('install-dir package ACL repair against real DACLs', () => { + let addon: WindowsSecurity['addon'] + let userSid: string let installDir: string let userDataPath: string let moduleFile: string - let trapFile: string + let nestedFile: string - beforeAll(async () => { + /** Explicit DACL, inheritance off: what a shipped module carries, and why a root grant alone is not enough. */ + function createProtectedFile(path: string): void { + writeFileSync(path, 'binary') + addon.setDaclSddl( + path, + `D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;${userSid})(A;;0x1200a9;;;${ORPHAN_SID})` + ) + } + + beforeAll(() => { + ;({ addon, userSid } = requireSecurity()) installDir = mkdtempSync(join(tmpdir(), 'orca-acl-live-')) userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-live-ud-')) mkdirSync(join(installDir, 'resources'), { recursive: true }) moduleFile = join(installDir, 'ffmpeg.dll') - trapFile = join(installDir, 'resources', 'trap.dll') - await createProtectedFile(moduleFile) - await createProtectedFile(trapFile) - // Poison: an orphan package ACE on the tree and on the module, no well-known grant. - await icacls(installDir, '/grant', `${ORPHAN_SID}:(OI)(CI)(RX)`) - await icacls(moduleFile, '/grant', `${ORPHAN_SID}:(RX)`) - await icacls(trapFile, '/grant', `${ORPHAN_SID}:(RX)`) + nestedFile = join(installDir, 'resources', 'nested.dll') + createProtectedFile(moduleFile) + createProtectedFile(nestedFile) + // Poison: an orphan package ACE on the tree, no well-known package grant anywhere. + addon.grantAccess(installDir, ORPHAN_SID, WINDOWS_FILE_READ_EXECUTE, { + inheritable: true, + replace: false + }) }) afterAll(() => { - // Why removeTreeSync: two icacls.exe children just rewrote DACLs on this tree, so a - // raw rmSync races handles Windows has not released and throws EPERM after the - // assertions already passed. + // Why removeTreeSync: the DACL rewrites just touched this tree, so a raw rmSync can race + // handles Windows has not released and throw EPERM after the assertions already passed. removeTreeSync(installDir) removeTreeSync(userDataPath) }) - function probeVerdict(): Promise> { + function probeVerdict(): Promise { resetWindowsInstallDirAclProbeForTest() return new Promise((resolve) => { probeWindowsInstallDirAcl({ installDir, recordBreadcrumb: () => undefined, - onDone: (data) => resolve(data as Record) + onDone: resolve }) }) } - // The trap, pinned against the real binary: this is the form that looks like it worked. - it('confirms an inheritance-flagged grant silently writes nothing to a file', async () => { - const flagged = await icacls(trapFile, '/grant', '*S-1-15-2-2:(OI)(CI)(RX)') - expect(flagged.code).toBe(0) - expect(flagged.out).toMatch(/Failed processing 0 files?/i) - const after = await icacls(trapFile) - expect(after.out).not.toMatch(RESTRICTED_PACKAGES_NAME) - }) - - it('repairs the tree before the window, and the grant lands on the module file', async () => { + it('repairs the tree before the window, and the grant lands on each module file', async () => { expect((await probeVerdict()).matchesPoisonSignature).toBe(true) resetWindowsInstallDirAclRecoveryForTest() @@ -100,25 +103,37 @@ describeOnWindows('install-dir package ACL repair against the real icacls', () = // The state a launch that died mid-repair leaves behind. writeInstallDirAclPoisonMarker(userDataPath, installDir, '1.4.196') - const startedAt = Date.now() + const startedAt = performance.now() const mode = await repairKnownPoisonedInstallDirBeforeWindow({ installDir, userDataPath, appVersion: '1.4.196', recordBreadcrumb: () => undefined }) - console.log(`[live-acl] blocking repair ${mode} in ${Date.now() - startedAt}ms`) + console.log( + `[live-acl] blocking repair ${mode} in ${(performance.now() - startedAt).toFixed(1)}ms` + ) expect(mode).toBe('repaired') - // A directory grant is not enough: the file carries its own DACL. - expect((await icacls(moduleFile)).out).toMatch(RESTRICTED_PACKAGES_NAME) - // The /T pass must also reach a NESTED protected file — the shape app.asar.unpacked - // and node_modules actually have. - expect((await icacls(trapFile)).out).toMatch(RESTRICTED_PACKAGES_NAME) - // And the (OI)(CI) root grant exists so files a later update writes inherit it. + // A directory grant is not enough: each protected file needs its own explicit, flagless ACE — + // the root one, and a NESTED one, the shape app.asar.unpacked and node_modules have. + const allow = (sid: string, mask: number, flags = 0) => + expect.objectContaining({ type: 0, flags, mask, sid }) + for (const file of [moduleFile, nestedFile]) { + const aces = addon.getDaclAces(file)?.aces + expect(aces).toContainEqual(allow(RESTRICTED_SID, READ_EXECUTE)) + // Additive: SYSTEM, Administrators, the user and the orphan all survive. + for (const sid of ['S-1-5-18', 'S-1-5-32-544', userSid]) { + expect(aces).toContainEqual(allow(sid, FULL_CONTROL)) + } + expect(aces).toContainEqual(allow(ORPHAN_SID, READ_EXECUTE)) + } + // And the inheritable root grant exists so files a later update writes inherit it. const updateFile = join(installDir, 'resources', 'added-by-update.dll') writeFileSync(updateFile, 'binary') - expect((await icacls(updateFile)).out).toMatch(RESTRICTED_PACKAGES_NAME) + expect(addon.getDaclAces(updateFile)?.aces).toContainEqual( + allow(RESTRICTED_SID, READ_EXECUTE, INHERITED_ACE) + ) expect((await probeVerdict()).matchesPoisonSignature).toBe(false) }) }) diff --git a/src/main/startup/windows-install-dir-acl.test-fixture.ts b/src/main/startup/windows-install-dir-acl.test-fixture.ts index f28fe9c3d67..c5853c8a723 100644 --- a/src/main/startup/windows-install-dir-acl.test-fixture.ts +++ b/src/main/startup/windows-install-dir-acl.test-fixture.ts @@ -1,12 +1,16 @@ -import { EventEmitter } from 'node:events' -import { writeFileSync } from 'node:fs' -import { basename } from 'node:path' -import type { spawn } from 'node:child_process' +import { setWindowsSecurityForTests } from '../../shared/windows-security-addon' +import { + createFakeWindowsSecurity, + daclFromSddl +} from '../../shared/windows-security-addon.test-fixture' +import type { + InstallDirPackageGrantOutcome, + InstallDirPackageGrants +} from './windows-install-dir-package-acl-grant' /** - * Shared icacls doubles for the install-dir DACL probe, repair, and recovery tests. - * ACEs are SDDL, the shape `icacls /save ` writes (captured on - * win32 10.0.26200: the leaf name, CRLF, then `D:AI(...)(...)`, UTF-16LE). + * Shared ACL doubles for the install-dir DACL probe, repair, and recovery tests. + * ACEs are written as SDDL for readability; the fake hands the probe their structure. */ export const ORPHAN_PACKAGE_ACE = '(A;OICI;0x1200a9;;;S-1-15-2-999-999-999)' @@ -21,51 +25,63 @@ export const BASELINE_ACES = [ '(A;OICIID;FA;;;S-1-5-21-432636774-4279371817-3971399515-1001)' ] -/** The file `icacls /save` writes for a target carrying these ACEs. */ -export function icaclsSavedAcl(target: string, aces: string[]): Buffer { - const leaf = basename(target.replaceAll('\\', '/')) - return Buffer.from(`${leaf}\r\nD:AI${[...aces, ...BASELINE_ACES].join('')}\r\n`, 'utf16le') +/** The SDDL a target carrying these ACEs (plus the inherited baseline) reads back as. */ +export function savedDacl(aces: string[]): string { + return `D:AI${[...aces, ...BASELINE_ACES].join('')}` } /** - * `saved` returns the target's extra ACEs, or the raw saved file; `null` makes the - * spawn fail, as an unreadable target does. `display` is what a bare - * `icacls ` prints. + * Installs a fake windows-security addon whose DACL reads come from `saved`: the target's extra + * ACEs, or a raw SDDL string; `null` makes the read throw, as an unreadable target does. Returns + * the paths read. Reset with resetWindowsSecurityLoadForTests(). */ -export function fakeIcaclsSpawn( - saved: (target: string) => string[] | Buffer | null, - display: (target: string) => string = () => '', - exitCode: number | null = 0 -): { - spawnFn: typeof spawn - calls: { file: string; args: string[] }[] +export function installFakeDacls(saved: (target: string) => string[] | string | null): { + calls: string[] } { - const calls: { file: string; args: string[] }[] = [] - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test double; the probe only touches kill/stdout/on of the child. - const spawnFn = ((file: string, args: string[]) => { - calls.push({ file, args }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both fields are assigned on the next two lines. - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter - kill: () => void + const calls: string[] = [] + const fake = createFakeWindowsSecurity() + fake.getDaclAces = (addonPath: string) => { + const path = addonPath.startsWith('\\\\?\\') ? addonPath.slice(4) : addonPath + calls.push(path) + const aces = saved(path) + if (aces === null) { + throw new Error('GetNamedSecurityInfoW failed with Win32 error 5') } - child.stdout = new EventEmitter() - child.kill = () => undefined - const [target, verb, saveFile] = args - const aces = saved(target) - setImmediate(() => { - if (aces === null) { - child.emit('error', new Error('ENOENT')) - return - } - if (verb === '/save') { - writeFileSync(saveFile, Buffer.isBuffer(aces) ? aces : icaclsSavedAcl(target, aces)) - } else { - child.stdout.emit('data', Buffer.from(display(target), 'utf-8')) - } - child.emit('close', exitCode) - }) - return child - }) as unknown as typeof spawn - return { spawnFn, calls } + return daclFromSddl(typeof aces === 'string' ? aces : savedDacl(aces)) + } + setWindowsSecurityForTests(fake) + return { calls } +} + +export type InstallDirPackageGrantPass = keyof InstallDirPackageGrants + +/** One function standing in for both passes, the shape most tests find easiest to write. */ +export type InstallDirPackageGrant = ( + installDir: string, + pass: InstallDirPackageGrantPass +) => Promise + +export function grantsFrom(grant: InstallDirPackageGrant): InstallDirPackageGrants { + return { + root: (installDir) => grant(installDir, 'root'), + tree: (installDir) => grant(installDir, 'tree') + } +} + +/** Records each grant pass; `reply` decides its outcome (default: success). */ +export function fakePackageGrant( + reply: ( + pass: InstallDirPackageGrantPass + ) => Partial | Promise = () => ({}) +): { + grants: InstallDirPackageGrants + passes: { installDir: string; pass: InstallDirPackageGrantPass }[] +} { + const passes: { installDir: string; pass: InstallDirPackageGrantPass }[] = [] + const grants = grantsFrom(async (installDir, pass) => { + passes.push({ installDir, pass }) + const outcome = await reply(pass) + return { ok: true, failedFileCount: 0, ...outcome } + }) + return { grants, passes } } diff --git a/src/main/startup/windows-install-dir-package-acl-grant.test.ts b/src/main/startup/windows-install-dir-package-acl-grant.test.ts new file mode 100644 index 00000000000..0adccf1c970 --- /dev/null +++ b/src/main/startup/windows-install-dir-package-acl-grant.test.ts @@ -0,0 +1,205 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + resetWindowsSecurityLoadForTests, + setWindowsSecurityForTests, + WINDOWS_FILE_READ_EXECUTE, + type GrantAccessOptions, + type WindowsSecurityAddon +} from '../../shared/windows-security-addon' +import { + grantInstallDirPackageRoot, + grantInstallDirPackageTree, + RESTRICTED_APP_PACKAGES_SID +} from './windows-install-dir-package-acl-grant' + +type GrantCall = { + path: string + sid: string + accessMask: number + options: GrantAccessOptions +} + +function unexpected(): never { + throw new Error('the package grant must only read DACLs and call grantAccessAsync') +} + +/** The addon wrapper namespaces every path; tests compare against the paths they wrote. */ +const plain = (path: string) => (path.startsWith('\\\\?\\') ? path.slice(4) : path) + +/** + * Installs a fake addon that only reads DACLs and grants, and returns the grants it saw. + * Directories in `protectedDirs` read back protected; everything else inherits. + */ +function installFakeAddon( + fail: (path: string) => boolean = () => false, + protectedDirs: string[] = [] +): GrantCall[] { + const calls: GrantCall[] = [] + const addon: WindowsSecurityAddon = { + currentUserSid: () => 'S-1-5-21-1-2-3-1001', + getDaclAces: (path) => ({ protected: protectedDirs.includes(plain(path)), aces: [] }), + setDaclSddl: unexpected, + setDaclSddlAsync: unexpected, + createFileWithDacl: unexpected, + grantAccess: unexpected, + grantAccessAsync: async (path, sid, accessMask, options) => { + calls.push({ path: plain(path), sid, accessMask, options }) + if (fail(plain(path))) { + throw new Error('SetNamedSecurityInfoW failed with Win32 error 5') + } + } + } + setWindowsSecurityForTests(addon) + return calls +} + +describe('install-dir package grant passes', () => { + let installDir: string + + beforeEach(() => { + installDir = mkdtempSync(join(tmpdir(), 'orca-acl-grant-')) + mkdirSync(join(installDir, 'resources', 'app.asar.unpacked'), { recursive: true }) + writeFileSync(join(installDir, 'ffmpeg.dll'), 'binary') + writeFileSync(join(installDir, 'resources', 'app.asar.unpacked', 'addon.node'), 'binary') + }) + + afterEach(() => { + resetWindowsSecurityLoadForTests() + rmSync(installDir, { recursive: true, force: true }) + }) + + it('grants the root once, inheritable, so files a later update writes inherit it', async () => { + const calls = installFakeAddon() + const outcome = await grantInstallDirPackageRoot(installDir) + expect(outcome).toEqual({ ok: true, failedFileCount: 0 }) + expect(calls).toEqual([ + { + path: installDir, + sid: RESTRICTED_APP_PACKAGES_SID, + accessMask: WINDOWS_FILE_READ_EXECUTE, + options: { inheritable: true, replace: false } + } + ]) + }) + + it('grants every file below the root flagless, and leaves the root to the root pass', async () => { + const calls = installFakeAddon() + const outcome = await grantInstallDirPackageTree(installDir) + expect(outcome).toEqual({ ok: true, failedFileCount: 0 }) + expect(calls.map((call) => call.path).sort()).toEqual( + [ + join(installDir, 'ffmpeg.dll'), + join(installDir, 'resources', 'app.asar.unpacked', 'addon.node') + ].sort() + ) + for (const call of calls) { + expect(call.sid).toBe(RESTRICTED_APP_PACKAGES_SID) + expect(call.accessMask).toBe(WINDOWS_FILE_READ_EXECUTE) + expect(call.options).toEqual({ inheritable: false, replace: false }) + } + }) + + // An inheriting directory already has the root's grant; re-granting it re-propagates its subtree. + it('grants a directory only when its own DACL is protected, inheritably', async () => { + const shielded = join(installDir, 'resources', 'app.asar.unpacked') + const calls = installFakeAddon(() => false, [shielded]) + await grantInstallDirPackageTree(installDir) + const directoryGrants = calls.filter((call) => !/\.(dll|node)$/.test(call.path)) + expect(directoryGrants).toEqual([ + { + path: shielded, + sid: RESTRICTED_APP_PACKAGES_SID, + accessMask: WINDOWS_FILE_READ_EXECUTE, + options: { inheritable: true, replace: false } + } + ]) + }) + + it('never replaces existing rules on either pass', async () => { + const calls = installFakeAddon() + await grantInstallDirPackageRoot(installDir) + await grantInstallDirPackageTree(installDir) + expect(calls.length).toBeGreaterThan(1) + expect(calls.every((call) => call.options.replace === false)).toBe(true) + }) + + it('does not follow a link out of the install tree', async (context) => { + const outside = mkdtempSync(join(tmpdir(), 'orca-acl-grant-outside-')) + writeFileSync(join(outside, 'elsewhere.dll'), 'binary') + try { + symlinkSync(outside, join(installDir, 'linked'), 'junction') + } catch { + rmSync(outside, { recursive: true, force: true }) + context.skip() + return + } + try { + const calls = installFakeAddon() + await grantInstallDirPackageTree(installDir) + const paths = calls.map((call) => call.path) + expect(paths).not.toContain(join(installDir, 'linked')) + expect(paths.some((path) => path.includes('elsewhere.dll'))).toBe(false) + } finally { + rmSync(join(installDir, 'linked'), { force: true }) + rmSync(outside, { recursive: true, force: true }) + } + }) + + it('keeps going past failed entries and counts them, like icacls /T /C', async () => { + const calls = installFakeAddon((path) => path.endsWith('.dll') || path.endsWith('.node')) + const outcome = await grantInstallDirPackageTree(installDir) + expect(calls).toHaveLength(2) + expect(outcome.ok).toBe(false) + expect(outcome.failedFileCount).toBe(2) + expect(outcome.reason).toMatch(/^failed-files: .*Win32 error 5/) + }) + + it('counts a failed root grant as one failed entry', async () => { + installFakeAddon(() => true) + const outcome = await grantInstallDirPackageRoot(installDir) + expect(outcome).toMatchObject({ ok: false, failedFileCount: 1 }) + expect(outcome.reason).toContain('Win32 error 5') + }) + + it('reports an unavailable addon instead of granting anything', async () => { + setWindowsSecurityForTests(null) + expect(await grantInstallDirPackageRoot(installDir)).toEqual({ + ok: false, + failedFileCount: 0, + reason: 'addon-unavailable' + }) + expect(await grantInstallDirPackageTree(installDir)).toEqual({ + ok: false, + failedFileCount: 0, + reason: 'addon-unavailable' + }) + }) + + // The Win32 security APIs fail with error 123 past MAX_PATH; the loader adds the escape once + // through the host's toNamespacedPath, which is the identity off Windows. + it.runIf(process.platform === 'win32')( + 'reaches a path past MAX_PATH through the extended-length prefix', + async () => { + const calls = installFakeAddon() + const longDir = `C:\\Program Files\\${'orca'.repeat(70)}` + const seen: string[] = [] + setWindowsSecurityForTests({ + currentUserSid: () => 'S-1-5-21-1-2-3-1001', + getDaclAces: unexpected, + setDaclSddl: unexpected, + setDaclSddlAsync: unexpected, + createFileWithDacl: unexpected, + grantAccess: unexpected, + grantAccessAsync: async (path) => { + seen.push(path) + } + }) + await grantInstallDirPackageRoot(longDir) + expect(calls).toEqual([]) + expect(seen).toEqual([`\\\\?\\${longDir}`]) + } + ) +}) diff --git a/src/main/startup/windows-install-dir-package-acl-grant.ts b/src/main/startup/windows-install-dir-package-acl-grant.ts new file mode 100644 index 00000000000..6997881beb3 --- /dev/null +++ b/src/main/startup/windows-install-dir-package-acl-grant.ts @@ -0,0 +1,125 @@ +import { readdirSync, type Dirent } from 'node:fs' +import { join } from 'node:path' +import { sanitizeCrashReportString } from '../../shared/crash-reporting' +import { + loadWindowsSecurity, + WINDOWS_FILE_READ_EXECUTE, + type WindowsSecurity +} from '../../shared/windows-security-addon' + +/** ALL RESTRICTED APPLICATION PACKAGES: the grant that cleared electron/electron#51761. */ +export const RESTRICTED_APP_PACKAGES_SID = 'S-1-15-2-2' + +export type InstallDirPackageGrantOutcome = { + ok: boolean + /** Entries whose DACL could not be written (or read, for a directory listing). */ + failedFileCount: number + reason?: string +} + +/** + * The two passes the repair runs. `root`: one inheritable grant, so files a later update writes + * inherit it. `tree`: what that grant cannot reach, because a shipped module or directory that + * carries its own protected DACL inherits nothing from the root. + */ +export type InstallDirPackageGrants = { + root: (installDir: string) => Promise + tree: (installDir: string) => Promise +} + +const ADDON_UNAVAILABLE: InstallDirPackageGrantOutcome = { + ok: false, + failedFileCount: 0, + reason: 'addon-unavailable' +} + +/** Never `replace`, so SYSTEM, Administrators, the user and the orphan all survive. */ +function grantPackageRead( + security: WindowsSecurity, + path: string, + inheritable: boolean +): Promise { + return security.addon.grantAccessAsync( + path, + RESTRICTED_APP_PACKAGES_SID, + WINDOWS_FILE_READ_EXECUTE, + { + inheritable, + replace: false + } + ) +} + +export async function grantInstallDirPackageRoot( + installDir: string +): Promise { + const security = loadWindowsSecurity() + if ('error' in security) { + return ADDON_UNAVAILABLE + } + try { + await grantPackageRead(security, installDir, true) + return { ok: true, failedFileCount: 0 } + } catch (error) { + return { ok: false, failedFileCount: 1, reason: sanitizeCrashReportString(String(error), 200) } + } +} + +/** + * Everything below the root (the root pass covers the root), continuing past failures the way + * `icacls /T /C` did. Every file gets a flagless grant. A directory is granted only when its DACL + * is protected, and then inheritably so its own descendants inherit it: any other directory + * already inherits the root's grant, and re-granting it would re-propagate its whole subtree + * (3.2 s for 1,093 directories in a 7.6k-entry install). + */ +export async function grantInstallDirPackageTree( + installDir: string +): Promise { + const security = loadWindowsSecurity() + if ('error' in security) { + return ADDON_UNAVAILABLE + } + let failedFileCount = 0 + let firstFailure: string | undefined + const attempt = async (step: () => Promise | void): Promise => { + try { + await step() + } catch (error) { + failedFileCount += 1 + firstFailure ??= sanitizeCrashReportString(String(error), 200) + } + } + const pending = [installDir] + for (let directory = pending.pop(); directory !== undefined; directory = pending.pop()) { + const current = directory + let entries: Dirent[] = [] + await attempt(() => { + entries = readdirSync(current, { withFileTypes: true }) + }) + for (const entry of entries) { + const path = join(current, entry.name) + // Never through a link: a junction can point anywhere, and the repair is for this tree. + if (entry.isSymbolicLink()) { + continue + } + if (!entry.isDirectory()) { + await attempt(() => grantPackageRead(security, path, false)) + continue + } + pending.push(path) + await attempt(async () => { + if (security.addon.getDaclAces(path)?.protected) { + await grantPackageRead(security, path, true) + } + }) + } + } + return failedFileCount === 0 + ? { ok: true, failedFileCount: 0 } + : { ok: false, failedFileCount, reason: `failed-files: ${firstFailure}` } +} + +export const installDirPackageGrants: InstallDirPackageGrants = { + root: grantInstallDirPackageRoot, + tree: grantInstallDirPackageTree +} diff --git a/src/main/startup/windows-install-dir-package-acl-repair.test.ts b/src/main/startup/windows-install-dir-package-acl-repair.test.ts index 4ad7425a25c..cef66fa66d6 100644 --- a/src/main/startup/windows-install-dir-package-acl-repair.test.ts +++ b/src/main/startup/windows-install-dir-package-acl-repair.test.ts @@ -3,7 +3,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' -import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' +const { runProcessMock, runProcessSyncMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), + runProcessSyncMock: vi.fn() +})) + +// The repair must never start a child process; any icacls left behind would land here. +vi.mock('@orca/process-host', () => ({ + runProcess: runProcessMock, + runProcessSync: runProcessSyncMock +})) + import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { buildInstallDirAclRepairCommands, @@ -15,34 +25,14 @@ import { WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION, type WindowsInstallDirAclRepairResult } from './windows-install-dir-package-acl-repair' +import { fakePackageGrant, grantsFrom } from './windows-install-dir-acl.test-fixture' +import type { InstallDirPackageGrants } from './windows-install-dir-package-acl-grant' const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca' const APP_VERSION = '1.4.184' -/** icacls' real success summary; the /T pass prints one per tree. */ -function icaclsOutput(processed: number, failed: number): string { - return `Successfully processed ${processed} files; Failed processing ${failed} files` -} - -type Runner = (spec: ProcessSpec) => Promise - -function fakeRunner(reply: (spec: ProcessSpec) => Partial = () => ({})): { - run: Runner - specs: ProcessSpec[] -} { - const specs: ProcessSpec[] = [] - const run: Runner = async (spec) => { - specs.push(spec) - return { - code: 0, - signal: null, - stdout: icaclsOutput(81, 0), - stderr: '', - timedOut: false, - ...reply(spec) - } - } - return { run, specs } +function deniedGrant(): ReturnType { + return fakePackageGrant(() => ({ ok: false, failedFileCount: 1, reason: 'Access is denied.' })) } function userDataDir(): string { @@ -58,13 +48,13 @@ function repair( installDir?: string platform?: NodeJS.Platform isServeMode?: boolean - run?: Runner + grants?: InstallDirPackageGrants } = {} ): Promise<{ result: WindowsInstallDirAclRepairResult data: CrashReportBreadcrumbData }> { - const { run, ...rest } = overrides + const { grants, ...rest } = overrides return new Promise((resolve, reject) => { let data: CrashReportBreadcrumbData = {} repairWindowsInstallDirPackageAcl({ @@ -73,7 +63,7 @@ function repair( appVersion: APP_VERSION, ...rest, userDataPath: rest.userDataPath ?? userDataDir(), - runProcessFn: (run ?? fakeRunner().run) as never, + grants: grants ?? fakePackageGrant().grants, recordBreadcrumb: (name, breadcrumb) => { expect(name).toBe(WINDOWS_INSTALL_DIR_ACL_REPAIR_BREADCRUMB) data = breadcrumb ?? {} @@ -88,37 +78,23 @@ function repair( describe('repairWindowsInstallDirPackageAcl', () => { beforeEach(() => { resetWindowsInstallDirAclRepairForTest() + runProcessMock.mockClear() + runProcessSyncMock.mockClear() }) - it('grants the flagless (RX) on the recursive pass, never (OI)(CI)(RX)', async () => { - // The whole fix: icacls silently no-ops an inheritance-flagged grant against a - // FILE — it exits 0 and reports zero failures while writing no ACE, so the - // shipped modules stay unreadable and every renderer still dies at init. - const { run, specs } = fakeRunner() - await repair({ run }) - - const treePass = specs.find((spec) => spec.args?.includes('/T')) - expect(treePass?.args).toEqual([INSTALL_DIR, '/grant', '*S-1-15-2-2:(RX)', '/T', '/C']) - expect(treePass?.args).not.toContain('*S-1-15-2-2:(OI)(CI)(RX)') + it('runs the inheritable root pass, then the per-entry tree pass, on the install dir', async () => { + const { grants, passes } = fakePackageGrant() + await repair({ grants }) + expect(passes).toEqual([ + { installDir: INSTALL_DIR, pass: 'root' }, + { installDir: INSTALL_DIR, pass: 'tree' } + ]) }) - it('grants the inheritable (OI)(CI)(RX) on the root, without /T', async () => { - const { run, specs } = fakeRunner() - await repair({ run }) - - const rootPass = specs.find((spec) => !spec.args?.includes('/T')) - expect(rootPass?.args).toEqual([INSTALL_DIR, '/grant', '*S-1-15-2-2:(OI)(CI)(RX)']) - expect(specs).toHaveLength(2) - }) - - it('is additive on every pass so SYSTEM, Administrators and the orphan survive', async () => { - const { run, specs } = fakeRunner() - await repair({ run }) - - for (const spec of specs) { - expect(spec.args).toContain('/grant') - expect(spec.args).not.toContain('/grant:r') - } + it('never starts a child process', async () => { + await repair() + expect(runProcessMock).not.toHaveBeenCalled() + expect(runProcessSyncMock).not.toHaveBeenCalled() }) it('reports the repair and records an ok breadcrumb', async () => { @@ -128,16 +104,16 @@ describe('repairWindowsInstallDirPackageAcl', () => { expect(data.failedFileCount).toBe(0) }) - it('runs once and then never spawns again for the same install and version', async () => { + it('runs once and then never grants again for the same install and version', async () => { const userDataPath = userDataDir() - const first = fakeRunner() - await repair({ userDataPath, run: first.run }) - expect(first.specs).toHaveLength(2) + const first = fakePackageGrant() + await repair({ userDataPath, grants: first.grants }) + expect(first.passes).toHaveLength(2) resetWindowsInstallDirAclRepairForTest() - const second = fakeRunner() - const { result, data } = await repair({ userDataPath, run: second.run }) - expect(second.specs).toHaveLength(0) + const second = fakePackageGrant() + const { result, data } = await repair({ userDataPath, grants: second.grants }) + expect(second.passes).toHaveLength(0) expect(result).toEqual({ mode: 'marker-hit', alreadyRepaired: true }) expect(data.reason).toBe('marker-hit') }) @@ -147,24 +123,24 @@ describe('repairWindowsInstallDirPackageAcl', () => { await repair({ userDataPath }) resetWindowsInstallDirAclRepairForTest() - const updated = fakeRunner() + const updated = fakePackageGrant() expect( - (await repair({ userDataPath, appVersion: '1.4.185', run: updated.run })).result + (await repair({ userDataPath, appVersion: '1.4.185', grants: updated.grants })).result ).toEqual({ mode: 'repaired' }) - expect(updated.specs).toHaveLength(2) + expect(updated.passes).toHaveLength(2) resetWindowsInstallDirAclRepairForTest() - const moved = fakeRunner() + const moved = fakePackageGrant() expect( ( await repair({ userDataPath, installDir: 'D:\\Program Files\\Orca', - run: moved.run + grants: moved.grants }) ).result ).toEqual({ mode: 'repaired' }) - expect(moved.specs).toHaveLength(2) + expect(moved.passes).toHaveLength(2) }) it('re-runs when the marker is from an older scheme or is corrupt', async () => { @@ -178,57 +154,54 @@ describe('repairWindowsInstallDirPackageAcl', () => { appVersion: APP_VERSION }) ) - const stale = fakeRunner() - await repair({ userDataPath, run: stale.run }) - expect(stale.specs).toHaveLength(2) + const stale = fakePackageGrant() + await repair({ userDataPath, grants: stale.grants }) + expect(stale.passes).toHaveLength(2) resetWindowsInstallDirAclRepairForTest() writeFileSync(markerFile, '{ not json') - const corrupt = fakeRunner() - await repair({ userDataPath, run: corrupt.run }) - expect(corrupt.specs).toHaveLength(2) + const corrupt = fakePackageGrant() + await repair({ userDataPath, grants: corrupt.grants }) + expect(corrupt.passes).toHaveLength(2) }) - it('records the failed-file count when a standard user cannot write the ACL', async () => { - // Program Files: /C keeps going, icacls reports the losses and exits non-zero. - const { run, specs } = fakeRunner((spec) => - spec.args?.includes('/T') - ? { - code: 1332, - stdout: icaclsOutput(0, 81), - stderr: 'Access is denied.' - } - : { code: 5, stdout: '', stderr: 'Access is denied.' } + it('records the summed failed-entry count when a standard user cannot write the ACL', async () => { + const { grants, passes } = fakePackageGrant((pass) => + pass === 'tree' + ? { ok: false, failedFileCount: 81, reason: 'failed-files: Access is denied.' } + : { ok: false, failedFileCount: 1, reason: 'Access is denied.' } ) - const { result, data } = await repair({ run }) + const { result, data } = await repair({ grants }) - expect(result.mode).toBe('failed') + expect(result).toMatchObject({ mode: 'failed', failedFileCount: 82 }) expect(data.status).toBe('failed') - expect(data.failedFileCount).toBe(81) + expect(data.failedFileCount).toBe(82) + expect(String(data.reason)).toContain('root: Access is denied.') + expect(String(data.reason)).toContain('tree: failed-files') // No retry loop: exactly the two passes, then it gives up for this version. - expect(specs).toHaveLength(2) + expect(passes).toHaveLength(2) }) it('still runs the per-file pass when the root grant fails', async () => { - const { run, specs } = fakeRunner((spec) => - spec.args?.includes('/T') ? {} : { code: 5, stderr: 'Access is denied.' } + const { grants, passes } = fakePackageGrant((pass) => + pass === 'root' ? { ok: false, failedFileCount: 1, reason: 'Access is denied.' } : {} ) - const { result } = await repair({ run }) - expect(specs).toHaveLength(2) + const { result } = await repair({ grants }) + expect(passes.map((entry) => entry.pass)).toEqual(['root', 'tree']) expect(result.mode).toBe('failed') }) - it('does not throw when the runner rejects, and still records a breadcrumb', async () => { - const run: Runner = () => Promise.reject(new Error('spawn EPERM')) - const { result, data } = await repair({ run }) + it('does not throw when a grant rejects, and still records a breadcrumb', async () => { + const grants = grantsFrom(() => Promise.reject(new Error('Win32 error 5'))) + const { result, data } = await repair({ grants }) expect(result.mode).toBe('failed') expect(data.status).toBe('failed') - expect(String(data.reason)).toContain('spawn EPERM') + expect(String(data.reason)).toContain('Win32 error 5') }) - it('marks the attempt so a hopeless install does not re-spawn icacls every launch', async () => { + it('marks the attempt so a hopeless install does not re-walk the tree every launch', async () => { const userDataPath = userDataDir() - await repair({ userDataPath, run: fakeRunner(() => ({ code: 5 })).run }) + await repair({ userDataPath, grants: deniedGrant().grants }) const marker = JSON.parse( readFileSync(join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE), 'utf-8') @@ -241,50 +214,50 @@ describe('repairWindowsInstallDirPackageAcl', () => { // 'marker-hit' — repair permanently skipped — for the life of that version. it('retries a failed repair on later launches, then stops once the budget is spent', async () => { const userDataPath = userDataDir() - const failing = fakeRunner(() => ({ code: 5, stderr: 'Access is denied.' })) + const failing = deniedGrant() for (let attempt = 0; attempt < 3; attempt++) { resetWindowsInstallDirAclRepairForTest() - expect((await repair({ userDataPath, run: failing.run })).result.mode).toBe('failed') + expect((await repair({ userDataPath, grants: failing.grants })).result.mode).toBe('failed') } - expect(failing.specs).toHaveLength(6) + expect(failing.passes).toHaveLength(6) resetWindowsInstallDirAclRepairForTest() - const spent = fakeRunner() - const { result } = await repair({ userDataPath, run: spent.run }) + const spent = fakePackageGrant() + const { result } = await repair({ userDataPath, grants: spent.grants }) // Not alreadyRepaired: the budget ran out, so the tree is still poisoned. expect(result).toEqual({ mode: 'marker-hit', alreadyRepaired: false }) - expect(spent.specs).toHaveLength(0) + expect(spent.passes).toHaveLength(0) }) it('stops retrying immediately once a repair has succeeded', async () => { const userDataPath = userDataDir() resetWindowsInstallDirAclRepairForTest() - await repair({ userDataPath, run: fakeRunner(() => ({ code: 5 })).run }) + await repair({ userDataPath, grants: deniedGrant().grants }) resetWindowsInstallDirAclRepairForTest() expect((await repair({ userDataPath })).result).toEqual({ mode: 'repaired' }) resetWindowsInstallDirAclRepairForTest() - const after = fakeRunner() - expect((await repair({ userDataPath, run: after.run })).result).toEqual({ + const after = fakePackageGrant() + expect((await repair({ userDataPath, grants: after.grants })).result).toEqual({ mode: 'marker-hit', alreadyRepaired: true }) - expect(after.specs).toHaveLength(0) + expect(after.passes).toHaveLength(0) }) it('is a no-op off win32 and in serve mode', async () => { - const off = fakeRunner() + const off = fakePackageGrant() repairWindowsInstallDirPackageAcl({ platform: 'darwin', installDir: INSTALL_DIR, appVersion: APP_VERSION, userDataPath: userDataDir(), - runProcessFn: off.run as never, + grants: off.grants, recordBreadcrumb: vi.fn(), onDone: () => expect.unreachable('no-op must not settle') }) resetWindowsInstallDirAclRepairForTest() - const serve = fakeRunner() + const serve = fakePackageGrant() const recordServe = vi.fn() repairWindowsInstallDirPackageAcl({ platform: 'win32', @@ -292,21 +265,23 @@ describe('repairWindowsInstallDirPackageAcl', () => { installDir: INSTALL_DIR, appVersion: APP_VERSION, userDataPath: userDataDir(), - runProcessFn: serve.run as never, + grants: serve.grants, recordBreadcrumb: recordServe, onDone: () => expect.unreachable('no-op must not settle') }) await new Promise((resolve) => setTimeout(resolve, 20)) - expect(off.specs).toHaveLength(0) - expect(serve.specs).toHaveLength(0) + expect(off.passes).toHaveLength(0) + expect(serve.passes).toHaveLength(0) expect(recordServe).not.toHaveBeenCalled() }) - it('offers commands that match what the repair itself runs', () => { + it('offers text-only commands that mirror the two in-process grants', () => { expect(buildInstallDirAclRepairCommands(INSTALL_DIR)).toEqual([ `icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(OI)(CI)(RX)"`, `icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(RX)" /T /C` ]) + expect(runProcessMock).not.toHaveBeenCalled() + expect(runProcessSyncMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/startup/windows-install-dir-package-acl-repair.ts b/src/main/startup/windows-install-dir-package-acl-repair.ts index 18f635de8b3..b3f186c4b89 100644 --- a/src/main/startup/windows-install-dir-package-acl-repair.ts +++ b/src/main/startup/windows-install-dir-package-acl-repair.ts @@ -1,12 +1,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { runProcess } from '@orca/process-host' import { sanitizeCrashReportString, type CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' -import { getIcaclsExePath } from '../win32-utils' +import { + installDirPackageGrants, + RESTRICTED_APP_PACKAGES_SID, + type InstallDirPackageGrants +} from './windows-install-dir-package-acl-grant' /** * Self-repair for the install-directory package ACL that @@ -21,13 +24,11 @@ import { getIcaclsExePath } from '../win32-utils' * * Two additive grants clear it. Both are required: * - the root grant is inheritable so files written later (an update) inherit it; - * - the /T pass must use the FLAGLESS `(RX)`. `icacls /grant - * "*S-1-15-2-2:(OI)(CI)(RX)"` exits 0 and reports "Failed processing 0 files" - * while writing no ACE at all — a silent no-op. A directory grant alone does - * not fix a shipped module that carries its own explicit DACL, so a /T pass - * that silently no-ops leaves the app just as dead as before. + * - every existing entry gets a FLAGLESS grant, because a directory grant alone + * does not fix a shipped module that carries its own explicit DACL. * - * Never `/grant:r`: the install tree's SYSTEM/Administrators/user ACEs must + * Both are applied in-process (windows-install-dir-package-acl-grant.ts). Never + * a replacing grant: the install tree's SYSTEM/Administrators/user ACEs must * survive, and so must the orphan (removing an ACE is not ours to do). */ @@ -35,23 +36,13 @@ export const WINDOWS_INSTALL_DIR_ACL_REPAIR_BREADCRUMB = 'windows_install_dir_ac export const WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE = 'windows-install-dir-acl-repair.json' export const WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION = 1 -/** `*`-prefixed so icacls reads it as a SID on every locale. */ -const RESTRICTED_APP_PACKAGES_SID = '*S-1-15-2-2' -/** Root only: inheritable, so files added by a later update inherit the grant. */ -export const INSTALL_DIR_ROOT_GRANT = `${RESTRICTED_APP_PACKAGES_SID}:(OI)(CI)(RX)` -/** Existing entries: flagless, the only form icacls actually writes onto a file. */ -export const INSTALL_DIR_TREE_GRANT = `${RESTRICTED_APP_PACKAGES_SID}:(RX)` - -const ROOT_GRANT_TIMEOUT_MS = 15_000 -// A packaged Orca install tree is ~3.2k entries (a bare Electron dist is ~80, which -// is NOT the shape that ships: app.asar.unpacked and node_modules dominate). At the -// ~2.2ms/entry this repo measured for a /T walk in windows-user-data-acl.ts, that is -// ~5-7s of per-file DACL writes, each intercepted by Defender's filter driver. The -// cap covers a slow or contended volume on top of that, not a tree this size alone. -const TREE_GRANT_TIMEOUT_MS = 120_000 - -/** icacls localizes this; an unparsed summary reports the count as unknown. */ -const FAILED_PROCESSING = /Failed processing (\d+) files?/i +/** + * The same two grants spelled for a user to run by hand when the repair cannot write the ACL: + * `*`-prefixed so icacls reads a SID on every locale, and flagless on the /T pass because + * icacls writes nothing for `(OI)(CI)` on a file while reporting success. + */ +export const INSTALL_DIR_ROOT_GRANT = `*${RESTRICTED_APP_PACKAGES_SID}:(OI)(CI)(RX)` +export const INSTALL_DIR_TREE_GRANT = `*${RESTRICTED_APP_PACKAGES_SID}:(RX)` export type WindowsInstallDirAclRepairResult = /** `alreadyRepaired`: the marker records a completed repair, not an exhausted retry budget. */ @@ -66,13 +57,13 @@ export type WindowsInstallDirAclRepairOptions = { /** * A DACL reading found this tree poisoned and nothing has read it clean since — this * launch's probe, or a persisted poison marker from an earlier one. A marker claiming a - * completed repair therefore describes a tree that has since been re-poisoned, or an - * icacls run that silently no-opped: it stops outranking the reading. The attempt + * completed repair therefore describes a tree that has since been re-poisoned, or a + * repair that silently no-opped: it stops outranking the reading. The attempt * budget still bounds retries. */ poisonEvidenceOutstanding?: boolean /** Test seams. */ - runProcessFn?: typeof runProcess + grants?: InstallDirPackageGrants recordBreadcrumb?: typeof recordDurableCrashBreadcrumb onDone?: (result: WindowsInstallDirAclRepairResult) => void } @@ -98,7 +89,7 @@ type RepairMarker = { // A Defender-locked file, a timeout or a contended volume fails one launch and // succeeds the next, and pinning on the first failure leaves the machine blank // forever for that version. Three is enough to stop a standard-user Program Files -// install — which can never win — from re-spawning icacls on every launch. +// install — which can never win — from re-walking the tree on every launch. const MAX_REPAIR_ATTEMPTS = 3 /** @@ -110,7 +101,7 @@ export function isInstallDirAclPoisonVerdict(data: CrashReportBreadcrumbData): b return data.matchesPoisonSignature === true } -/** The commands to hand a user whose account cannot write the install ACL. */ +/** Text only, never run: the commands to hand a user whose account cannot write the install ACL. */ export function buildInstallDirAclRepairCommands(installDir: string): string[] { return [ `icacls "${installDir}" /grant "${INSTALL_DIR_ROOT_GRANT}"`, @@ -152,7 +143,7 @@ function markerHitFor(args: WindowsInstallDirAclRepairArgs): { alreadyRepaired: return (marker.attempts ?? 0) >= MAX_REPAIR_ATTEMPTS ? { alreadyRepaired: false } : null } -// Why write it on failure too: re-spawning icacls on every launch forever buys +// Why write it on failure too: re-walking the tree on every launch forever buys // nothing, so failures spend the retry budget. Reinstall or update changes the key. function writeMarker(args: WindowsInstallDirAclRepairArgs, outcome: string): void { const marker: RepairMarker = { @@ -169,46 +160,6 @@ function writeMarker(args: WindowsInstallDirAclRepairArgs, outcome: string): voi writeFileSync(markerPath(args.userDataPath), JSON.stringify(marker)) } -type GrantOutcome = { - ok: boolean - failedFileCount: number | null - reason?: string -} - -async function runGrant( - runner: typeof runProcess, - installDir: string, - grant: string, - extraArgs: readonly string[], - timeoutMs: number -): Promise { - try { - const result = await runner({ - program: getIcaclsExePath(), - args: [installDir, '/grant', grant, ...extraArgs], - timeoutMs - }) - const summary = FAILED_PROCESSING.exec(`${result.stdout}\n${result.stderr}`) - const failedFileCount = summary ? Number(summary[1]) : null - if (result.timedOut) { - return { ok: false, failedFileCount, reason: 'timeout' } - } - if (result.code !== 0) { - return { ok: false, failedFileCount, reason: `exit ${result.code}` } - } - if (failedFileCount !== null && failedFileCount > 0) { - return { ok: false, failedFileCount, reason: 'failed-files' } - } - return { ok: true, failedFileCount } - } catch (error) { - return { - ok: false, - failedFileCount: null, - reason: sanitizeCrashReportString(String(error), 200) - } - } -} - async function runRepair(args: WindowsInstallDirAclRepairArgs): Promise { const record = args.recordBreadcrumb ?? recordDurableCrashBreadcrumb const installDir = args.installDir ?? dirname(process.execPath) @@ -221,27 +172,15 @@ async function runRepair(args: WindowsInstallDirAclRepairArgs): Promise { result = { mode: 'marker-hit', alreadyRepaired: markerHit.alreadyRepaired } data = { status: 'skipped', reason: 'marker-hit', alreadyRepaired: markerHit.alreadyRepaired } } else { - const runner = args.runProcessFn ?? runProcess - const root = await runGrant( - runner, - installDir, - INSTALL_DIR_ROOT_GRANT, - [], - ROOT_GRANT_TIMEOUT_MS - ) + const grants = args.grants ?? installDirPackageGrants + const root = await grants.root(installDir) // Unconditional: the root grant failing does not make the per-file pass — // the one that actually unblocks the shipped modules — any less worth trying. - const tree = await runGrant( - runner, - installDir, - INSTALL_DIR_TREE_GRANT, - ['/T', '/C'], - TREE_GRANT_TIMEOUT_MS - ) - const failedFileCount = tree.failedFileCount ?? root.failedFileCount + const tree = await grants.tree(installDir) + const failedFileCount = root.failedFileCount + tree.failedFileCount if (root.ok && tree.ok) { result = { mode: 'repaired' } - data = { status: 'ok', failedFileCount: failedFileCount ?? -1 } + data = { status: 'ok', failedFileCount } } else { const reason = [ root.reason && `root: ${root.reason}`, @@ -253,8 +192,7 @@ async function runRepair(args: WindowsInstallDirAclRepairArgs): Promise { data = { status: 'failed', reason: sanitizeCrashReportString(reason, 200), - // -1 means icacls printed no parsable summary (a localized Windows). - failedFileCount: failedFileCount ?? -1 + failedFileCount } } try { @@ -286,7 +224,7 @@ export function resetWindowsInstallDirAclRepairForTest(): void { } /** - * Fire-and-forget; returns before any spawn. Call only when the probe reported + * Fire-and-forget; returns before any ACL write. Call only when the probe reported * `matchesPoisonSignature`. win32 only, exempt in serve mode, and it must never * throw into window creation. * diff --git a/src/main/startup/windows-user-data-acl.test.ts b/src/main/startup/windows-user-data-acl.test.ts index b49510b0bb1..166af167456 100644 --- a/src/main/startup/windows-user-data-acl.test.ts +++ b/src/main/startup/windows-user-data-acl.test.ts @@ -1,8 +1,16 @@ -import { EventEmitter } from 'node:events' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + resetWindowsSecurityLoadForTests, + setWindowsSecurityForTests +} from '../../shared/windows-security-addon' +import { + createFakeWindowsSecurity, + FAKE_USER_SID, + type FakeWindowsSecurity +} from '../../shared/windows-security-addon.test-fixture' import { ensureWindowsUserDataAclGrant, WINDOWS_ACL_GRANT_MARKER_FILE, @@ -10,126 +18,120 @@ import { type WindowsAclGrantResult } from './windows-user-data-acl' -type SpawnCall = { target: string; args: string[] } - -function createFakeSpawn(exitCode: number): { - calls: SpawnCall[] - spawnFn: (command: string, args?: readonly string[], options?: unknown) => EventEmitter -} { - const calls: SpawnCall[] = [] - return { - calls, - spawnFn: (_command: string, args: readonly string[] = []) => { - calls.push({ target: args[0] ?? '', args: [...args] }) - const child = new EventEmitter() as EventEmitter & { kill: () => void } - child.kill = () => undefined - setImmediate(() => child.emit('exit', exitCode)) - return child - } - } -} - -function awaitResult( - userDataPath: string, - options: Parameters[1] -): Promise { +function awaitResult(userDataPath: string): Promise { return new Promise((resolve) => { - ensureWindowsUserDataAclGrant(userDataPath, { ...options, onDone: resolve }) + ensureWindowsUserDataAclGrant(userDataPath, { onDone: resolve }) }) } +function writeMarker(userDataPath: string, identity: string): void { + writeFileSync( + join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE), + JSON.stringify({ schemeVersion: WINDOWS_ACL_GRANT_SCHEME_VERSION, identity, grantedAt: 1 }) + ) +} + +const IDENTITY = `*${FAKE_USER_SID}` + describe('ensureWindowsUserDataAclGrant', () => { let userDataPath: string + let fake: FakeWindowsSecurity + const granted = (): string[] => fake.callsFor('grantAsync').map((call) => call.path) beforeEach(() => { userDataPath = mkdtempSync(join(os.tmpdir(), 'orca-acl-test-')) + fake = createFakeWindowsSecurity() + setWindowsSecurityForTests(fake) }) afterEach(() => { + resetWindowsSecurityLoadForTests() + vi.unstubAllEnvs() rmSync(userDataPath, { recursive: true, force: true }) }) - it('grants children + root then writes the marker', async () => { - const fake = createFakeSpawn(0) - const result = await awaitResult(userDataPath, { - identity: 'testuser', - spawnFn: fake.spawnFn as never + it('grants each child directory, then the root, then writes the marker', async () => { + mkdirSync(join(userDataPath, 'agent-hooks')) + mkdirSync(join(userDataPath, 'codex-runtime-home')) + // Files carry no inheritable rule to fix, so they are not touched. + writeFileSync(join(userDataPath, 'orca-runtime.json'), '{}') + + expect(await awaitResult(userDataPath)).toEqual({ mode: 'granted' }) + + expect(granted().slice(0, -1).sort()).toEqual( + [join(userDataPath, 'agent-hooks'), join(userDataPath, 'codex-runtime-home')].sort() + ) + expect(granted().at(-1)).toBe(userDataPath) + expect(fake.callsFor('grantAsync')[0]).toMatchObject({ + sid: FAKE_USER_SID, + mask: 0x1f01ff, + options: { inheritable: true, replace: true } }) - expect(result).toEqual({ mode: 'granted' }) - expect(fake.calls).toHaveLength(2) - expect(fake.calls[0].target).toBe(join(userDataPath, '*')) - expect(fake.calls[1].target).toBe(userDataPath) - expect(fake.calls[0].args).toContain('testuser:(OI)(CI)(F)') const marker = JSON.parse( readFileSync(join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE), 'utf-8') ) expect(marker.schemeVersion).toBe(WINDOWS_ACL_GRANT_SCHEME_VERSION) - expect(marker.identity).toBe('testuser') + expect(marker.identity).toBe(IDENTITY) }) - it('skips all spawns when the marker matches the identity', async () => { - writeFileSync( - join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE), - JSON.stringify({ - schemeVersion: WINDOWS_ACL_GRANT_SCHEME_VERSION, - identity: 'testuser', - grantedAt: 1 - }) - ) - const fake = createFakeSpawn(0) - const result = await awaitResult(userDataPath, { - identity: 'testuser', - spawnFn: fake.spawnFn as never - }) - expect(result).toEqual({ mode: 'marker-hit' }) - expect(fake.calls).toHaveLength(0) + it('skips every grant when the marker matches the identity', async () => { + writeMarker(userDataPath, IDENTITY) + + expect(await awaitResult(userDataPath)).toEqual({ mode: 'marker-hit' }) + expect(granted()).toEqual([]) + }) + + // An upgrade must not re-walk the whole userData tree just because the identity's spelling changed. + it('accepts a marker written under the pre-native USERNAME identity', async () => { + vi.stubEnv('USERNAME', 'alice') + writeMarker(userDataPath, 'alice') + + expect(await awaitResult(userDataPath)).toEqual({ mode: 'marker-hit' }) + expect(granted()).toEqual([]) }) it('re-grants when the marker belongs to a different identity', async () => { - writeFileSync( - join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE), - JSON.stringify({ - schemeVersion: WINDOWS_ACL_GRANT_SCHEME_VERSION, - identity: 'someone-else', - grantedAt: 1 - }) - ) - const fake = createFakeSpawn(0) - const result = await awaitResult(userDataPath, { - identity: 'testuser', - spawnFn: fake.spawnFn as never - }) - expect(result).toEqual({ mode: 'granted' }) - expect(fake.calls).toHaveLength(2) + vi.stubEnv('USERNAME', 'alice') + writeMarker(userDataPath, '*S-1-5-21-9-9-9-1001') + + expect(await awaitResult(userDataPath)).toEqual({ mode: 'granted' }) + expect(granted()).toEqual([userDataPath]) }) - it('does not write the marker when icacls fails, so the next launch retries', async () => { - const fake = createFakeSpawn(5) - const result = await awaitResult(userDataPath, { - identity: 'testuser', - spawnFn: fake.spawnFn as never - }) - expect(result).toEqual({ mode: 'failed', reason: 'exit 5; exit 5' }) + it('keeps going past a failed child, then withholds the marker so the next launch retries', async () => { + const locked = join(userDataPath, 'locked') + mkdirSync(locked) + mkdirSync(join(userDataPath, 'fine')) + fake.failing.add(locked) + + const result = await awaitResult(userDataPath) + + expect(result).toMatchObject({ mode: 'failed', reason: expect.stringContaining('locked') }) + expect(granted()).toContain(userDataPath) expect(() => readFileSync(join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE))).toThrow() }) - it('no-ops without a resolvable identity', async () => { - const fake = createFakeSpawn(0) - const result = await awaitResult(userDataPath, { - identity: null, - spawnFn: fake.spawnFn as never - }) - expect(result).toEqual({ mode: 'no-identity' }) - expect(fake.calls).toHaveLength(0) + it('still grants the root when its children cannot be listed, and reports the failure', async () => { + // A file stands in for a userData whose listing is refused: readdir throws, the grant does not. + const unlistable = join(userDataPath, 'unlistable') + writeFileSync(unlistable, '') + + const result = await awaitResult(unlistable) + + expect(result).toMatchObject({ mode: 'failed', reason: expect.stringContaining('ENOTDIR') }) + expect(granted()).toEqual([unlistable]) + }) + + it('no-ops when the addon cannot load', async () => { + setWindowsSecurityForTests(null) + + expect(await awaitResult(userDataPath)).toEqual({ mode: 'no-identity' }) + expect(granted()).toEqual([]) }) it('ignores a corrupt marker and re-grants', async () => { writeFileSync(join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE), '{not json') - const fake = createFakeSpawn(0) - const result = await awaitResult(userDataPath, { - identity: 'testuser', - spawnFn: fake.spawnFn as never - }) - expect(result).toEqual({ mode: 'granted' }) + + expect(await awaitResult(userDataPath)).toEqual({ mode: 'granted' }) }) }) diff --git a/src/main/startup/windows-user-data-acl.ts b/src/main/startup/windows-user-data-acl.ts index bf1e1d01f05..8e2dac3600e 100644 --- a/src/main/startup/windows-user-data-acl.ts +++ b/src/main/startup/windows-user-data-acl.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' -import { readFileSync, writeFileSync } from 'node:fs' +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { getIcaclsExePath, resolveCurrentWindowsIdentity } from '../win32-utils' +import { grantDirAclAsync } from '../win32-utils' +import { loadWindowsSecurity } from '../../shared/windows-security-addon' /** * Startup ACL grant for the win32 userData tree. @@ -12,29 +12,23 @@ import { getIcaclsExePath, resolveCurrentWindowsIdentity } from '../win32-utils' * (codex-runtime-home, agent-hooks, …) fail with EPERM. Explicit ACEs survive * future DACL propagation, so granting them once fixes the tree permanently. * - * Why not `icacls /T` synchronously (the previous implementation): NTFS - * inheritance is recalculated from the immediate parent, so explicit ACEs on - * userData + its immediate children already protect the whole tree — per-file - * ACEs on tens of thousands of Chromium cache files are pure waste. On a real - * 28k-file profile the recursive walk measured 62s, blocking the main thread - * before first paint and then *timing out* (60s cap), so users paid ~1 minute - * per launch for a grant that never completed. + * Why not a recursive per-file walk: NTFS inheritance is recalculated from the + * immediate parent, so explicit ACEs on userData + its immediate child + * directories already protect the whole tree. On a real 28k-file profile a + * recursive walk measured 62s. * * Strategy: * - A marker file records that the grant completed for this identity. When it - * matches, startup performs zero icacls spawns. - * - When absent, the grant runs asynchronously (never blocks window creation): - * userData root + immediate children (`\*`). Per-write EPERM + * matches, startup touches no ACL at all. + * - When absent, the grant runs on the thread pool (never blocks window + * creation): each immediate child directory, then the root. Per-write EPERM * retries in codex-accounts/fs-utils and agent-hooks/installer-utils remain - * the backstop during the brief async window, exactly as they already were - * for the (common) case where the old synchronous walk timed out. + * the backstop during that window. */ export const WINDOWS_ACL_GRANT_MARKER_FILE = 'windows-acl-grant.json' export const WINDOWS_ACL_GRANT_SCHEME_VERSION = 1 -const GRANT_TIMEOUT_MS = 120_000 - type WindowsAclGrantMarker = { schemeVersion: number identity: string @@ -49,10 +43,6 @@ export type WindowsAclGrantResult = type EnsureOptions = { onDone?: (result: WindowsAclGrantResult) => void - /** Test seam — defaults to node:child_process spawn. */ - spawnFn?: typeof spawn - /** Test seam — defaults to the real current-user identity. */ - identity?: string | null } function readMarker(userDataPath: string): WindowsAclGrantMarker | null { @@ -81,40 +71,30 @@ function writeMarker(userDataPath: string, identity: string): void { writeFileSync(join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE), JSON.stringify(marker)) } -function runIcaclsGrant( - spawnFn: typeof spawn, - target: string, - identity: string -): Promise<{ ok: boolean; reason?: string }> { - return new Promise((resolve) => { - // /C continues past per-entry errors (e.g. files locked by another - // process); a partial grant is still strictly better than none and the - // per-write EPERM backstop covers stragglers. - const child = spawnFn( - getIcaclsExePath(), - [target, '/grant:r', `${identity}:(OI)(CI)(F)`, '/C'], - { - stdio: 'ignore', - windowsHide: true - } - ) - let settled = false - const settle = (ok: boolean, reason?: string): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - resolve(ok ? { ok } : { ok, reason }) +/** `*` + SID is the spelling markers written by the icacls-era grant already use. */ +function currentIdentity(): string | null { + const security = loadWindowsSecurity() + return 'error' in security ? null : `*${security.userSid}` +} + +function childDirectories(userDataPath: string): string[] { + return readdirSync(userDataPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(userDataPath, entry.name)) +} + +async function grantEach(directories: string[]): Promise { + const failures: string[] = [] + // Sequential: each grant already walks its subtree on a pool thread. + for (const directory of directories) { + try { + await grantDirAclAsync(directory) + } catch (error) { + // A partial grant is still strictly better than none; the per-write EPERM backstop covers stragglers. + failures.push(`${directory}: ${error instanceof Error ? error.message : String(error)}`) } - const timer = setTimeout(() => { - child.kill() - settle(false, 'timeout') - }, GRANT_TIMEOUT_MS) - timer.unref?.() - child.on('error', (error) => settle(false, error.message)) - child.on('exit', (code) => settle(code === 0, code === 0 ? undefined : `exit ${code}`)) - }) + } + return failures } /** @@ -127,26 +107,33 @@ export function ensureWindowsUserDataAclGrant( options: EnsureOptions = {} ): void { const onDone = options.onDone ?? ((): void => undefined) - const identity = - options.identity !== undefined ? options.identity : resolveCurrentWindowsIdentity() + const identity = currentIdentity() if (!identity) { onDone({ mode: 'no-identity' }) return } const marker = readMarker(userDataPath) - if (marker && marker.identity === identity) { + // Why the legacy name too: markers written before the native grant record USERNAME, and + // re-granting would re-walk the whole tree once for nothing. + if (marker && (marker.identity === identity || marker.identity === process.env.USERNAME)) { onDone({ mode: 'marker-hit' }) return } - const spawnFn = options.spawnFn ?? spawn void (async () => { // Immediate children first: those explicit ACEs are the durable fix // (Chromium replaces the root DACL on every BrowserWindow construction, // but never strips explicit ACEs from children). Root second so writes // directly under userData succeed before Chromium's first reset. - const children = await runIcaclsGrant(spawnFn, join(userDataPath, '*'), identity) - const root = await runIcaclsGrant(spawnFn, userDataPath, identity) - if (children.ok && root.ok) { + const failures: string[] = [] + let children: string[] = [] + try { + children = childDirectories(userDataPath) + } catch (error) { + // Still grant the root: a listing the broken DACL refuses is what that grant repairs. + failures.push(`${userDataPath}: ${error instanceof Error ? error.message : String(error)}`) + } + failures.push(...(await grantEach([...children, userDataPath]))) + if (failures.length === 0) { try { writeMarker(userDataPath, identity) onDone({ mode: 'granted' }) @@ -155,9 +142,6 @@ export function ensureWindowsUserDataAclGrant( } return } - onDone({ - mode: 'failed', - reason: [children.reason, root.reason].filter(Boolean).join('; ') || 'unknown' - }) + onDone({ mode: 'failed', reason: failures.join('; ') }) })() } diff --git a/src/main/win32-utils-async-acl.test.ts b/src/main/win32-utils-async-acl.test.ts index 22c9ff992c8..4c904172f18 100644 --- a/src/main/win32-utils-async-acl.test.ts +++ b/src/main/win32-utils-async-acl.test.ts @@ -1,157 +1,68 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess, runProcessSync } from '@orca/process-host' +import { setWindowsSecurityForTests } from '../shared/windows-security-addon' +import { + createFakeWindowsSecurity, + FAKE_USER_SID, + type FakeWindowsSecurity +} from '../shared/windows-security-addon.test-fixture' +import { grantDirAcl, grantDirAclAsync } from './win32-utils' -const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - execFileSyncMock: vi.fn() +vi.mock('@orca/process-host', () => ({ + runProcess: vi.fn(), + runProcessSync: vi.fn() })) -vi.mock('node:child_process', () => ({ - execFile: execFileMock, - execFileSync: execFileSyncMock -})) +const USER_DIRECTORY_GRANT = { + sid: FAKE_USER_SID, + mask: 0x1f01ff, + options: { inheritable: true, replace: true } +} -type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void +describe('grantDirAcl', () => { + let fake: FakeWindowsSecurity -const originalUsername = process.env.USERNAME - -beforeEach(() => { - vi.resetModules() - execFileMock.mockReset() - execFileSyncMock.mockReset() -}) - -afterEach(() => { - if (originalUsername === undefined) { - delete process.env.USERNAME - } else { - process.env.USERNAME = originalUsername - } -}) - -describe('grantDirAclAsync', () => { - it('keeps icacls off the synchronous main-process path', async () => { - process.env.USERNAME = 'alice' - let complete: ExecCallback | undefined - execFileMock.mockImplementation( - (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { - complete = callback - return {} as never - } - ) - const { getIcaclsExePath, grantDirAclAsync } = await import('./win32-utils') - - const pending = grantDirAclAsync('C:\\Users\\alice\\Orca') - await Promise.resolve() - - expect(execFileSyncMock).not.toHaveBeenCalled() - expect(execFileMock).toHaveBeenCalledWith( - getIcaclsExePath(), - ['C:\\Users\\alice\\Orca', '/grant:r', 'alice:(OI)(CI)(F)'], - { encoding: 'utf-8', windowsHide: true, timeout: 10_000 }, - expect.any(Function) - ) - complete?.(null, '', '') - await expect(pending).resolves.toBeUndefined() + beforeEach(() => { + fake = createFakeWindowsSecurity() + setWindowsSecurityForTests(fake) }) - it('resolves a missing username through asynchronous whoami before icacls', async () => { - delete process.env.USERNAME - execFileMock - .mockImplementationOnce( - (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '') - return {} as never - } - ) - .mockImplementationOnce( - (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, '', '') - return {} as never - } - ) - const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils') - - await grantDirAclAsync('C:\\Orca') - - expect(execFileMock.mock.calls[0]?.slice(0, 3)).toEqual([ - getWhoamiExePath(), - ['/user', '/fo', 'csv', '/nh'], - { encoding: 'utf-8', windowsHide: true, timeout: 5000 } - ]) - expect(execFileMock.mock.calls[1]?.[1]).toEqual([ - 'C:\\Orca', - '/grant:r', - '*S-1-5-21-123:(OI)(CI)(F)' - ]) - expect(execFileSyncMock).not.toHaveBeenCalled() + afterEach(() => { + // Neither grant may start a process: no icacls, no whoami. + expect(runProcess).not.toHaveBeenCalled() + expect(runProcessSync).not.toHaveBeenCalled() + setWindowsSecurityForTests(null) }) - it('does not overwrite a concurrent cached identity with a late async result', async () => { - delete process.env.USERNAME - let completeWhoami: ExecCallback | undefined - execFileMock - .mockImplementationOnce( - (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { - completeWhoami = callback - return {} as never - } - ) - .mockImplementationOnce( - (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, '', '') - return {} as never - } - ) - execFileSyncMock.mockReturnValue('"DOMAIN\\sync","S-1-5-21-456"\r\n') - const { grantDirAclAsync, resolveCurrentWindowsIdentity } = await import('./win32-utils') + it('replaces the user rule with inheritable full control, by SID, in-process', () => { + grantDirAcl('C:\\Users\\alice\\Orca') - const pending = grantDirAclAsync('C:\\Orca') - await Promise.resolve() - expect(resolveCurrentWindowsIdentity()).toBe('*S-1-5-21-456') - completeWhoami?.(null, '"DOMAIN\\async","S-1-5-21-789"\r\n', '') - await pending - - expect(execFileMock.mock.calls[1]?.[1]).toEqual([ - 'C:\\Orca', - '/grant:r', - '*S-1-5-21-456:(OI)(CI)(F)' + expect(fake.calls).toMatchObject([ + { op: 'grant', path: 'C:\\Users\\alice\\Orca', ...USER_DIRECTORY_GRANT } ]) }) - it('retries identity resolution after a transient whoami failure', async () => { - delete process.env.USERNAME - execFileMock - .mockImplementationOnce( - (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(new Error('whoami timed out'), '', '') - return {} as never - } - ) - .mockImplementationOnce( - (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '') - return {} as never - } - ) - .mockImplementationOnce( - (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, '', '') - return {} as never - } - ) - const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils') + it('runs the asynchronous grant on the thread pool rather than the main thread', async () => { + await grantDirAclAsync('C:\\Users\\alice\\Orca') - await grantDirAclAsync('C:\\Orca') - await grantDirAclAsync('C:\\Orca') - - expect( - execFileMock.mock.calls.filter(([command]) => command === getWhoamiExePath()) - ).toHaveLength(2) - expect(execFileMock.mock.calls[2]?.[1]).toEqual([ - 'C:\\Orca', - '/grant:r', - '*S-1-5-21-123:(OI)(CI)(F)' + expect(fake.calls).toMatchObject([ + { op: 'grantAsync', path: 'C:\\Users\\alice\\Orca', ...USER_DIRECTORY_GRANT } ]) - expect(execFileSyncMock).not.toHaveBeenCalled() + }) + + // Callers retry their write after the grant and re-throw the original EPERM if it still fails. + it('throws a failed grant so the caller can surface its original error', async () => { + fake.failing.add('C:\\Orca') + + expect(() => grantDirAcl('C:\\Orca')).toThrow(/Win32 error 5/) + await expect(grantDirAclAsync('C:\\Orca')).rejects.toThrow(/Win32 error 5/) + }) + + it('is a silent no-op when the addon cannot load', async () => { + setWindowsSecurityForTests(null) + + expect(() => grantDirAcl('C:\\Orca')).not.toThrow() + await expect(grantDirAclAsync('C:\\Orca')).resolves.toBeUndefined() + expect(fake.calls).toEqual([]) }) }) diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 06df30523d9..378ba080e26 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -1,6 +1,6 @@ -import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from 'node:child_process' import { delimiter, join, win32 } from 'node:path' import { existsSync } from 'node:fs' +import { loadWindowsSecurity, WINDOWS_FILE_ALL_ACCESS } from '../shared/windows-security-addon' export { getCmdExePath, @@ -13,35 +13,6 @@ export { type GetSpawnArgsForWindowsOptions } from '../shared/windows-batch-spawn' -function execFileWithoutBlocking( - command: string, - args: string[], - options: ExecFileOptionsWithStringEncoding -): Promise { - return new Promise((resolve, reject) => { - execFile(command, args, options, (error, stdout) => { - if (error) { - reject(error) - return - } - resolve(stdout) - }) - }) -} - -/** - * Full path to icacls.exe. Electron's main process may have a stripped PATH - * that excludes System32, causing bare `icacls` to throw ENOENT. - */ -export function getIcaclsExePath(): string { - return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\icacls.exe` -} - -/** Absolute path because service-launched Electron can omit System32 from PATH. */ -export function getWhoamiExePath(): string { - return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\whoami.exe` -} - /** Absolute path because service-launched Electron can omit System32 from PATH. */ export function getRegExePath(env: NodeJS.ProcessEnv = process.env): string { const systemRoot = env.SystemRoot?.trim() @@ -86,136 +57,42 @@ export function isPermissionError(error: unknown): boolean { ) } -// Why: USERNAME-only identity resolution silently no-ops under services, CI, -// and hardened envs where USERNAME is unset. Fall back to the SID via -// `whoami /user` (same strategy as runtime-metadata.ts), which is authoritative -// and always available on Windows. Cached because it never changes in-process. -let cachedIdentity: string | undefined -let pendingIdentityResolution: Promise | null = null - -function cachedOrEnvironmentIdentity(): string | undefined { - if (cachedIdentity !== undefined) { - return cachedIdentity - } - if (process.env.USERNAME) { - cachedIdentity = process.env.USERNAME - return cachedIdentity - } - return undefined -} - -function identityFromWhoamiOutput(output: string): string | null { - // CSV columns: "DOMAIN\\user","S-1-5-21-..." - const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output.trim()) - return sidMatch ? `*${sidMatch[1]}` : null -} - -export function resolveCurrentWindowsIdentity(): string | null { - return resolveCurrentIdentity() -} - -function resolveCurrentIdentity(): string | null { - const knownIdentity = cachedOrEnvironmentIdentity() - if (knownIdentity !== undefined) { - return knownIdentity - } - try { - const output = execFileSync(getWhoamiExePath(), ['/user', '/fo', 'csv', '/nh'], { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - windowsHide: true, - timeout: 5000 - }) - const resolvedIdentity = identityFromWhoamiOutput(output) - if (resolvedIdentity) { - cachedIdentity = resolvedIdentity - } - return resolvedIdentity - } catch { - return null - } -} - -async function resolveCurrentIdentityAsync(): Promise { - const knownIdentity = cachedOrEnvironmentIdentity() - if (knownIdentity !== undefined) { - return knownIdentity - } - if (!pendingIdentityResolution) { - pendingIdentityResolution = (async () => { - try { - const stdout = await execFileWithoutBlocking( - getWhoamiExePath(), - ['/user', '/fo', 'csv', '/nh'], - { - encoding: 'utf-8', - windowsHide: true, - timeout: 5000 - } - ) - // Why: a synchronous caller may resolve identity while async whoami - // is in flight; its authoritative cached result must win the race. - const resolvedIdentity = identityFromWhoamiOutput(stdout) - if (cachedIdentity === undefined && resolvedIdentity) { - cachedIdentity = resolvedIdentity - } - return cachedIdentity ?? resolvedIdentity - } catch { - // Why: transient service/PATH failures must not permanently disable - // ACL repair for every later crash attempt in this process. - return cachedIdentity ?? null - } - })().finally(() => { - pendingIdentityResolution = null - }) - } - return pendingIdentityResolution -} +const USER_DIRECTORY_GRANT = { inheritable: true, replace: true } as const /** * Grant Full Control (OI)(CI)(F) on a directory for the current user. * Used to fix Chromium's Protected DACL propagation which leaves child * directories with Inherit-Only ACEs that deny direct file creation. * - * Why /grant:r not /inheritance:e: Chromium's ACEs carry the Inherit-Only - * flag when propagated to children, so restoring inheritance does not grant - * the directory itself any effective permissions. An explicit ACE survives - * future DACL propagation and grants create-file rights. + * Why replace the user's rule rather than restore inheritance: Chromium's ACEs carry the + * Inherit-Only flag when propagated to children, so restoring inheritance does not grant the + * directory itself any effective permissions. An explicit ACE survives future DACL propagation + * and grants create-file rights. + * + * Throws when the grant fails; a host without the native addon is a silent no-op, which leaves + * the caller's original EPERM to surface. */ -export function grantDirAcl(dirPath: string, options?: { recursive?: boolean }): void { - const identity = resolveCurrentIdentity() - if (!identity) { - return +export function grantDirAcl(dirPath: string): void { + const security = loadWindowsSecurity() + if (!('error' in security)) { + security.addon.grantAccess( + dirPath, + security.userSid, + WINDOWS_FILE_ALL_ACCESS, + USER_DIRECTORY_GRANT + ) } - const args = [dirPath, '/grant:r', `${identity}:(OI)(CI)(F)`] - if (options?.recursive) { - args.push('/T', '/C') - } - // Why: /T walks the entire subtree; a 10s cap can starve on large userData - // dirs (tens of thousands of cached chromium files), making the startup - // grant silently fail. Give recursive calls a generous budget. - const timeout = options?.recursive ? 60_000 : 10_000 - execFileSync(getIcaclsExePath(), args, { - stdio: 'ignore', - windowsHide: true, - timeout - }) } +/** As grantDirAcl, on the thread pool: the grant propagates through the directory's subtree. */ export async function grantDirAclAsync(dirPath: string): Promise { - const identity = await resolveCurrentIdentityAsync() - if (!identity) { - return + const security = loadWindowsSecurity() + if (!('error' in security)) { + await security.addon.grantAccessAsync( + dirPath, + security.userSid, + WINDOWS_FILE_ALL_ACCESS, + USER_DIRECTORY_GRANT + ) } - // Why: crash recovery runs on Electron's main thread; an asynchronous - // icacls child keeps its worst-case timeout from freezing every window. - await execFileWithoutBlocking( - getIcaclsExePath(), - [dirPath, '/grant:r', `${identity}:(OI)(CI)(F)`], - { - encoding: 'utf-8', - windowsHide: true, - timeout: 10_000 - } - ) } diff --git a/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts b/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts index 7e069327e89..56897f1801b 100644 --- a/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts @@ -34,12 +34,6 @@ vi.mock('node:fs', async (original) => { const real = await original() return { ...real, - writeFileSync: (...args: Parameters) => { - if (++state.writes === 2 && state.fault === 'before-restore') { - throw new Error('Synthetic restore staging failure') - } - return real.writeFileSync(...args) - }, renameSync: (...args: Parameters) => { real.renameSync(...args) if (++state.renames === 2 && state.fault === 'after-restore') { @@ -48,30 +42,31 @@ vi.mock('node:fs', async (original) => { } } }) +// The real born-restricted create, with faults injected around it. vi.mock('../../shared/secure-path-windows-acl', async (original) => { const real = await original() const files = await vi.importActual('node:fs') return { ...real, - restrictWindowsPathSync: (...args: Parameters) => { - const outcome = real.restrictWindowsPathSync(...args) + writeRestrictedWindowsFile: (path: string, contents: string) => { + if (++state.writes === 2 && state.fault === 'before-restore') { + throw new Error('Synthetic restore staging failure') + } + const outcome = real.writeRestrictedWindowsFile(path, contents) state.nativeOutcomes.push(outcome) - if (state.fault === 'competing' && args[0].endsWith('.tmp') && ++state.stages === 2) { + if (state.fault === 'competing' && ++state.stages === 2) { const competing = join(state.home, '.orca', 'competing-key') - files.writeFileSync(competing, envelope('synthetic-competing')) - expect(real.restrictWindowsPathSync(competing, false)).toBe(true) + expect(real.writeRestrictedWindowsFile(competing, envelope('synthetic-competing'))).toBe( + true + ) files.renameSync(competing, join(state.home, '.orca', 'zcode-plan-api-key.enc')) } return state.reject ? false : outcome }, - bestEffortRestrictWindowsPath: ( - path: string, - directory: boolean, - settled?: (restricted: boolean) => void - ) => - real.bestEffortRestrictWindowsPath(path, directory, (restricted) => { + restrictWindowsDirectory: (path: string) => + real.restrictWindowsDirectory(path).then((restricted) => { state.directories.push(restricted) - settled?.(restricted) + return restricted }) } }) diff --git a/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts b/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts index 5784b8a0a6d..88cb7d844b2 100644 --- a/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts @@ -22,14 +22,15 @@ vi.mock('node:fs', async (original) => ({ writeFileSync: mocks.write, renameSync: mocks.rename })) +// Stands in for the born-restricted create: it stages through the mocked writeFileSync (the +// fallback the real one uses) and reports whatever restriction outcome the test injects. vi.mock('../../shared/secure-path-windows-acl', () => ({ restrictWindowsPathSync: mocks.restrict, - bestEffortRestrictWindowsPath: ( - _path: string, - _directory: boolean, - settled: (restricted: boolean) => void - ) => settled(false), - resetSecureFileWindowsUserSidForTests: vi.fn() + writeRestrictedWindowsFile: (path: string, contents: string) => { + mocks.write(path, contents, { encoding: 'utf-8', mode: 0o600 }) + return mocks.restrict(path, false) + }, + restrictWindowsDirectory: () => Promise.resolve(false) })) const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! @@ -81,6 +82,8 @@ describe('plaintext rollback through the secure-file writer', () => { expect(realFs.readdirSync(join(mocks.home, '.orca'))).toEqual([]) }) + // Windows has no post-rename restriction any more; the published path is next restricted by + // the read path, and that failing must not cost the restored key either. it.each(['restriction-false', 'restriction-throws'])( 'keeps the previous envelope if rollback publishes before %s', (failure) => { @@ -114,11 +117,10 @@ describe('plaintext rollback through the secure-file writer', () => { realFs.writeFileSync(keyPath, envelope('previous-key')) } if (phase === 'before-rollback') { - mocks.restrict.mockImplementation((path) => { - if (path === keyPath && mocks.write.mock.calls.length === 1) { - realFs.writeFileSync(keyPath, envelope('other-writer-key')) - } - return false + // Another writer lands between the rejected publication and the rollback. + mocks.rename.mockImplementationOnce((from, to) => { + realFs.renameSync(from, to) + realFs.writeFileSync(keyPath, envelope('other-writer-key')) }) } else { mocks.write.mockImplementationOnce(realFs.writeFileSync).mockImplementationOnce(() => { @@ -316,12 +318,8 @@ describe('conditional rollback publication through the real writer', () => { }) ).toBe(true) - expect(operations).toEqual([ - 'staged restriction', - 'ownership check', - 'rename', - 'published restriction' - ]) + // The rename carries the staged file's DACL, so nothing restricts the published path again. + expect(operations).toEqual(['staged restriction', 'ownership check', 'rename']) }) it('cleans guarded staging bytes if rename throws without deleting the original target', () => { diff --git a/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts b/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts index dca5e914eb0..f55a210ea0f 100644 --- a/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts +++ b/src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts @@ -11,21 +11,25 @@ import { import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Fs from 'node:fs' import type * as SecureFile from '../../shared/secure-file' import type * as ZcodePlanApiKeyStore from './zcode-plan-api-key-store' const restriction = vi.hoisted(() => vi.fn((_path: string, _directory: boolean) => false)) vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => false } })) -vi.mock('../../shared/secure-path-windows-acl', () => ({ - restrictWindowsPathSync: restriction, - bestEffortRestrictWindowsPath: ( - _path: string, - _directory: boolean, - settled: (restricted: boolean) => void - ) => settled(false), - resetSecureFileWindowsUserSidForTests: vi.fn() -})) +// Stands in for the born-restricted create: a real staged write, then the injected outcome. +vi.mock('../../shared/secure-path-windows-acl', async () => { + const { writeFileSync: stage } = await vi.importActual('node:fs') + return { + restrictWindowsPathSync: restriction, + writeRestrictedWindowsFile: (path: string, contents: string) => { + stage(path, contents, { encoding: 'utf-8', mode: 0o600 }) + return restriction(path, false) + }, + restrictWindowsDirectory: () => Promise.resolve(false) + } +}) const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! const envelope = (key: string): string => diff --git a/src/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts index 69804b63c9c..68e3edfcde0 100644 --- a/src/shared/orcad-artifacts.test.ts +++ b/src/shared/orcad-artifacts.test.ts @@ -10,10 +10,12 @@ import { ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_RIPGREP_ARTIFACTS, ORCAD_RIPGREP_LICENSE_ARTIFACTS, + ORCAD_WINDOWS_SECURITY_FILENAME, orcadArtifactFilenames, orcadBunRuntimeFilename, orcadNodeRuntimeRelativePath, - orcadTemplateCommonFilenames + orcadTemplateCommonFilenames, + orcadTemplateTargetFilenames } from './orcad-artifacts' describe('standalone runtime artifacts', () => { @@ -76,6 +78,16 @@ describe('standalone runtime artifacts', () => { ]) }) + // Without it orcad's data-root privacy check fails closed and a Windows slot never starts. + it('requires the Windows security addon in every Windows slot and nowhere else', () => { + for (const target of ['win32-x64', 'win32-arm64']) { + expect(orcadArtifactFilenames(target)).toContain(ORCAD_WINDOWS_SECURITY_FILENAME) + expect(orcadTemplateTargetFilenames(target)).toContain(ORCAD_WINDOWS_SECURITY_FILENAME) + } + expect(orcadArtifactFilenames('linux-x64-glibc')).not.toContain(ORCAD_WINDOWS_SECURITY_FILENAME) + expect(orcadArtifactFilenames('darwin-arm64')).not.toContain(ORCAD_WINDOWS_SECURITY_FILENAME) + }) + it('keeps the runtime executable under its upstream name in the shared store', () => { expect(orcadNodeRuntimeRelativePath('linux-x64-glibc', 'a'.repeat(64))).toEqual([ '..', diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 3455f3563e1..25f299e54db 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -12,6 +12,8 @@ export const ORCAD_BUN_RUNTIME_FILENAME = 'bun-runtime' export const ORCAD_WINDOWS_BUN_RUNTIME_FILENAME = 'bun-runtime.exe' export const ORCAD_BUILD_TARGET_FILENAME = '.build-target' export const ORCAD_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node' +/** native/windows-security: secure-file ACLs without spawning icacls. */ +export const ORCAD_WINDOWS_SECURITY_FILENAME = 'windows-security.node' /** native/windows-cli-launcher: cmd.exe and .ps1 shims cannot carry every argv intact. */ export const ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME = 'bin/orca.exe' @@ -206,7 +208,11 @@ export function orcadArtifactFilenames(target: string): string[] { ) filenames.push(...orcadNodePtyNativeArtifacts(target), orcadRipgrepArtifact(target)) if (isWindowsTarget(target)) { - filenames.push(ORCAD_WINDOWS_PROCESS_TREE_FILENAME, ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME) + filenames.push( + ORCAD_WINDOWS_PROCESS_TREE_FILENAME, + ORCAD_WINDOWS_SECURITY_FILENAME, + ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME + ) } return filenames } diff --git a/src/shared/secure-file-coarse-ctime.test.ts b/src/shared/secure-file-coarse-ctime.test.ts index 64c33ba36f1..daff28255ca 100644 --- a/src/shared/secure-file-coarse-ctime.test.ts +++ b/src/shared/secure-file-coarse-ctime.test.ts @@ -29,11 +29,8 @@ vi.mock('child_process', () => ({ execFileSync: vi.fn() })) -import { - __resetSecureFileHardenedPathsForTests, - __resetSecureFileWindowsUserSidForTests, - hardenExistingSecureFile -} from './secure-file' +import { resetWindowsSecurityLoadForTests } from './windows-security-addon' +import { __resetSecureFileHardenedPathsForTests, hardenExistingSecureFile } from './secure-file' describe('secure-file coarse-ctime mode drift', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -61,7 +58,7 @@ describe('secure-file coarse-ctime mode drift', () => { state.fileMode = mode & 0o777 } }) - __resetSecureFileWindowsUserSidForTests() + resetWindowsSecurityLoadForTests() __resetSecureFileHardenedPathsForTests() }) @@ -69,7 +66,7 @@ describe('secure-file coarse-ctime mode drift', () => { chmodSyncMock.mockReset() existsSyncMock.mockReset() statSyncMock.mockReset() - __resetSecureFileWindowsUserSidForTests() + resetWindowsSecurityLoadForTests() __resetSecureFileHardenedPathsForTests() if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/shared/secure-file-fsync-flags.test.ts b/src/shared/secure-file-fsync-flags.test.ts index 6cb7b0fe1fc..e0366cc161b 100644 --- a/src/shared/secure-file-fsync-flags.test.ts +++ b/src/shared/secure-file-fsync-flags.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, statSync, writeFileSync } from 'node:fs' import { removeTreeSync } from './windows-transient-lock-removal' import type * as NodeFs from 'node:fs' import { tmpdir } from 'node:os' @@ -76,7 +76,11 @@ describe('secure file fsync flags', () => { process.umask(originalUmask) } - expect(openedPaths[0]).toMatchObject({ flags: 'r', ownerWritable: false }) + // The temp file is chmodded to 0o600 before the fsync, so the fsync also covers the mode change; + // what matters is that it opens read-only and the published file lands at exactly 0o600. + expect(openedPaths[0]).toMatchObject({ flags: 'r' }) + expect(openedPaths[0]?.path).toMatch(/record\.json\.\d+\.\d+\.[0-9a-f]+\.tmp$/) + expect(statSync(path).mode & 0o777).toBe(0o600) }) posixIt('opens directories read-only before fsync', () => { diff --git a/src/shared/secure-file-windows.test.ts b/src/shared/secure-file-windows.test.ts new file mode 100644 index 00000000000..f82399b0b2e --- /dev/null +++ b/src/shared/secure-file-windows.test.ts @@ -0,0 +1,406 @@ +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess, runProcessSync } from '@orca/process-host' +import { + __getSecureFileHardeningCacheStateForTests, + __resetSecureFileHardenedPathsForTests, + hardenExistingSecureFile, + writeSecureFile +} from './secure-file' +import { mayAttemptHardening } from './secure-path-hardening-retry-budget' +import { + setSecurePathHardeningReporter, + type SecurePathHardeningReport +} from './secure-path-hardening-report' +import { + loadWindowsSecurity, + resetWindowsSecurityLoadForTests, + setWindowsSecurityForTests +} from './windows-security-addon' +import { + createFakeWindowsSecurity, + FAKE_USER_SID, + type FakeWindowsSecurity +} from './windows-security-addon.test-fixture' + +vi.mock('@orca/process-host', () => ({ + runProcess: vi.fn(), + runProcessSync: vi.fn() +})) + +const FILE_SDDL = `D:P(A;;FA;;;S-1-5-18)(A;;FA;;;S-1-5-32-544)(A;;FA;;;${FAKE_USER_SID})` +const DIRECTORY_SDDL = `D:P(A;OICI;FA;;;S-1-5-18)(A;OICI;FA;;;S-1-5-32-544)(A;OICI;FA;;;${FAKE_USER_SID})` +/** What the icacls-era hardening left on disk; it must keep verifying with no rewrite. */ +const ICACLS_ERA_FILE_SDDL = `D:PAI(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;${FAKE_USER_SID})` +const ICACLS_ERA_DIRECTORY_SDDL = `D:PAI(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)(A;OICI;FA;;;${FAKE_USER_SID})` + +describe('Windows secure files through the native ACL addon', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + const tempDirs: string[] = [] + let fake: FakeWindowsSecurity + let reports: SecurePathHardeningReport[] + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + resetWindowsSecurityLoadForTests() + __resetSecureFileHardenedPathsForTests() + fake = createFakeWindowsSecurity() + setWindowsSecurityForTests(fake) + reports = [] + setSecurePathHardeningReporter((entry) => reports.push(entry)) + vi.mocked(runProcess).mockReset() + vi.mocked(runProcessSync).mockReset() + }) + + afterEach(() => { + // The whole point of #25583 and #26027: no ACL work may start a process. + expect(runProcess).not.toHaveBeenCalled() + expect(runProcessSync).not.toHaveBeenCalled() + setSecurePathHardeningReporter(null) + setWindowsSecurityForTests(null) + resetWindowsSecurityLoadForTests() + __resetSecureFileHardenedPathsForTests() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-secure-file-win-')) + tempDirs.push(dir) + return dir + } + + async function settle(): Promise { + for (let i = 0; i < 4; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + + describe('write path', () => { + it('creates the credential already restricted and publishes it without touching its ACL', () => { + const dir = tempDir() + const targetPath = join(dir, 'secret.json') + + expect(writeSecureFile(targetPath, 'contents')).toBe(true) + + const [create] = fake.callsFor('create') + expect(create?.path).toMatch(/secret\.json\.\d+\.\d+\.[0-9a-f]+\.tmp$/) + expect(create && 'sddl' in create ? create.sddl : '').toBe(FILE_SDDL) + expect(readFileSync(targetPath, 'utf-8')).toBe('contents') + // Born restricted: no ACL is ever set on the temp file or the published one. + expect(fake.callsFor('set')).toEqual([]) + expect(fake.calls.some((call) => call.path === targetPath)).toBe(false) + }) + + it('reports false but still writes when the addon cannot load, and says so once', () => { + setWindowsSecurityForTests(null) + const dir = tempDir() + + for (const name of ['a.json', 'b.json', 'c.json']) { + expect(writeSecureFile(join(dir, name), name)).toBe(false) + expect(readFileSync(join(dir, name), 'utf-8')).toBe(name) + } + expect(reports.filter((entry) => entry.stage === 'addon')).toHaveLength(1) + }) + + // Never a truncating plain write of the secret: a failed create surfaces like any fs failure. + it('throws a failed restricted create and leaves no temp file', () => { + const dir = tempDir() + const targetPath = join(dir, 'secret.json') + writeFileSync(targetPath, 'previous') + fake.createFileWithDacl = () => { + throw new Error('CreateFileW failed with Win32 error 5') + } + + expect(() => writeSecureFile(targetPath, 'contents')).toThrow( + 'CreateFileW failed with Win32 error 5' + ) + + expect(readFileSync(targetPath, 'utf-8')).toBe('previous') + expect(readdirSync(dir).filter((name) => name.endsWith('.tmp'))).toEqual([]) + }) + + it('reports false when the filesystem drops the descriptor (FAT32, some shares)', () => { + const dir = tempDir() + const targetPath = join(dir, 'secret.json') + const realCreate = fake.createFileWithDacl + fake.createFileWithDacl = (path, sddl, data) => { + realCreate(path, sddl, data) + const created = fake.callsFor('create').at(-1)?.path ?? path + fake.dacls.set(created, 'D:NO_ACCESS_CONTROL') + } + + expect(writeSecureFile(targetPath, 'contents')).toBe(false) + + expect(reports).toContainEqual( + expect.objectContaining({ stage: 'verify', detail: expect.stringContaining('NULL DACL') }) + ) + }) + + it('clears the read-path backoff once a write restricts the same path', () => { + const dir = tempDir() + const targetPath = join(dir, 'secret.json') + writeFileSync(targetPath, '{}') + fake.failing.add(targetPath) + let clock = performance.now() + const now = vi.spyOn(performance, 'now').mockImplementation(() => clock) + for (const wait of [0, 61_000, 121_000]) { + clock += wait + hardenExistingSecureFile(targetPath) + } + expect(mayAttemptHardening(targetPath)).toBe(false) + + fake.failing.delete(targetPath) + expect(writeSecureFile(targetPath, 'contents')).toBe(true) + + expect(mayAttemptHardening(targetPath)).toBe(true) + expect(reports).toContainEqual(expect.objectContaining({ targetPath, stage: 'recovered' })) + now.mockRestore() + }) + }) + + describe('directory relock', () => { + it('relocks the parent once, on the thread pool, across many writes', async () => { + const dir = tempDir() + for (let i = 0; i < 5; i++) { + writeSecureFile(join(dir, `secret-${i}.json`), `contents-${i}`) + } + await settle() + + expect(fake.callsFor('setAsync', dir)).toEqual([ + expect.objectContaining({ op: 'setAsync', path: dir, sddl: DIRECTORY_SDDL }) + ]) + expect(fake.callsFor('set', dir)).toEqual([]) + }) + + it('never rewrites a directory the icacls-era hardening already locked', async () => { + const dir = tempDir() + fake.dacls.set(dir, ICACLS_ERA_DIRECTORY_SDDL) + + writeSecureFile(join(dir, 'secret.json'), 'contents') + await settle() + + expect(fake.callsFor('setAsync')).toEqual([]) + }) + + // A relock walks every descendant: ~2 s on a 29k-entry userData. Writes must not wait on it. + it('neither blocks a write nor starts twice while a slow relock is still running', async () => { + fake.holdAsync = true + const dir = tempDir() + + for (let i = 0; i < 3; i++) { + expect(writeSecureFile(join(dir, `secret-${i}.json`), 'contents')).toBe(true) + expect(readFileSync(join(dir, `secret-${i}.json`), 'utf-8')).toBe('contents') + } + await settle() + expect(fake.callsFor('setAsync', dir)).toHaveLength(1) + + fake.releaseAsync() + await settle() + writeSecureFile(join(dir, 'later.json'), 'contents') + await settle() + expect(fake.callsFor('setAsync', dir)).toHaveLength(1) + expect(fake.dacls.get(dir)).toBe(DIRECTORY_SDDL) + }) + + it('retries a failed relock only after its backoff, not on every write', async () => { + const dir = tempDir() + fake.failing.add(dir) + let clock = performance.now() + const now = vi.spyOn(performance, 'now').mockImplementation(() => clock) + + writeSecureFile(join(dir, 'a.json'), 'a') + await settle() + writeSecureFile(join(dir, 'b.json'), 'b') + await settle() + expect(fake.callsFor('setAsync', dir)).toHaveLength(1) + + clock += 61_000 + fake.failing.delete(dir) + writeSecureFile(join(dir, 'c.json'), 'c') + await settle() + expect(fake.callsFor('setAsync', dir)).toHaveLength(2) + expect(reports).toContainEqual(expect.objectContaining({ targetPath: dir, stage: 'apply' })) + now.mockRestore() + }) + }) + + describe('read path', () => { + function existingFile(): { dir: string; targetPath: string } { + const dir = tempDir() + const targetPath = join(dir, 'secret.json') + writeFileSync(targetPath, '{}') + return { dir, targetPath } + } + + it('fixes an inherited-ACL file in-process, synchronously, once while it is unchanged', () => { + const { targetPath } = existingFile() + + for (let i = 0; i < 3; i++) { + hardenExistingSecureFile(targetPath) + } + + expect(fake.callsFor('set', targetPath)).toEqual([ + expect.objectContaining({ op: 'set', path: targetPath, sddl: FILE_SDDL }) + ]) + expect(fake.callsFor('setAsync', targetPath)).toEqual([]) + }) + + it('leaves a file the icacls-era hardening locked exactly as it is', () => { + const { targetPath } = existingFile() + fake.dacls.set(targetPath, ICACLS_ERA_FILE_SDDL) + + hardenExistingSecureFile(targetPath) + + expect(fake.callsFor('set', targetPath)).toEqual([]) + expect(fake.dacls.get(targetPath)).toBe(ICACLS_ERA_FILE_SDDL) + }) + + it('rewrites a protected DACL that grants a stranger', () => { + const { targetPath } = existingFile() + fake.dacls.set(targetPath, 'D:PAI(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;WD)') + + hardenExistingSecureFile(targetPath) + + expect(fake.dacls.get(targetPath)).toBe(FILE_SDDL) + }) + + it('re-checks a file whose metadata changed after it was cached', async () => { + const { targetPath } = existingFile() + hardenExistingSecureFile(targetPath) + hardenExistingSecureFile(targetPath) + const checksWhileUnchanged = fake.callsFor('get', targetPath).length + await new Promise((resolve) => setTimeout(resolve, 20)) + writeFileSync(targetPath, '{"changed":true}') + + hardenExistingSecureFile(targetPath) + + expect(fake.callsFor('get', targetPath)).toHaveLength(checksWhileUnchanged + 1) + }) + + it('does not re-verify the parent directory as its mtime churns', async () => { + const { dir, targetPath } = existingFile() + hardenExistingSecureFile(targetPath) + await settle() + writeFileSync(join(dir, 'other.json'), '{}') + hardenExistingSecureFile(targetPath) + hardenExistingSecureFile(targetPath) + await settle() + + expect(fake.callsFor('get', dir)).toHaveLength(2) // verify, then the post-relock read-back + expect(fake.callsFor('setAsync', dir)).toHaveLength(1) + }) + + // The read path polls at ~2/s (#4901); a host that can never restrict must not log each time. + it('collapses a failing read-path poll to a single attempt', () => { + const { targetPath } = existingFile() + fake.failing.add(targetPath) + + for (let read = 0; read < 25; read++) { + hardenExistingSecureFile(targetPath) + } + + expect(fake.callsFor('set', targetPath)).toHaveLength(1) + }) + + it('re-probes a long-failing path once its backoff has elapsed', () => { + const { targetPath } = existingFile() + fake.failing.add(targetPath) + let clock = performance.now() + const now = vi.spyOn(performance, 'now').mockImplementation(() => clock) + + for (let step = 0; step < 48; step++) { + hardenExistingSecureFile(targetPath) + clock += 31 * 60_000 + } + + expect(fake.callsFor('set', targetPath)).toHaveLength(48) + now.mockRestore() + }) + + it('LRU-evicts file entries and safely re-checks an evicted path', () => { + __resetSecureFileHardenedPathsForTests({ + maxEntries: 2, + maxKeyBytes: 4096, + maxTotalKeyBytes: 8192 + }) + const dir = tempDir() + const paths = ['first.json', 'second.json', 'third.json'].map((name) => join(dir, name)) + for (const path of paths) { + writeFileSync(path, '{}') + hardenExistingSecureFile(path) + } + + hardenExistingSecureFile(paths[0]!) + + const checked = fake + .callsFor('get') + .map((call) => call.path) + .filter((path) => paths.includes(path)) + expect(checked.filter((path) => path === paths[0])).toHaveLength(3) // verify, read-back, re-check + expect(__getSecureFileHardeningCacheStateForTests().paths).toMatchObject({ entries: 2 }) + }) + + it('LRU-evicts directory entries instead of retaining every path', () => { + __resetSecureFileHardenedPathsForTests({ + maxEntries: 2, + maxKeyBytes: 4096, + maxTotalKeyBytes: 8192 + }) + const root = tempDir() + const files = ['first', 'second', 'third'].map((name) => { + mkdirSync(join(root, name)) + const file = join(root, name, 'secret.json') + writeFileSync(file, '{}') + return file + }) + for (const file of files) { + hardenExistingSecureFile(file) + } + expect(__getSecureFileHardeningCacheStateForTests().directories).toMatchObject({ + entries: 2 + }) + }) + }) + + // The prefix comes from the host's toNamespacedPath, which is the identity off Windows. + it.runIf(process.platform === 'win32')( + 'hands the addon extended-length paths past MAX_PATH', + () => { + const longPath = `C:\\Users\\me\\.orca\\${'d'.repeat(300)}\\secret.json` + const loaded = loadWindowsSecurity() + + expect('error' in loaded ? null : loaded.addon.getDaclAces(longPath)).toBeTruthy() + expect(fake.callsFor('get')[0]?.addonPath).toBe(`\\\\?\\${longPath}`) + } + ) + + // A grant reads the DACL and writes back a merge; racing a re-lock it could restore the broad one. + it('runs async DACL writes to the same path one after another', async () => { + const loaded = loadWindowsSecurity() + if ('error' in loaded) { + throw new Error('fake not installed') + } + fake.holdAsync = true + const order: string[] = [] + const relock = loaded.addon + .setDaclSddlAsync('C:\\Orca', 'D:P(A;OICI;FA;;;SY)') + .then(() => order.push('relock')) + const grant = loaded.addon + .grantAccessAsync('c:\\orca', FAKE_USER_SID, 0x1f01ff, { inheritable: true, replace: true }) + .then(() => order.push('grant')) + await settle() + + // The grant waits for the parked re-lock on the same path, whatever its letter case. + expect(fake.callsFor('grantAsync')).toEqual([]) + fake.releaseAsync() + await Promise.all([relock, grant]) + expect(order).toEqual(['relock', 'grant']) + }) +}) diff --git a/src/shared/secure-file.test.ts b/src/shared/secure-file.test.ts index 7ac742e1ffd..8f1c8d5ff3b 100644 --- a/src/shared/secure-file.test.ts +++ b/src/shared/secure-file.test.ts @@ -1,112 +1,35 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess, runProcessSync } from '@orca/process-host' -import { mayAttemptHardening } from './secure-path-hardening-retry-budget' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { __getSecureFileHardeningCacheStateForTests, __resetSecureFileHardenedPathsForTests, - __resetSecureFileWindowsUserSidForTests, hardenExistingSecureFile, - hardenSecurePath, isUnreadableError, writeSecureFile } from './secure-file' +import { + setWindowsSecurityForTests, + resetWindowsSecurityLoadForTests +} from './windows-security-addon' +import { createFakeWindowsSecurity } from './windows-security-addon.test-fixture' const posixModeIt = process.platform === 'win32' ? it.skip : it -vi.mock('@orca/process-host', () => ({ - runProcess: vi.fn(), - runProcessSync: vi.fn() -})) - -const OK = { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } -const USER_SID = 'S-1-5-21-1000' - -type FakeSpec = { program: string; args?: readonly string[] } - -/** Paths the fake considers already hardened, with the ACE flags the grant pass used. */ -const hardenedByFake = new Map() - -/** Paths whose verify pass should answer with a DACL that is not the intended one. */ -const forcedBadSddl = new Map() - -/** - * Stands in for icacls. `/save` really writes a UTF-16LE SDDL file, because the code under test - * reads that file back off disk — which also means these tests exercise the real SDDL parser - * rather than a restatement of it. - */ -function fakeIcacls(spec: FakeSpec): typeof OK { - const args = spec.args ?? [] - const path = args[0] ?? '' - const grantIndex = args.indexOf('/grant:r') - if (grantIndex !== -1) { - const grant = args[grantIndex + 1]! - hardenedByFake.set(path, grant.includes('(OI)(CI)') ? 'OICI' : '') - return OK - } - const saveIndex = args.indexOf('/save') - if (saveIndex === -1) { - return OK // /reset - } - writeFileSync(args[saveIndex + 1]!, fakeSddl(path), 'utf16le') - return OK -} - -function fakeSddl(path: string): string { - const forced = forcedBadSddl.get(path) - if (forced) { - return `name\r\n${forced}\r\n` - } - const aceFlags = hardenedByFake.get(path) - if (aceFlags === undefined) { - // Never hardened: the inherited DACL a fresh file carries, so the first verify must fail. - return `name\r\nD:(A;ID;FA;;;SY)(A;ID;FA;;;BA)(A;ID;FA;;;${USER_SID})\r\n` - } - const ace = (sid: string): string => `(A;${aceFlags};FA;;;${sid})` - return `name\r\nD:PAI${ace('BA')}${ace('SY')}${ace(USER_SID)}\r\n` -} - -describe('hardenSecurePath', () => { - const originalSystemRoot = process.env.SystemRoot - const originalWindir = process.env.WINDIR +describe('POSIX secure files', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const tempDirs: string[] = [] beforeEach(() => { - process.env.SystemRoot = 'C:\\Windows' - delete process.env.WINDIR - __resetSecureFileWindowsUserSidForTests() + resetWindowsSecurityLoadForTests() __resetSecureFileHardenedPathsForTests() - vi.mocked(runProcessSync).mockReset() - vi.mocked(runProcess).mockReset() - hardenedByFake.clear() - forcedBadSddl.clear() - // runProcessSync serves whoami.exe (SID lookup) and the SYNCHRONOUS icacls file-ACL path - // used by writeSecureFile. Directory + read-path re-hardens use async runProcess. - vi.mocked(runProcessSync).mockImplementation((spec) => { - if (spec.program === 'C:\\Windows\\System32\\whoami.exe') { - return { ...OK, stdout: `"USER","${USER_SID}"` } - } - return fakeIcacls(spec) - }) - vi.mocked(runProcess).mockImplementation((spec) => Promise.resolve(fakeIcacls(spec))) }) afterEach(() => { - if (originalSystemRoot === undefined) { - delete process.env.SystemRoot - } else { - process.env.SystemRoot = originalSystemRoot - } - if (originalWindir === undefined) { - delete process.env.WINDIR - } else { - process.env.WINDIR = originalWindir - } - __resetSecureFileWindowsUserSidForTests() + resetWindowsSecurityLoadForTests() __resetSecureFileHardenedPathsForTests() + setWindowsSecurityForTests(null) if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } @@ -115,618 +38,38 @@ describe('hardenSecurePath', () => { } }) - it('rewrites Windows ACLs through icacls, purging explicit ACEs before granting', async () => { - hardenSecurePath('C:\\Users\\me\\.orca\\secret.json', { - isDirectory: false, - platform: 'win32' - }) - await flushAsyncAcl() - - // whoami.exe called synchronously to obtain SID - expect(vi.mocked(runProcessSync).mock.calls[0]![0]).toMatchObject({ - program: 'C:\\Windows\\System32\\whoami.exe', - args: ['/user', '/fo', 'csv', '/nh'] - }) - - const specs = vi.mocked(runProcess).mock.calls.map(([spec]) => spec) - expect(specs.every((spec) => spec.program === 'C:\\Windows\\System32\\icacls.exe')).toBe(true) - // Verify runs first, so an already-correct DACL is never rewritten. - expect(specs[0]!.args?.slice(0, 2)).toEqual(['C:\\Users\\me\\.orca\\secret.json', '/save']) - expect(specs[1]!.args).toEqual(['C:\\Users\\me\\.orca\\secret.json', '/reset', '/q']) - expect(specs[2]!.args).toEqual([ - 'C:\\Users\\me\\.orca\\secret.json', - '/inheritance:r', - '/grant:r', - `*${USER_SID}:(F)`, - '/grant:r', - '*S-1-5-18:(F)', - '/grant:r', - '*S-1-5-32-544:(F)', - '/q' - ]) - // The apply is read back: a loosened ACL has to be detectable, not just overwritten. - expect(specs[3]!.args?.slice(0, 2)).toEqual(['C:\\Users\\me\\.orca\\secret.json', '/save']) - expect(specs[2]!.timeoutMs).toBe(5000) - }) - - // BLOCKING 1: re-running /reset on an already-correct DACL restores the inherited (broader) one - // for the few ms until the grant pass lands, for no gain. A correct DACL must be left alone. - it('leaves an already-correct ACL untouched instead of rewriting it', async () => { - const target = 'C:\\Users\\me\\.orca\\secret.json' - hardenedByFake.set(target, '') - - hardenSecurePath(target, { isDirectory: false, platform: 'win32' }) - await flushAsyncAcl() - - const specs = vi.mocked(runProcess).mock.calls.map(([spec]) => spec) - expect(specs).toHaveLength(1) - expect(specs[0]!.args).toContain('/save') - expect(specs.some((spec) => spec.args?.includes('/reset'))).toBe(false) - expect(specs.some((spec) => spec.args?.includes('/grant:r'))).toBe(false) - }) - - // BLOCKING 3: the verify pass must check *identity*, not just rule count, inheritance and rights. - // Granting Everyone full control satisfies all three of those and is the failure it exists for. - it.each([ - ['full control to Everyone', 'D:PAI(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;WD)', 'S-1-1-0'], - ['a deny rule', `D:PAI(D;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;${USER_SID})`, 'unexpected D rule'], - ['an unprotected DACL', `D:AI(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;${USER_SID})`, 'not protected'], - [ - 'a surviving inherited rule', - `D:PAI(A;ID;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;${USER_SID})`, - 'inherited' - ], - ['read-only rights', `D:PAI(A;;FR;;;BA)(A;;FA;;;SY)(A;;FA;;;${USER_SID})`, 'not full control'] - ])('rejects a verified DACL granting %s', async (_label, sddl, expected) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const target = 'C:\\Users\\me\\.orca\\secret.json' - forcedBadSddl.set(target, sddl) - - hardenSecurePath(target, { isDirectory: false, platform: 'win32' }) - await flushAsyncAcl() - - expect(warn).toHaveBeenCalledWith( - '[secure-path.windows-acl] failed to restrict path', - expect.objectContaining({ - stage: 'verify', - detail: expect.stringContaining(expected) - }) - ) - warn.mockRestore() - }) - - /** - * Evicting the cache on every failed apply is the #4901 storm wearing a different hat: the env - * store re-hardens on the *read* path at ~2/s, so on a host where hardening legitimately cannot - * work (FAT32, network path, restricted token) that is two icacls spawns and two warnings a - * second, forever. - * - * The curve itself is pinned in secure-path-hardening-retry-budget.test.ts; what matters here is - * that the read path is actually wired to it. - */ - it('collapses a failing read-path poll to a single attempt', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const targetPath = writeFailingHardenTarget() - - for (let read = 0; read < 25; read++) { - hardenExistingSecureFile(targetPath) - await flushAsyncAcl() - } - - expect(attemptsFor(targetPath)).toHaveLength(1) - warn.mockRestore() - }) - - /** - * A budget that expires rather than latching: three transient failures used to abandon a path - * for the life of the process, so one AV scan or momentary lock left every later credential - * write unprotected on a host where hardening would now succeed. - */ - it('re-probes a long-failing path once its backoff has elapsed', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const targetPath = writeFailingHardenTarget() - let clock = performance.now() - const now = vi.spyOn(performance, 'now').mockImplementation(() => clock) - - // A day of failing, well past any fixed cap, stepping by more than the 30-minute ceiling. - for (let step = 0; step < 48; step++) { - hardenExistingSecureFile(targetPath) - await flushAsyncAcl() - clock += 31 * 60_000 - } - - expect(attemptsFor(targetPath)).toHaveLength(48) - now.mockRestore() - warn.mockRestore() - }) - - it('reports recovery when a previously throttled path hardens again', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const info = vi.spyOn(console, 'info').mockImplementation(() => {}) - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const targetPath = writeFailingHardenTarget() - let clock = performance.now() - const now = vi.spyOn(performance, 'now').mockImplementation(() => clock) - - // Three failures to reach the announced degraded state, each past its own backoff. - for (const wait of [0, 61_000, 121_000]) { - clock += wait - hardenExistingSecureFile(targetPath) - await flushAsyncAcl() - } - expect(throttleReports(warn, targetPath)).toHaveLength(1) - - // The transient condition clears; the next re-probe must notice. - clock += 5 * 60_000 - vi.mocked(runProcess).mockImplementation((spec) => Promise.resolve(fakeIcacls(spec))) - hardenExistingSecureFile(targetPath) - await flushAsyncAcl() - - expect(info).toHaveBeenCalledWith( - '[secure-path.windows-acl] path hardening recovered', - expect.objectContaining({ targetPath, stage: 'recovered' }) - ) - now.mockRestore() - info.mockRestore() - warn.mockRestore() - }) - - /** - * The write path is exempt from the budget, but it was also invisible to it: a successful write - * left the failure record standing, so the read path went on backing off for up to 30 minutes - * after the host had demonstrably recovered, and no `recovered` transition came from this lane. - */ - it('clears the read-path backoff when the exempt write path succeeds', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const info = vi.spyOn(console, 'info').mockImplementation(() => {}) - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const targetPath = writeFailingHardenTarget() - let clock = performance.now() - const now = vi.spyOn(performance, 'now').mockImplementation(() => clock) - - // Three read-path failures: the path is throttled and its next re-probe is minutes away. - for (const wait of [0, 61_000, 121_000]) { - clock += wait - hardenExistingSecureFile(targetPath) - await flushAsyncAcl() - } - expect(mayAttemptHardening(targetPath)).toBe(false) - - // The host recovers and a credential is written. The synchronous apply succeeds (runProcessSync - // was never made to fail), so the read path must stop backing off. - writeSecureFile(targetPath, 'contents') - - expect(mayAttemptHardening(targetPath)).toBe(true) - expect(info).toHaveBeenCalledWith( - '[secure-path.windows-acl] path hardening recovered', - expect.objectContaining({ targetPath, stage: 'recovered' }) - ) - now.mockRestore() - info.mockRestore() - warn.mockRestore() - }) - - /** - * The SID lookup's own one-minute latch, which is the read-path budget's twin and strictly - * worse: a failed lookup makes the plan null, disabling the *synchronous write* path too — so - * the write-path exemption that recovers the budget cannot recover this. Measured against the - * wall clock, a backwards step held it shut for the whole length of the step. - */ - it('re-probes the user SID after a backwards clock step', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - let clock = performance.now() - const now = vi.spyOn(performance, 'now').mockImplementation(() => clock) - let wallClock = Date.parse('2026-01-01T00:00:00Z') - const wallNow = vi.spyOn(Date, 'now').mockImplementation(() => wallClock) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const targetPath = join(userDataPath, 'secret.json') - let sidLookupFails = true - vi.mocked(runProcessSync).mockImplementation((spec) => { - if (spec.program === 'C:\\Windows\\System32\\whoami.exe') { - return sidLookupFails ? { ...OK, code: 1 } : { ...OK, stdout: `"USER","${USER_SID}"` } - } - return fakeIcacls(spec) - }) - - // No SID, so no plan, so hardening is off entirely — not merely throttled. - expect(writeSecureFile(targetPath, 'first')).toBe(false) - - // A minute of real time passes while the wall clock steps back a year. - clock += 61_000 - wallClock -= 365 * 24 * 60 * 60_000 - sidLookupFails = false - - expect(writeSecureFile(targetPath, 'second')).toBe(true) - wallNow.mockRestore() - now.mockRestore() - warn.mockRestore() - }) - - // Scoped to one path: the parent directory is hardened too, and reports its own transition. - function throttleReports(warn: ReturnType, targetPath: string): unknown[] { - return warn.mock.calls.filter((call) => { - const entry = call[1] as { stage?: string; targetPath?: string } | undefined - return entry?.stage === 'throttled' && entry.targetPath === targetPath - }) + function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) + tempDirs.push(dir) + return dir } - function writeFailingHardenTarget(): string { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const targetPath = join(userDataPath, 'secret.json') - writeFileSync(targetPath, '{}') - vi.mocked(runProcess).mockResolvedValue({ ...OK, code: 5, stderr: 'Access is denied.' }) - return targetPath - } - - function attemptsFor(targetPath: string): { args?: readonly string[] }[] { - return getHardenAclCalls().filter((spec) => getAclTarget(spec) === targetPath) - } - - // /c makes icacls exit 0 while printing "Failed processing 1 files" — a silent no-op by another route. - it('never passes the icacls /c continue-on-error flag', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - // Cover both runners: the write path is synchronous, the directory re-harden is not. - writeSecureFile(join(userDataPath, 'secret.json'), 'contents') - hardenSecurePath('C:\\Users\\me\\.orca\\other.json', { - isDirectory: false, - platform: 'win32' - }) - await flushAsyncAcl() - - const specs = [ - ...vi.mocked(runProcess).mock.calls.map(([spec]) => spec), - ...vi.mocked(runProcessSync).mock.calls.map(([spec]) => spec) - ] - expect(specs.length).toBeGreaterThan(4) - for (const spec of specs) { - expect(spec.args).not.toContain('/c') - } - }) - - it('adds inheritable rules when hardening a Windows directory', async () => { - hardenSecurePath('C:\\Users\\me\\.orca', { isDirectory: true, platform: 'win32' }) - await flushAsyncAcl() - - const grantArgs = vi - .mocked(runProcess) - .mock.calls.map(([spec]) => spec.args as string[]) - .find((args) => args.includes('/grant:r'))! - expect(grantArgs).toContain(`*${USER_SID}:(OI)(CI)(F)`) - expect(grantArgs).toContain('*S-1-5-18:(OI)(CI)(F)') - }) - - it('keeps Windows hardening best-effort when ACL rewriting fails', async () => { - vi.mocked(runProcess).mockRejectedValue(new Error('access denied')) - - expect(() => - hardenSecurePath('C:\\Users\\me\\.orca\\secret.json', { - isDirectory: false, - platform: 'win32' - }) - ).not.toThrow() - await expect(flushAsyncAcl()).resolves.toBeUndefined() - }) - - // The old PowerShell command line never reached the grant step at all, so a failure had to be - // visible somewhere; "best effort" may not mean "undetectable". - it('logs when a Windows ACL apply fails instead of swallowing it', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.mocked(runProcess).mockResolvedValue({ ...OK, code: 5, stderr: 'Access is denied.' }) - - hardenSecurePath('C:\\Users\\me\\.orca\\secret.json', { - isDirectory: false, - platform: 'win32' - }) - await flushAsyncAcl() - - expect(warn).toHaveBeenCalledWith( - '[secure-path.windows-acl] failed to restrict path', - expect.objectContaining({ - targetPath: 'C:\\Users\\me\\.orca\\secret.json', - stage: 'reset', - detail: 'Access is denied.' - }) - ) - warn.mockRestore() - }) - - it('reports a failed synchronous ACL apply to the caller and the log', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - vi.mocked(runProcessSync).mockImplementation((spec) => { - if (spec.program === 'C:\\Windows\\System32\\whoami.exe') { - return { ...OK, stdout: '"USER","S-1-5-21-1000"' } - } - return { ...OK, code: 5, stderr: 'Access is denied.' } - }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - - writeSecureFile(join(userDataPath, 'secret.json'), 'contents') - - expect(warn).toHaveBeenCalledWith( - '[secure-path.windows-acl] failed to restrict path', - expect.objectContaining({ stage: 'reset', detail: 'Access is denied.' }) - ) - warn.mockRestore() - }) - - // Paths past MAX_PATH make icacls report "cannot find the path specified"; the extended prefix is the escape. - it('uses the extended-length prefix for paths past MAX_PATH', async () => { - const longPath = `C:\\Users\\me\\.orca\\${'d'.repeat(300)}\\secret.json` - hardenSecurePath(longPath, { isDirectory: false, platform: 'win32' }) - await flushAsyncAcl() - - for (const [spec] of vi.mocked(runProcess).mock.calls) { - expect(spec.args![0]).toBe(`\\\\?\\${longPath}`) - } - }) - - it('caches successful existing-file hardening within a process', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const targetPath = join(userDataPath, 'secret.json') - writeFileSync(targetPath, '{}') - - hardenExistingSecureFile(targetPath) - hardenExistingSecureFile(targetPath) - - // dir hardened once (path-cached), file hardened once (metadata-cached) — 2 total - expect(getHardenAclCalls()).toHaveLength(2) - expect(getHardenAclCalls().map(getAclTarget)).toEqual([userDataPath, targetPath]) - }) - - it('LRU-evicts Windows file hardening entries and safely re-hardens an evicted path', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - __resetSecureFileHardenedPathsForTests({ - maxEntries: 2, - maxKeyBytes: 4096, - maxTotalKeyBytes: 8192 - }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const paths = ['first.json', 'second.json', 'third.json'].map((name) => - join(userDataPath, name) - ) - for (const path of paths) { - writeFileSync(path, '{}') - hardenExistingSecureFile(path) - } - - hardenExistingSecureFile(paths[0]!) - - const fileTargets = getHardenAclCalls() - .map(getAclTarget) - .filter((path) => paths.includes(path)) - expect(fileTargets).toEqual([...paths, paths[0]]) - expect(__getSecureFileHardeningCacheStateForTests().paths).toMatchObject({ - entries: 2 - }) - }) - - it('LRU-evicts Windows directory hardening entries instead of retaining every path', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - __resetSecureFileHardenedPathsForTests({ - maxEntries: 2, - maxKeyBytes: 4096, - maxTotalKeyBytes: 8192 - }) - const root = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(root) - const directories = ['first', 'second', 'third'].map((name) => join(root, name)) - const files = directories.map((dir) => { - mkdirSync(dir) - const file = join(dir, 'secret.json') - writeFileSync(file, '{}') - return file - }) - for (const file of files) { - hardenExistingSecureFile(file) - } - - hardenExistingSecureFile(files[0]!) - - const directoryTargets = getHardenAclCalls() - .map(getAclTarget) - .filter((path) => directories.includes(path)) - expect(directoryTargets).toEqual([...directories, directories[0]]) - expect(__getSecureFileHardeningCacheStateForTests().directories).toMatchObject({ - entries: 2 - }) - }) - - it('re-hardens an existing file when its metadata changes after caching', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const targetPath = join(userDataPath, 'secret.json') - writeFileSync(targetPath, '{}') - - hardenExistingSecureFile(targetPath) - await waitForFileTimestampTick() - writeFileSync(targetPath, '{"changed":true}') - hardenExistingSecureFile(targetPath) - - // call 1: dir + file. call 2: dir skipped (path-cached), file re-hardened (new mtime) - expect(getHardenAclCalls()).toHaveLength(3) - expect(getHardenAclCalls().map(getAclTarget)).toEqual([userDataPath, targetPath, targetPath]) - }) - - it('keeps post-rename target hardening on every write while caching the directory', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const targetPath = join(userDataPath, 'secret.json') - - writeSecureFile(targetPath, 'first') - writeSecureFile(targetPath, 'second') - - // The DIRECTORY is hardened async + path-cached: exactly once across both writes. - const asyncTargets = getHardenAclCalls().map(getAclTarget) - expect(asyncTargets).toEqual([userDataPath]) - - // The credential FILES (tmpFile + renamed target) are hardened SYNCHRONOUSLY on each write. - // write 1: tmpFile(1) + targetFile(1) = 2; write 2: tmpFile(1) + targetFile(1) = 2; total 4. - const syncTargets = getSyncHardenAclCalls().map(getAclTarget) - expect(syncTargets).toHaveLength(4) - expect(syncTargets.filter((entry) => entry === targetPath)).toHaveLength(2) - // No directory should be hardened via the synchronous path. - expect(syncTargets.filter((entry) => entry === userDataPath)).toHaveLength(0) - }) - - // Regression test: #4901 — env-store reads at ~2×/s caused an ACL-spawn storm because the - // parent directory mtime churned (every secure write updates it), so the mtime-keyed cache - // never matched. Directories must be path-cached for the process lifetime. - it('does not re-harden the parent directory when its mtime changes between reads', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const targetPath = join(userDataPath, 'secret.json') - writeFileSync(targetPath, '{}') - - // Simulate the env-store read loop: hardenExistingSecureFile called many times while - // another part of Orca writes to the same directory (changing its mtime). - hardenExistingSecureFile(targetPath) - await waitForFileTimestampTick() - // Simulate a write to another file in the same dir (changes dir mtime) - writeFileSync(join(userDataPath, 'other.json'), '{}') - hardenExistingSecureFile(targetPath) - hardenExistingSecureFile(targetPath) - - // The parent directory must be hardened exactly ONCE despite its mtime changing - const dirCalls = getHardenAclCalls().filter((call) => getAclTarget(call) === userDataPath) - expect(dirCalls).toHaveLength(1) - }) - - it('does not re-harden an unchanged file on repeated reads', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const targetPath = join(userDataPath, 'secret.json') - writeFileSync(targetPath, '{}') - - hardenExistingSecureFile(targetPath) - hardenExistingSecureFile(targetPath) - hardenExistingSecureFile(targetPath) - - const fileCalls = getHardenAclCalls().filter((call) => getAclTarget(call) === targetPath) - expect(fileCalls).toHaveLength(1) - }) - - it('applies the read-path ACL asynchronously without blocking (async runProcess)', () => { - hardenSecurePath('C:\\Users\\me\\.orca\\secret.json', { - isDirectory: false, - platform: 'win32' - }) - - // The default (read/dir) path must launch icacls via runProcess (async), never sync. - expect(getSyncHardenAclCalls()).toHaveLength(0) - expect(getHardenAclCalls()).toHaveLength(1) - }) - - // Security regression guard (#5006 review finding): writeSecureFile must restrict the - // credential FILE's ACL SYNCHRONOUSLY before returning. On Windows writeFileSync({mode}) - // is a no-op, so an async file ACL would leave the credential briefly readable under the - // parent's inherited (broader) ACL for the duration of the spawn. - it('hardens the credential file synchronously while keeping the directory async', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const targetPath = join(userDataPath, 'secret.json') - - writeSecureFile(targetPath, 'contents') - - // Directory: async only. - expect(getHardenAclCalls().map(getAclTarget)).toEqual([userDataPath]) - // File (tmpFile + renamed target): synchronous only — no async file ACL window. - const syncTargets = getSyncHardenAclCalls().map(getAclTarget) - expect(syncTargets).toContain(targetPath) - expect(syncTargets.filter((entry) => entry === userDataPath)).toHaveLength(0) - // The final published target's ACL must have been applied via the synchronous path. - expect(getHardenAclCalls().map(getAclTarget)).not.toContain(targetPath) - }) - - // Nit #1 (review): the synchronous file path must cache as hardened ONLY on confirmed - // success, so a failed ACL apply is retried on the next write instead of being silently - // trusted. - it('retries the credential-file ACL on the next write when the sync apply fails', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const targetPath = join(userDataPath, 'secret.json') - - // First write: the synchronous icacls ACL apply throws for every icacls call. - vi.mocked(runProcessSync).mockImplementation((spec) => { - if (spec.program === 'C:\\Windows\\System32\\whoami.exe') { - return { ...OK, stdout: '"USER","S-1-5-21-1000"' } - } - throw new Error('access denied') - }) - expect(() => writeSecureFile(targetPath, 'first')).not.toThrow() - const firstWriteTargetCalls = getSyncHardenAclCalls() - .map(getAclTarget) - .filter((entry) => entry === targetPath) - expect(firstWriteTargetCalls).toHaveLength(1) - - // Second write: ACL apply now succeeds. Because the failed apply was NOT cached, the - // target file is hardened again rather than skipped. - vi.mocked(runProcessSync).mockImplementation((spec) => { - if (spec.program === 'C:\\Windows\\System32\\whoami.exe') { - return { ...OK, stdout: '"USER","S-1-5-21-1000"' } - } - return OK - }) - writeSecureFile(targetPath, 'second') - const allTargetCalls = getSyncHardenAclCalls() - .map(getAclTarget) - .filter((entry) => entry === targetPath) - expect(allTargetCalls).toHaveLength(2) - }) - - // Nit #2 (review) / hardening: the process-lifetime directory cache hardens a directory - // exactly once even when its mtime churns across many writes (the #4901 storm condition, - // exercised through the write path rather than the read path). - it('hardens the directory exactly once across many writes despite mtime churn', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - - for (let i = 0; i < 5; i++) { - // Each write changes the directory's mtime (a new file lands in it). - writeSecureFile(join(userDataPath, `secret-${i}.json`), `contents-${i}`) - } - - const dirCalls = getHardenAclCalls().filter((call) => getAclTarget(call) === userDataPath) - expect(dirCalls).toHaveLength(1) - }) - - // win32-only guard: on non-win32 platforms no icacls is ever spawned (sync or async); - // POSIX hardening uses chmodSync only. - it('never spawns icacls on non-win32 platforms', () => { + it('never touches the Windows ACL addon off win32', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const targetPath = join(userDataPath, 'secret.json') + const fake = createFakeWindowsSecurity() + setWindowsSecurityForTests(fake) + const targetPath = join(tempDir(), 'secret.json') writeSecureFile(targetPath, 'contents') hardenExistingSecureFile(targetPath) - expect(getHardenAclCalls()).toHaveLength(0) - expect(getSyncHardenAclCalls()).toHaveLength(0) + expect(fake.calls).toEqual([]) + }) + + posixModeIt('restricts the published file and its directory by mode', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const userDataPath = tempDir() + const targetPath = join(userDataPath, 'secret.json') + + expect(writeSecureFile(targetPath, 'contents')).toBe(true) + + expect(statMode(targetPath)).toBe(0o600) + expect(statMode(userDataPath)).toBe(0o700) }) posixModeIt('re-hardens a POSIX directory when its metadata changes after caching', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) + const userDataPath = tempDir() const targetPath = join(userDataPath, 'secret.json') writeFileSync(targetPath, '{}') @@ -746,8 +89,7 @@ describe('hardenSecurePath', () => { maxKeyBytes: 4096, maxTotalKeyBytes: 8192 }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) + const userDataPath = tempDir() const firstPath = join(userDataPath, 'first.json') const secondPath = join(userDataPath, 'second.json') writeFileSync(firstPath, '{}') @@ -768,57 +110,6 @@ describe('hardenSecurePath', () => { }) }) -/** - * Every harden opens with a `/save` verify; one that has work to do then runs `/reset`, `/grant:r` - * and a closing `/save`. Counting only the *opening* verify keeps "one harden = one entry" - * regardless of which of the two shapes it took. - */ -function hardenInitiations(specs: FakeSpec[]): { args?: readonly string[] }[] { - const initiations: { args?: readonly string[] }[] = [] - const awaitingClosingVerify = new Set() - for (const spec of specs) { - if (!spec.program.endsWith('icacls.exe')) { - continue - } - const path = spec.args?.[0] ?? '' - if (spec.args?.includes('/grant:r')) { - awaitingClosingVerify.add(path) - } else if (spec.args?.includes('/save')) { - if (awaitingClosingVerify.has(path)) { - awaitingClosingVerify.delete(path) - } else { - initiations.push(spec) - } - } - } - return initiations -} - -// Async icacls calls (directory hardening + read-path file re-harden). -function getHardenAclCalls(): { args?: readonly string[] }[] { - return hardenInitiations(vi.mocked(runProcess).mock.calls.map(([spec]) => spec)) -} - -// Synchronous icacls calls (credential-file ACL on the write path). -function getSyncHardenAclCalls(): { args?: readonly string[] }[] { - return hardenInitiations(vi.mocked(runProcessSync).mock.calls.map(([spec]) => spec)) -} - -function getAclTarget(spec: { args?: readonly string[] }): string { - return spec.args![0]! -} - -// The async harden awaits three icacls passes, so let the chain settle before asserting on it. -async function flushAsyncAcl(): Promise { - for (let i = 0; i < 4; i++) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } -} - -async function waitForFileTimestampTick(): Promise { - await new Promise((resolve) => setTimeout(resolve, 20)) -} - function statMode(path: string): number { return statSync(path).mode & 0o777 } diff --git a/src/shared/secure-file.ts b/src/shared/secure-file.ts index 172436dcc5b..8e6a7c1b130 100644 --- a/src/shared/secure-file.ts +++ b/src/shared/secure-file.ts @@ -8,7 +8,6 @@ import { openSync, renameSync, rmSync, - statSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' @@ -23,43 +22,39 @@ import { recordHardeningOutcome } from './secure-path-hardening-retry-budget' import { - bestEffortRestrictWindowsPath, - resetSecureFileWindowsUserSidForTests, - restrictWindowsPathSync + restrictWindowsDirectory, + restrictWindowsPathSync, + writeRestrictedWindowsFile } from './secure-path-windows-acl' - -type HardenedPathCacheEntry = { - isDirectory: boolean - dev: number - ino: number - size: number - mode: number - ctimeMs: number - mtimeMs: number - birthtimeMs: number -} +import { + getHardenedPathCacheEntry, + hardenedPathCacheEntriesMatch, + type HardenedPathCacheEntry +} from './secure-path-metadata-snapshot' const UNSUPPORTED_DIRECTORY_FSYNC_CODES = new Set(['EINVAL', 'ENOTSUP', 'EOPNOTSUPP']) -// Why: hardening spawns icacls synchronously (once when the DACL already verifies, four times when it must be rewritten), so cache idempotent re-hardens per process. +// Why: read paths poll (~2/s, #4901); a metadata-keyed cache keeps an unchanged file from being re-verified and re-logged. let hardenedPathsThisProcess = new SecurePathHardeningCache( DEFAULT_HARDENING_CACHE_BOUNDS ) -// Why: child writes constantly bump a dir's mtime, so cache dirs by path (not metadata) to avoid an icacls spawn every read (#4901). +// Why: child writes constantly bump a dir's mtime, so cache dirs by path (not metadata); a Windows rewrite walks the whole tree. // Limitation: a dir deleted+recreated in-process won't re-harden; fine since we never delete our secure dirs at runtime. let hardenedDirectoryPathsThisProcess = new SecurePathHardeningCache( DEFAULT_HARDENING_CACHE_BOUNDS ) -function hardenSecureDirectoryOnce(dirPath: string): void { - // Why: dir hardening stays async — re-applying it stormed the main thread (#4901); files inside are hardened synchronously anyway. - if (hardenedDirectoryPathsThisProcess.get(dirPath)) { +function hardenWindowsDirectoryOnce(dirPath: string): void { + // Why the floor: read paths poll at ~2/s (#4901); retrying every failure there would storm. + if (hardenedDirectoryPathsThisProcess.get(dirPath) || !mayAttemptHardening(dirPath)) { return } // Cache before the ACL lands so concurrent writes don't restorm; a failure drops it, under the retry budget. hardenedDirectoryPathsThisProcess.set(dirPath, true) - applySecurePathRestriction(dirPath, true, process.platform, false, (restricted) => { + // Why async: a directory rewrite propagates to every descendant, ~2 s on a 29k-entry userData. + void restrictWindowsDirectory(dirPath).then((restricted) => { + recordHardeningOutcome(dirPath, restricted) if (!restricted) { hardenedDirectoryPathsThisProcess.delete(dirPath) } @@ -68,7 +63,7 @@ function hardenSecureDirectoryOnce(dirPath: string): void { function hardenSecurePathOnce(targetPath: string, isDirectory: boolean): boolean { if (isDirectory && process.platform === 'win32') { - hardenSecureDirectoryOnce(targetPath) + hardenWindowsDirectoryOnce(targetPath) return true } @@ -80,23 +75,27 @@ function hardenSecurePathOnce(targetPath: string, isDirectory: boolean): boolean if (currentEntry && cachedEntry && hardenedPathCacheEntriesMatch(currentEntry, cachedEntry)) { return true } - // Why: async re-harden is safe here — read path hardens each file at most once/process; new files harden synchronously on the write path. - const outcome = applySecurePathRestriction( - targetPath, - isDirectory, - process.platform, - false, - (restricted) => { - if (!restricted) { - hardenedPathsThisProcess.delete(targetPath) - } - } - ) - if (outcome !== 'failed') { - rememberHardenedPath(targetPath, isDirectory) - return true + if (process.platform === 'win32') { + return hardenExistingWindowsFile(targetPath) } - return false + chmodSync(targetPath, isDirectory ? 0o700 : 0o600) + rememberHardenedPath(targetPath, isDirectory) + return true +} + +/** A file has no descendants, so verifying and fixing it in-process costs microseconds. */ +function hardenExistingWindowsFile(targetPath: string): boolean { + // Why the floor: this is the read path, polled at ~2/s; a host that can never restrict + // (FAT32, some shares) would otherwise log on every poll. + if (!mayAttemptHardening(targetPath)) { + return false + } + const restricted = restrictWindowsPathSync(targetPath, false) + recordHardeningOutcome(targetPath, restricted) + if (restricted) { + rememberHardenedPath(targetPath, false) + } + return restricted } /** Returns false when the file was written but its permissions could not be restricted. */ @@ -112,15 +111,17 @@ export function writeDurableSecureJsonFile(targetPath: string, value: unknown): /** * Writes `contents` and restricts the result to the current user. * - * Returns whether the restriction actually took. Hardening stays best-effort — it fails - * legitimately on FAT32, network paths and restricted tokens, and must not break a write — but - * the outcome is now reported rather than assumed, so a caller storing a credential can react. + * Returns whether the restriction actually took, so a caller storing a credential can react. On + * Windows with the ACL addon loaded the file is created already restricted, and a failed create + * throws like any write failure (fail closed). Only when the addon cannot load is the file written + * plainly, best-effort, and reported as unrestricted. A restriction the filesystem drops (FAT32, + * some shares) also reports false. * * An optional publication predicate runs after staged hardening; false discards the staged file. * * The return value covers the *file* only. The parent directory is hardened fire-and-forget — on - * Windows that lane is async and answers `pending` regardless — so a `true` here says nothing - * about the directory's ACL. + * Windows on the thread pool, settling after this returns — so a `true` here says nothing about + * the directory's ACL. */ export function writeSecureFile( targetPath: string, @@ -136,37 +137,44 @@ export function writeSecureFile( const tmpFile = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp` try { - writeFileSync(tmpFile, contents, { - encoding: 'utf-8', - mode: 0o600 - }) + // Why born restricted on Windows: writeFileSync's mode is a no-op there, and a file created + // plainly would hold the credential under the directory's inherited DACL until a later fix. + const restricted = + process.platform === 'win32' + ? writeRestrictedWindowsFile(tmpFile, contents) + : writeModeRestrictedFile(tmpFile, contents) if (options.durable) { fsyncFileSync(tmpFile) } - // Why: writeFileSync mode is a no-op on Windows, so restrict the credential's ACL synchronously before the rename publishes it under inherited ACLs. - const stagedOutcome = applySecurePathRestriction(tmpFile, false, process.platform, true) // Recheck after hardening; the check and rename are separate, non-atomic operations. if (options.shouldPublish && !options.shouldPublish()) { rmSync(tmpFile, { force: true }) return false } + // The rename publishes the staged inode, so its DACL or mode is already the published file's. renameSync(tmpFile, targetPath) - // Why: these hold auth credentials, so the published path must stay current-user only; cache only on confirmed success so failures retry. - // The staged file's protected DACL survives the rename, so this pass usually just verifies it. - const publishedOutcome = applySecurePathRestriction(targetPath, false, process.platform, true) - if (publishedOutcome === 'applied') { + // Cache only on confirmed success so failures retry; a success also clears the read path's backoff. + if (restricted) { rememberHardenedPath(targetPath, false) + recordHardeningOutcome(targetPath, true) } if (options.durable) { bestEffortFsyncDirectorySync(dir) } - return stagedOutcome === 'applied' && publishedOutcome === 'applied' + return restricted } catch (error) { rmSync(tmpFile, { force: true }) throw error } } +function writeModeRestrictedFile(tmpFile: string, contents: string): boolean { + writeFileSync(tmpFile, contents, { encoding: 'utf-8', mode: 0o600 }) + // The umask can strip bits from `mode` but never add them; chmod sets exactly 0o600. + chmodSync(tmpFile, 0o600) + return true +} + function fsyncPathSync(path: string, flags: 'r' | 'r+'): void { const descriptor = openSync(path, flags) try { @@ -236,67 +244,6 @@ export function hardenExistingSecureFile(targetPath: string): void { } } -/** Applies the platform-appropriate permission restriction to a path once, bypassing the cache. */ -export function hardenSecurePath( - targetPath: string, - options: { - isDirectory: boolean - platform: NodeJS.Platform - sync?: boolean - } -): void { - applySecurePathRestriction( - targetPath, - options.isDirectory, - options.platform, - options.sync ?? false - ) -} - -/** - * `pending` is the honest answer for the async Windows branch: it has not happened yet, and - * reporting it as `applied` is what let a dead ACL look like a working one. The real outcome - * arrives through `onAsyncSettled`. - */ -type HardeningOutcome = 'applied' | 'pending' | 'failed' - -function applySecurePathRestriction( - targetPath: string, - isDirectory: boolean, - platform: NodeJS.Platform, - sync: boolean, - onAsyncSettled?: (restricted: boolean) => void -): HardeningOutcome { - if (platform === 'win32') { - if (sync) { - // Why no retry floor here: the write path is user-driven, not polled, and a failed apply - // must still be retried on the next write of the same credential. - // Why: apply the ACL synchronously so the credential file isn't briefly readable under inherited ACLs (writeFileSync mode is a no-op on Windows). - const restricted = restrictWindowsPathSync(targetPath, isDirectory) - if (restricted) { - // Success only: this is how a recovered host clears the read path's backoff (and reports - // `recovered`). Recording a failure here would put the exempt lane back under the budget. - recordHardeningOutcome(targetPath, true) - } - return restricted ? 'applied' : 'failed' - } - // Why the floor: this is the read path, polled at ~2/s (#4901). Retrying every failure there - // is the same storm the cache exists to prevent. - if (!mayAttemptHardening(targetPath)) { - onAsyncSettled?.(false) - return 'failed' - } - // Why: dir/read-path re-harden runs async to avoid blocking the main thread (#4901). - bestEffortRestrictWindowsPath(targetPath, isDirectory, (restricted) => { - recordHardeningOutcome(targetPath, restricted) - onAsyncSettled?.(restricted) - }) - return 'pending' - } - chmodSync(targetPath, isDirectory ? 0o700 : 0o600) - return 'applied' -} - /** Caches the current metadata snapshot for a just-hardened path, or clears it if the path is gone. */ function rememberHardenedPath(targetPath: string, isDirectory: boolean): void { const entry = getHardenedPathCacheEntry(targetPath, isDirectory) @@ -307,55 +254,6 @@ function rememberHardenedPath(targetPath: string, isDirectory: boolean): void { } } -/** - * Snapshots a path's identity, mode, and timestamps so later drift is detectable. - * Mode is tracked directly so a chmod is caught even where coarse ctime granularity hides it. - */ -function getHardenedPathCacheEntry( - targetPath: string, - isDirectory: boolean -): HardenedPathCacheEntry | null { - try { - const stats = statSync(targetPath) - if (stats.isDirectory() !== isDirectory) { - return null - } - return { - isDirectory, - dev: stats.dev, - ino: stats.ino, - size: stats.size, - mode: stats.mode & 0o777, - ctimeMs: stats.ctimeMs, - mtimeMs: stats.mtimeMs, - birthtimeMs: stats.birthtimeMs - } - } catch { - return null - } -} - -/** True when two snapshots describe the same unchanged path (identity, mode, timestamps). */ -function hardenedPathCacheEntriesMatch( - a: HardenedPathCacheEntry, - b: HardenedPathCacheEntry -): boolean { - return ( - a.isDirectory === b.isDirectory && - a.dev === b.dev && - a.ino === b.ino && - a.size === b.size && - a.mode === b.mode && - a.ctimeMs === b.ctimeMs && - a.mtimeMs === b.mtimeMs && - a.birthtimeMs === b.birthtimeMs - ) -} - -export function __resetSecureFileWindowsUserSidForTests(): void { - resetSecureFileWindowsUserSidForTests() -} - export function __resetSecureFileHardenedPathsForTests( bounds: SecurePathHardeningCacheBounds = DEFAULT_HARDENING_CACHE_BOUNDS ): void { diff --git a/src/shared/secure-path-hardening-report.ts b/src/shared/secure-path-hardening-report.ts index 45408da0317..d13b18bd997 100644 --- a/src/shared/secure-path-hardening-report.ts +++ b/src/shared/secure-path-hardening-report.ts @@ -6,9 +6,9 @@ export type SecurePathHardeningReport = { targetPath: string /** * `throttled` and `recovered` mark entering and leaving the rate-limited degraded state; - * `settle` is the async lane's own callback failing, which is a caller bug rather than a host one. + * `addon` is the native ACL module failing to load, reported once per process. */ - stage: 'sid-lookup' | 'reset' | 'grant' | 'verify' | 'settle' | 'throttled' | 'recovered' + stage: 'addon' | 'apply' | 'verify' | 'throttled' | 'recovered' detail: string } @@ -39,5 +39,9 @@ export function reportSecurePathHardening( stage: SecurePathHardeningReport['stage'], detail: string ): void { - reportEntry({ targetPath, stage, detail: detail.trim().slice(0, 500) }) + try { + reportEntry({ targetPath, stage, detail: detail.trim().slice(0, 500) }) + } catch { + // A throwing caller-installed reporter must not break hardening; this diagnostic is lost. + } } diff --git a/src/shared/secure-path-hardening-retry-budget.ts b/src/shared/secure-path-hardening-retry-budget.ts index 9dfd93f02f3..43c5359d7a2 100644 --- a/src/shared/secure-path-hardening-retry-budget.ts +++ b/src/shared/secure-path-hardening-retry-budget.ts @@ -11,7 +11,7 @@ type HardeningFailureRecord = { at: number; attempts: number } * How often a path whose hardening keeps failing may be retried. * * Why throttle at all: the env store re-hardens on the *read* path at ~2/s (#4901), so retrying - * every failure is an icacls-and-log storm on hosts where hardening cannot work — FAT32/exFAT have + * every failure is a log storm on hosts where hardening cannot work — FAT32/exFAT have * no ACLs, and network paths, redirected profiles and restricted tokens refuse. * * Why exponential and not a cap: a cap that never expires latches a *transient* failure — one AV diff --git a/src/shared/secure-path-metadata-snapshot.ts b/src/shared/secure-path-metadata-snapshot.ts new file mode 100644 index 00000000000..957ea794b70 --- /dev/null +++ b/src/shared/secure-path-metadata-snapshot.ts @@ -0,0 +1,58 @@ +import { statSync } from 'node:fs' + +/** What a secure-path cache entry remembers, so a later change to the path is detectable. */ +export type HardenedPathCacheEntry = { + isDirectory: boolean + dev: number + ino: number + size: number + mode: number + ctimeMs: number + mtimeMs: number + birthtimeMs: number +} + +/** + * Snapshots a path's identity, mode, and timestamps so later drift is detectable. + * Mode is tracked directly so a chmod is caught even where coarse ctime granularity hides it. + */ +export function getHardenedPathCacheEntry( + targetPath: string, + isDirectory: boolean +): HardenedPathCacheEntry | null { + try { + const stats = statSync(targetPath) + if (stats.isDirectory() !== isDirectory) { + return null + } + return { + isDirectory, + dev: stats.dev, + ino: stats.ino, + size: stats.size, + mode: stats.mode & 0o777, + ctimeMs: stats.ctimeMs, + mtimeMs: stats.mtimeMs, + birthtimeMs: stats.birthtimeMs + } + } catch { + return null + } +} + +/** True when two snapshots describe the same unchanged path (identity, mode, timestamps). */ +export function hardenedPathCacheEntriesMatch( + a: HardenedPathCacheEntry, + b: HardenedPathCacheEntry +): boolean { + return ( + a.isDirectory === b.isDirectory && + a.dev === b.dev && + a.ino === b.ino && + a.size === b.size && + a.mode === b.mode && + a.ctimeMs === b.ctimeMs && + a.mtimeMs === b.mtimeMs && + a.birthtimeMs === b.birthtimeMs + ) +} diff --git a/src/shared/secure-path-windows-acl.ts b/src/shared/secure-path-windows-acl.ts index d5f49adf0c7..4d595ff0bdf 100644 --- a/src/shared/secure-path-windows-acl.ts +++ b/src/shared/secure-path-windows-acl.ts @@ -1,271 +1,85 @@ -import { randomBytes } from 'node:crypto' -import { readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, win32 as pathWin32 } from 'node:path' -import { runProcess, runProcessSync } from '@orca/process-host' -import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' -import { - reportSecurePathHardening, - type SecurePathHardeningReport -} from './secure-path-hardening-report' -import { localDomainSidOf, parseSddlDacl } from './windows-security-descriptor' +import { writeFileSync } from 'node:fs' +import { reportSecurePathHardening as report } from './secure-path-hardening-report' +import { loadWindowsSecurity, type WindowsSecurity } from './windows-security-addon' +import { findSecureDaclViolation, hardenedDaclSddl } from './windows-secure-dacl' -const ACL_TIMEOUT_MS = 5000 +/** + * Restricts a directory on the thread pool: a rewrite walks every descendant, seconds on a large + * userData. Never rejects; failures are reported and resolve false so the caller can retry. + */ +export function restrictWindowsDirectory(dirPath: string): Promise { + const security = securityFor(dirPath) + return security ? restrictAsync(security, dirPath, true) : Promise.resolve(false) +} -/** SYSTEM and the local Administrators group: they can take ownership regardless, so denying them buys nothing. */ -const LOCAL_SYSTEM_SID = 'S-1-5-18' -const BUILTIN_ADMINISTRATORS_SID = 'S-1-5-32-544' - -const WINDOWS_SID_PATTERN = /^S-1-\d+(?:-\d+)+$/ - -type AclPlan = { - program: string - /** The path as icacls must receive it, already extended-length prefixed when needed. */ - icaclsPath: string +async function restrictAsync( + security: WindowsSecurity, + targetPath: string, isDirectory: boolean - allowedSids: string[] - /** Resolves the machine-relative aliases `/save` emits; null when the user SID is not one. */ - localDomainSid: string | null - resetArgs: string[] - grantArgs: string[] -} - -function buildAclPlan(targetPath: string, currentUserSid: string, isDirectory: boolean): AclPlan { - const icaclsPath = toIcaclsPath(targetPath) - // Directories propagate to children (artifact-intent files rely on inheritance); files take no flags. - const rights = isDirectory ? '(OI)(CI)(F)' : '(F)' - const allowedSids = [...new Set([currentUserSid, LOCAL_SYSTEM_SID, BUILTIN_ADMINISTRATORS_SID])] - return { - program: windowsSystem32Binary('icacls.exe'), - icaclsPath, - isDirectory, - allowedSids, - localDomainSid: localDomainSidOf(currentUserSid), - // `/reset` purges explicit ACEs, which `/inheritance:r` leaves in place — a planted - // `Everyone:(R)` survives the grant pass otherwise. The two cannot be combined in one call. - resetArgs: [icaclsPath, '/reset', '/q'], - // Never add /c: it makes icacls exit 0 on "Failed processing 1 files", a silent no-op by another route. - grantArgs: [ - icaclsPath, - '/inheritance:r', - ...allowedSids.flatMap((sid) => ['/grant:r', `*${sid}:${rights}`]), - '/q' - ] - } -} - -function verifyArgs(plan: AclPlan, savePath: string): string[] { - return [plan.icaclsPath, '/save', savePath, '/q'] -} - -function sddlSavePath(): string { - return join(tmpdir(), `orca-acl-${process.pid}-${randomBytes(6).toString('hex')}.sddl`) -} - -/** - * Judges a `/save` result. Returns a failure reason, or null when the DACL on disk is exactly the - * intended one — protected, granting full control to the allowed SIDs and to nobody else. - */ -function evaluateSavedAcl( - plan: AclPlan, - result: { code: number | null; stderr: string }, - savePath: string -): string | null { - if (result.code !== 0) { - return result.stderr.trim() || `icacls exited ${result.code}` - } - let sddl: string - try { - // icacls writes the descriptor as UTF-16LE, which sidesteps the OEM codepage its stdout uses. - sddl = readFileSync(savePath, 'utf16le') - } catch { - return 'icacls saved no security descriptor' - } - return validateHardenedDacl(sddl, plan) -} - -function validateHardenedDacl(sddl: string, plan: AclPlan): string | null { - const dacl = parseSddlDacl(sddl, plan.localDomainSid ?? undefined) - if (!dacl) { - return 'no DACL in the saved security descriptor' - } - if (!dacl.isProtected) { - return 'DACL is not protected; the parent still propagates into it' - } - // Exactly these, in any order: a directory's rules must be inheritable and nothing else. - const expectedFlags = plan.isDirectory ? ['OI', 'CI'] : [] - const observed = new Set() - for (const ace of dacl.aces) { - if (ace.type !== 'A') { - return `unexpected ${ace.type} rule for ${ace.sid}` - } - if (ace.flags.includes('ID')) { - return `inherited rule survived for ${ace.sid}` - } - if (ace.rights !== 'FA') { - return `rule for ${ace.sid} grants ${ace.rights || 'nothing'}, not full control` - } - // The whole set, not just OI. (OI) without (CI) leaves subdirectories unprotected, and - // adding (IO) makes every rule inherit-only, so the directory object itself grants nobody - // anything and Orca cannot even write into it. Both used to be repaired blindly on every - // pass; since hardening short-circuits on a DACL that verifies, whatever this accepts stays. - if ( - ace.flags.length !== expectedFlags.length || - !expectedFlags.every((flag) => ace.flags.includes(flag)) - ) { - return `wrong inheritance flags (${ace.flags.join('') || 'none'}) for ${ace.sid}` - } - observed.add(ace.sid) - } - // Identity, not just shape: a count check alone accepts a granted SID swapped for another. - // Unexpected principals are reported before missing ones — "Everyone has full control" is the - // headline, and a substitution always produces both. - for (const sid of observed) { - if (!plan.allowedSids.includes(sid)) { - return `unexpected rule for ${sid}` - } - } - for (const sid of plan.allowedSids) { - if (!observed.has(sid)) { - return `missing rule for ${sid}` - } - } - return null -} - -/** - * icacls resolves through the MAX_PATH-limited API and fails with "cannot find the path - * specified" past 259 characters; the extended prefix is the documented escape. - */ -function toIcaclsPath(targetPath: string): string { - if (targetPath.length < 260 || targetPath.startsWith('\\\\?\\')) { - return targetPath - } - const normalized = pathWin32.normalize(targetPath) - if (/^[A-Za-z]:\\/.test(normalized)) { - return `\\\\?\\${normalized}` - } - if (normalized.startsWith('\\\\')) { - return `\\\\?\\UNC\\${normalized.slice(2)}` - } - return targetPath -} - -function report( - targetPath: string, - stage: SecurePathHardeningReport['stage'], - detail: string -): void { - reportSecurePathHardening(targetPath, stage, detail) -} - -/** - * Applies the ACL without blocking. `onSettled` reports the real outcome, which the return value - * cannot: the caller's cache must not keep claiming a path is hardened when the apply failed. - */ -export function bestEffortRestrictWindowsPath( - targetPath: string, - isDirectory: boolean, - onSettled?: (restricted: boolean) => void -): void { - const plan = planFor(targetPath, isDirectory) - if (!plan) { - onSettled?.(false) - return - } - // Why async: hardening runs on the read path, and blocking it on a spawn stormed the main thread (#4901). - // Why both arms and a terminal catch: a bare `void p.then(fn)` makes a rejected `restrictAsync` - // *and* a throw from `onSettled` itself an unhandled rejection, which Node's default turns into - // a main-process crash — the exact opposite of what the reporter hook exists for. `false` is the - // right value on the error arm: it drops the path from the caller's cache and leaves it retryable. - void restrictAsync(targetPath, plan) - .then(onSettled, () => onSettled?.(false)) - .catch((error: unknown) => reportSettlementThrow(targetPath, error)) -} - -/** - * The last frame before an unhandled rejection, so it must not throw either — and the reporter it - * calls is a caller-installed hook, which is the one thing here that plausibly does. - */ -function reportSettlementThrow(targetPath: string, error: unknown): void { - try { - report(targetPath, 'settle', `hardening settlement callback threw: ${String(error)}`) - } catch { - // Nothing left to report through; losing one diagnostic beats crashing the main process. - } -} - -async function restrictAsync(targetPath: string, plan: AclPlan): Promise { - // Verify first: a path that already reads back correct needs no write at all. Re-running - // `/reset` on a correct DACL would briefly restore the inherited (broader) one for no gain. - if ((await verifyAsync(plan)) === null) { +): Promise { + // Verify first: a directory rewrite walks every descendant (seconds on a large userData), so + // a path that already reads back correct must never pay it again. + if (violationOf(security, targetPath, isDirectory) === null) { return true } - for (const [stage, args] of [ - ['reset', plan.resetArgs], - ['grant', plan.grantArgs] - ] as const) { - try { - const result = await runProcess({ program: plan.program, args, timeoutMs: ACL_TIMEOUT_MS }) - if (result.code !== 0) { - report(targetPath, stage, result.stderr || `icacls exited ${result.code}`) - return false - } - } catch (error) { - report(targetPath, stage, String(error)) - return false - } - } - const invalid = await verifyAsync(plan) - if (invalid) { - report(targetPath, 'verify', invalid) + try { + await security.addon.setDaclSddlAsync( + targetPath, + hardenedDaclSddl(security.userSid, isDirectory) + ) + } catch (error) { + report(targetPath, 'apply', String(error)) return false } - return true -} - -async function verifyAsync(plan: AclPlan): Promise { - const savePath = sddlSavePath() - try { - const result = await runProcess({ - program: plan.program, - args: verifyArgs(plan, savePath), - timeoutMs: ACL_TIMEOUT_MS - }) - return evaluateSavedAcl(plan, result, savePath) - } catch (error) { - return String(error) - } finally { - discard(savePath) - } + return verifyAfterApply(security, targetPath, isDirectory) } +/** Verifies and, only when that fails, rewrites the DACL on the calling thread. */ export function restrictWindowsPathSync(targetPath: string, isDirectory: boolean): boolean { - const plan = planFor(targetPath, isDirectory) - if (!plan) { + const security = securityFor(targetPath) + if (!security) { return false } - // Why sync: the file must not be published until its ACL is actually restricted (read path stays async, #4901). - if (verifySync(plan) === null) { + if (violationOf(security, targetPath, isDirectory) === null) { return true } - for (const [stage, args] of [ - ['reset', plan.resetArgs], - ['grant', plan.grantArgs] - ] as const) { - try { - const result = runProcessSync({ program: plan.program, args, timeoutMs: ACL_TIMEOUT_MS }) - if (result.code !== 0) { - report(targetPath, stage, result.stderr || `icacls exited ${result.code}`) - return false - } - } catch (error) { - // Why not fatal: a failed ACL apply must not crash the write; false leaves the path uncached to retry later. - report(targetPath, stage, String(error)) - return false - } + try { + security.addon.setDaclSddl(targetPath, hardenedDaclSddl(security.userSid, isDirectory)) + } catch (error) { + // Why not fatal: a failed apply must not crash the caller; false leaves the path retryable. + report(targetPath, 'apply', String(error)) + return false } - const invalid = verifySync(plan) + return verifyAfterApply(security, targetPath, isDirectory) +} + +/** + * Writes a NEW file that is restricted from the moment it exists, so its contents are never + * readable under the directory's (possibly broader) inherited DACL. Returns whether the + * restriction holds. Without the addon the file is written plainly and this is false; with it, a + * failed create throws like any other write failure. + */ +export function writeRestrictedWindowsFile(targetPath: string, contents: string): boolean { + const security = securityFor(targetPath) + if (!security) { + writeFileSync(targetPath, contents, { encoding: 'utf-8', mode: 0o600 }) + return false + } + security.addon.createFileWithDacl( + targetPath, + hardenedDaclSddl(security.userSid, false), + Buffer.from(contents, 'utf-8') + ) + // Why read it back: FAT32 and some shares accept the descriptor and silently drop it. + return verifyAfterApply(security, targetPath, false) +} + +function verifyAfterApply( + security: WindowsSecurity, + targetPath: string, + isDirectory: boolean +): boolean { + const invalid = violationOf(security, targetPath, isDirectory) if (invalid) { report(targetPath, 'verify', invalid) return false @@ -273,86 +87,34 @@ export function restrictWindowsPathSync(targetPath: string, isDirectory: boolean return true } -function verifySync(plan: AclPlan): string | null { - const savePath = sddlSavePath() +/** Why the DACL on disk is not acceptable, or null when it is. */ +function violationOf( + security: WindowsSecurity, + targetPath: string, + isDirectory: boolean +): string | null { try { - const result = runProcessSync({ - program: plan.program, - args: verifyArgs(plan, savePath), - timeoutMs: ACL_TIMEOUT_MS - }) - return evaluateSavedAcl(plan, result, savePath) + return findSecureDaclViolation( + security.addon.getDaclAces(targetPath), + security.userSid, + isDirectory + ) } catch (error) { return String(error) - } finally { - discard(savePath) } } -function discard(savePath: string): void { - try { - rmSync(savePath, { force: true }) - } catch { - // The descriptor holds no secrets; a leftover temp file is not worth reporting. - } -} +let reportedLoadFailure = false -function planFor(targetPath: string, isDirectory: boolean): AclPlan | null { - const currentUserSid = getCurrentWindowsUserSid() - if (!currentUserSid) { - report(targetPath, 'sid-lookup', 'could not resolve the current user SID') - return null - } - return buildAclPlan(targetPath, currentUserSid, isDirectory) -} - -let cachedWindowsUserSid: string | null = null -let sidLookupFailedAt: number | null = null -const SID_LOOKUP_RETRY_MS = 60_000 - -/** - * Why monotonic and not `Date.now`: a backwards wall-clock step held this window open until the - * clock caught up, and this latch is worse than the read-path budget's — a failed lookup makes - * `planFor` return null, which disables the synchronous *write* path too, so the write-path - * exemption that recovers from that one cannot recover from this. - */ -const monotonicNowMs = (): number => performance.now() - -/** - * Only a well-formed SID is cached for the process lifetime. A failure is cached for a minute: - * caching it forever let one transient `whoami` hiccup disable hardening until restart. - */ -function getCurrentWindowsUserSid(): string | null { - if (cachedWindowsUserSid) { - return cachedWindowsUserSid - } - if (sidLookupFailedAt !== null && monotonicNowMs() - sidLookupFailedAt < SID_LOOKUP_RETRY_MS) { - return null - } - try { - const result = runProcessSync({ - program: windowsSystem32Binary('whoami.exe'), - args: ['/user', '/fo', 'csv', '/nh'], - timeoutMs: ACL_TIMEOUT_MS - }) - const candidate = result.code === 0 ? parseCsvLine(result.stdout.trim())[1] : undefined - if (candidate && WINDOWS_SID_PATTERN.test(candidate)) { - cachedWindowsUserSid = candidate - sidLookupFailedAt = null - return candidate +function securityFor(targetPath: string): WindowsSecurity | null { + const loaded = loadWindowsSecurity() + if ('error' in loaded) { + // Once per process: a missing addon is a packaging fault, and every secure write would repeat it. + if (!reportedLoadFailure) { + reportedLoadFailure = true + report(targetPath, 'addon', loaded.error) } - } catch { - // Fall through to the failure record below. + return null } - sidLookupFailedAt = monotonicNowMs() - return null -} - -function parseCsvLine(line: string): string[] { - return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, '')) -} - -export function resetSecureFileWindowsUserSidForTests(): void { - cachedWindowsUserSid = null - sidLookupFailedAt = null + return loaded } diff --git a/src/shared/secure-path-windows-acl.win32.test.ts b/src/shared/secure-path-windows-acl.win32.test.ts index eea6bb914b2..ff9873a6a7f 100644 --- a/src/shared/secure-path-windows-acl.win32.test.ts +++ b/src/shared/secure-path-windows-acl.win32.test.ts @@ -1,199 +1,172 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync +} from 'node:fs' +import { randomBytes } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { runProcessSync } from '@orca/process-host' -import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' +import { Worker } from 'node:worker_threads' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { __resetSecureFileHardenedPathsForTests, writeSecureFile } from './secure-file' import { setSecurePathHardeningReporter, type SecurePathHardeningReport } from './secure-path-hardening-report' +import { restrictWindowsPathSync } from './secure-path-windows-acl' import { - bestEffortRestrictWindowsPath, - resetSecureFileWindowsUserSidForTests, - restrictWindowsPathSync -} from './secure-path-windows-acl' + loadWindowsSecurity, + resetWindowsSecurityLoadForTests, + setWindowsSecurityForTests, + type WindowsSecurity +} from './windows-security-addon' +import { + CONTAINER_INHERIT_ACE, + findSecureDaclViolation, + OBJECT_INHERIT_ACE +} from './windows-secure-dacl' import { removeTreeSync } from './windows-transient-lock-removal' /** - * The half of the proof a mocked argv test cannot give. - * - * The shipped bug was not a wrong argv — it was an argv the *callee* never - * received: `powershell.exe -Command