mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(windows): restrict secure files in-process instead of spawning icacls (#28135)
* fix(windows): restrict secure files in-process instead of spawning icacls Every secure write on Windows spawned icacls.exe five times on the calling thread (the Electron main thread for most stores): verify, /reset, /inheritance:r /grant:r, verify, verify. Process creation under endpoint security costs ~0.6 s each, so each write froze the UI for seconds (#26027). The /reset + /inheritance:r pair is also what behavioural EDR scores as ransomware tradecraft (#25583). Add native/windows-security (@orca/windows-security): a small N-API addon over advapi32 that reads and writes DACLs in-process. It exposes the token user SID, a DACL read as SDDL, a DACL write (sync and on the thread pool), a create-with-DACL file write, and an additive grant. Policy and verification stay in TypeScript. - writeSecureFile creates each temp file already carrying the protected user/SYSTEM/Administrators DACL (CreateFileW + SECURITY_ATTRIBUTES), reads it back once, and renames it. No ACL is modified on the write path and no process starts. Before: 5-7 spawns, 94-138 ms per write. After: 0 spawns, ~0.5 ms. - A secure directory is verified once per process. It is rewritten only when verification fails, on the thread pool, because the write walks every descendant (~2 s on a 29k-entry userData). - The verifier follows OpenSSH's rule: only the user, SYSTEM and Administrators may be granted anything, and the path must be protected (or, for a file, inherit from a secure parent). DACLs written by the icacls-era hardening pass unchanged, so there is no migration. - If the addon cannot load, the write still happens and reports unrestricted, logged once. There is no icacls or PowerShell fallback. - grantDirAcl, the startup userData grant, and the install-dir probe and package repair use the same addon, so whoami.exe and icacls.exe are no longer spawned anywhere at runtime. - orcad's Windows slots ship the addon beside their bundles (built per arch, PE-machine checked). Its data-root privacy check fails closed, so without the addon a Windows orcad would not start. Fixes #26027 Fixes #25583 * refactor(secure-file): harden existing Windows files through the sync lane The read path called restrictWindowsPathSync directly and recorded success itself; the existing sync lane already does both. One call site fewer. * refactor(windows-security): one load, one rule, no fallthrough (review #28135) - The descriptor parser is anchored, so a conditional ACE (its body holds parentheses) or anything else it cannot read fails closed instead of verifying the ACEs before it. - findSecureDaclViolation is one flat rule: protected, allow rules only for the user, SYSTEM and Administrators (OpenSSH's ACE rule), and full control for the user. The inherits-from-a-secure-parent exception is gone: it accepted exactly what loosening the parent reaches. - The addon and the user SID load once, behind one wrapper that adds the extended-length prefix to every path and runs async DACL writes to one path in order, so a startup grant cannot land after a re-lock and restore the broader DACL. - With the addon loaded, a failed born-restricted create throws with the matching Node error code instead of falling back to a plain write. - A grant leaves a NULL DACL untouched instead of turning 'everyone' into 'only this SID'. WriteFile runs once and fails on a short write. - The artifact create-intent store writes through writeSecureFile and drops its own temp writer, directory LRU and synchronous directory re-lock. - Test seams the global fake makes redundant are gone. * fix(packaging): fail a Windows build whose workspace addon binary is missing or foreign-arch The @orca/windows-* workspace links exist on every host, so their presence proved nothing: a missing or wrong-architecture .node only surfaced at runtime, as unrestricted secure files or an orcad that refuses to start. beforePack now reads each addon's PE machine against the target arch. * refactor(windows-security): prune what the native ACL left dead (review #28135) - Sync-spawn ratchet: hardenSecurePath and the sync-flag exemption it alone used are gone, and the advice and baseline header no longer say secure writers run icacls. - The install-dir probe reads ACEs through parseSddlDacl, so an ACE it cannot parse fails the whole reading closed instead of being skipped; AC is a resolved alias there now. - The packaging check reads the addon binary through node_modules, the copy that ships. - No winCode-to-errno table: nothing reads .code off a failed secure write. - The load memo is not mutated; secure-path keeps its own log-once flag. - grantInstallDirPackageAcl is a plain function, and secure-file restricts a Windows directory and a POSIX path directly instead of through one two-platform helper. * fix(windows-security): resolve LA in real-ACL tests; tear a file whose close fails - The GitHub Windows runner is the built-in Administrator (RID 500), whose SID reads back as LA. Production already resolves it through localDomainSidOf; the win32 test helpers parsed without it. One shared readDaclOf resolves aliases for every real-ACL suite, and the verifier's unit test covers an LA-aliased directory and LA as a stranger. - createFileWithDacl treats a failed CloseHandle like a failed write (lost data on a network volume) and deletes the torn file. - Drop a duplicate test reset and a typo. * fix(windows-security): load only from inside the app; read DACLs as structure (review #28135) - Loader: orcad's staged binary first, then the workspace package only if its real path is inside the app (process.resourcesPath, or this checkout). A bare specifier otherwise walks every parent up to C:\node_modules, which any local user may create, and orcad holds credentials. - The addon reads a DACL as { protected, aces: [{ type, flags, mask, sid }] } via GetAce, with a SID only for plain allow/deny ACEs and null for a NULL DACL. findSecureDaclViolation and the install-dir probe check that structure, so conditional, callback and object ACEs fail closed. The SDDL parser, its alias table, LA/localDomainSid resolution and getDaclSddl are gone; SDDL is only the write format. - Install-dir tree pass: files flagless as before, but a directory only when its own DACL is protected (inheritably). Re-granting inheriting directories re-propagated each subtree (3.2 s for 1,093 directories). The root is no longer granted, or counted, twice. The two passes are plain functions behind a { root, tree } seam. - One namespacing (path.win32.toNamespacedPath) at the addon boundary, also the per-path ordering key; the comment states the real sync-grant invariant. - restrictWindowsDirectory(dir): Promise<boolean> replaces the callback lane; the reporter is guarded once, so the 'settle' stage is gone. - Sync-spawn ratchet: the secure writers no longer spawn or block, so they leave the gate (109 -> 61 uses). - Errors name the Win32 call and code only; a failed GetSecurityDescriptorControl fails the write instead of defaulting to unprotected. * test(secure-file): assert the durable POSIX write's outcome, not the old chmod order writeSecureFile now chmods the temp file to 0o600 before fsyncing it, so the fsync also covers the mode change and the file is owner-writable when opened. Assert what matters under umask 0o200: the write succeeds, the temp file is fsynced read-only, and the published file is exactly 0o600. * fix(windows-security): namespace paths with the host's toNamespacedPath path.win32.toNamespacedPath rewrote POSIX temp paths (/tmp/x -> \tmp\x) in the Linux runs of the Windows-logic unit tests, so the fake addon wrote to the wrong place. The addon only runs on Windows, where the host function is the win32 one; elsewhere it is the identity. The long-path assertion runs on Windows only. * test(windows-security): assert the install-dir long-path prefix on Windows only The prefix comes from the host's toNamespacedPath, which is the identity off Windows; the Linux run of this suite expected the win32 escape. * fix(windows-user-data-acl): grant the root even when its children cannot be listed (review #28135) A readdir failure on userData aborted the whole grant, so the root grant that could repair a DACL refusing the listing was never attempted. The listing failure is now recorded, the root is still granted, and the marker stays withheld so the next launch retries. * test(windows-security): wait for relock propagation, accept Entra ID SIDs (review #28135) - The large-relock test waited only for the parent to turn protected, which happens before propagation reaches the children, then read a child. It now waits on the parent and both ends of the child range. - The token-user test rejected S-1-12-1 (Entra ID) accounts, failing the suite on Entra-joined dev machines. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
@@ -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<<ORCA_NATIVE_BUILD_DIRECTORIES
|
||||
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
|
||||
ORCA_NATIVE_BUILD_DIRECTORIES
|
||||
ORCA_NATIVE_CACHE_PATHS
|
||||
@@ -82,6 +83,7 @@ runs:
|
||||
path: |
|
||||
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
|
||||
key: ${{ steps.native-cache-scope.outputs.key }}
|
||||
|
||||
@@ -93,6 +95,7 @@ runs:
|
||||
path: |
|
||||
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
|
||||
key: ${{ steps.native-cache-scope.outputs.key }}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ on:
|
||||
- 'config/scripts/space-sharing-copy.mjs'
|
||||
- 'config/patches/**'
|
||||
- 'native/windows-registry/**'
|
||||
- 'native/windows-security/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/ci-cache-warmup.yml'
|
||||
|
||||
@@ -250,6 +250,23 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
overwrite: true
|
||||
# orcad's data-root privacy check fails closed without it, so every Windows slot needs one.
|
||||
- name: Build the Windows security addons for the desktop template
|
||||
if: inputs.build_template && matrix.os == 'windows-2022'
|
||||
shell: bash
|
||||
run: |
|
||||
node config/scripts/build-windows-security-addon.mjs --arch=x64
|
||||
node config/scripts/build-windows-security-addon.mjs --arch=arm64
|
||||
node -e "require('./.build/windows-security/x64/windows-security.node').currentUserSid()"
|
||||
- name: Keep the Windows security addons for the desktop template
|
||||
if: inputs.build_template && matrix.os == 'windows-2022'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orcad-windows-security
|
||||
path: .build/windows-security/*/windows-security.node
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
overwrite: true
|
||||
- uses: actions/setup-node@v6
|
||||
if: runner.arch == 'X64'
|
||||
with:
|
||||
@@ -497,6 +514,11 @@ jobs:
|
||||
with:
|
||||
name: orcad-windows-cli-launcher
|
||||
path: .build/windows-cli-launcher
|
||||
- name: Collect the Windows security addons
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: orcad-windows-security
|
||||
path: .build/windows-security
|
||||
- name: Merge the lanes and gate the full slot matrix
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -1310,6 +1310,7 @@ jobs:
|
||||
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
|
||||
config/scripts/windows-process-tree-gyp-path.test.mjs
|
||||
config/scripts/windows-process-tree-gyp-rebuild.test.mjs
|
||||
config/scripts/package-electron-runtime-contract.test.mjs
|
||||
|
||||
@@ -27,6 +27,9 @@ on:
|
||||
- 'config/scripts/orcad-prebuild-*.mjs'
|
||||
- 'config/scripts/orcad-windows-process-tree.mjs'
|
||||
- 'config/scripts/orcad-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/orcad-windows-security.mjs'
|
||||
- 'config/scripts/build-windows-security-addon.mjs'
|
||||
- 'native/windows-security/**'
|
||||
- 'config/scripts/build-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/windows-rust-executable-build.mjs'
|
||||
- 'native/windows-cli-launcher/**'
|
||||
|
||||
+4
-1
@@ -30,9 +30,12 @@ release/
|
||||
native/**/.build/
|
||||
native/windows-cli-launcher/target/
|
||||
native/windows-agent-hook/target/
|
||||
# node-gyp output for the vendored Windows registry addon; generated per host and ABI.
|
||||
# node-gyp output for the Windows native addons; generated per host and ABI.
|
||||
native/windows-registry/build/
|
||||
native/windows-registry/bin/
|
||||
native/windows-security/build/
|
||||
native/windows-security/bin/
|
||||
.build/windows-security/
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
@@ -202,7 +202,7 @@ const TABULAR_FILE_EXTENSIONS = ['csv', 'tsv']
|
||||
// Why: the config must load on a host-only install without resolving unused Windows addons.
|
||||
// This is load-time tolerance only; beforePack enforces that the target's natives are installed.
|
||||
// Why one package: @vscode/windows-process-tree is the only os: win32 npm addon;
|
||||
// @orca/windows-registry is a workspace link present on every host, so its presence proves nothing.
|
||||
// the @orca/windows-* addons are workspace links present on every host, so their presence proves nothing.
|
||||
const windowsRuntimeResources = existsSync(
|
||||
join(__dirname, '..', 'node_modules', '@vscode', 'windows-process-tree', 'package.json')
|
||||
)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# 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
|
||||
1 src/main/agent-hooks/installer-utils.ts
|
||||
1 src/main/artifacts/artifact-create-intent-store.ts
|
||||
5 src/main/artifacts/artifact-share-record-store.ts
|
||||
2 src/main/browser/browser-cookie-key.ts
|
||||
1 src/main/browser/browser-route-egress-electron-launch.ts
|
||||
1 src/main/browser/browser-route-partition-binding-store.ts
|
||||
@@ -19,7 +18,6 @@
|
||||
1 src/main/codex-accounts/runtime-home-service-wsl.ts
|
||||
2 src/main/codex-accounts/service.ts
|
||||
1 src/main/computer/macos-computer-use-permissions.ts
|
||||
2 src/main/credentials/encrypted-api-key-file-store.ts
|
||||
2 src/main/daemon/daemon-cgroup-scope.ts
|
||||
1 src/main/daemon/daemon-process-identity-query.ts
|
||||
1 src/main/daemon/daemon-process-start-time.ts
|
||||
@@ -33,53 +31,24 @@
|
||||
1 src/main/git/runner.ts
|
||||
1 src/main/ipc/worktree-logic.ts
|
||||
2 src/main/macos-press-and-hold-default.ts
|
||||
2 src/main/managed-data-accounts/credential-capture.ts
|
||||
1 src/main/managed-data-accounts/profile-removal.ts
|
||||
1 src/main/managed-data-accounts/service.ts
|
||||
2 src/main/minimax/minimax-cookie-store.ts
|
||||
1 src/main/network/macos-tailscale-dns-diagnostic.ts
|
||||
1 src/main/opencode/legacy-plugin-acl-retry.ts
|
||||
2 src/main/orca-profiles/profile-artifact-cloud-cleanup.ts
|
||||
1 src/main/orca-profiles/profile-cloud-session-mutation.ts
|
||||
2 src/main/orca-profiles/profile-cloud-session-store.ts
|
||||
2 src/main/orcad/node-pty-precondition.ts
|
||||
1 src/main/orcad/orcad-completed-stop-receipt.ts
|
||||
1 src/main/orcad/orcad-data-root-privacy.ts
|
||||
1 src/main/orcad/orcad-idle-stop-record.ts
|
||||
1 src/main/orcad/orcad-managed-stop-completion.ts
|
||||
1 src/main/orcad/orcad-managed-stop-decision.ts
|
||||
2 src/main/persistence/profile-state/profile-state-access-identity.ts
|
||||
1 src/main/persistence/profile-state/profile-state-recovery-copy.ts
|
||||
1 src/main/plugins/plugin-private-marketplace-ssh-shim.cjs
|
||||
2 src/main/plugins/plugin-secrets-store.ts
|
||||
2 src/main/plugins/plugin-storage-store.ts
|
||||
1 src/main/pty/posix-pty-foreground-group.ts
|
||||
1 src/main/pty/posix-pty-process-groups.ts
|
||||
1 src/main/pwsh.ts
|
||||
3 src/main/runtime/device-registry.ts
|
||||
1 src/main/runtime/e2ee-keypair.ts
|
||||
1 src/main/runtime/push/push-unregister-outbox.ts
|
||||
1 src/main/runtime/relay/relay-region-preference.ts
|
||||
1 src/main/runtime/relay/relay-revoke-outbox.ts
|
||||
1 src/main/runtime/runtime-metadata.ts
|
||||
1 src/main/runtime/runtime-skill-install-authority.ts
|
||||
1 src/main/runtime/tls-certificate.ts
|
||||
1 src/main/skills/skill-discovery-target.ts
|
||||
1 src/main/ssh/orcad-held-fence-tokens.ts
|
||||
1 src/main/ssh/orcad-migration-cutover-journal.ts
|
||||
2 src/main/startup/cli-launch-redirect.ts
|
||||
1 src/main/startup/linux-secret-service-probe.ts
|
||||
2 src/main/win32-utils.ts
|
||||
1 src/main/worktree-runner-script.ts
|
||||
3 src/main/wsl.ts
|
||||
3 src/main/zcode/zcode-plan-api-key-store.ts
|
||||
2 src/main/zsh-startup-hook-pty-harness.ts
|
||||
1 src/shared/bounded-secure-json-file.ts
|
||||
1 src/shared/ephemeral-vm-runtime-feature-store.ts
|
||||
2 src/shared/ephemeral-vm-runtime-store.ts
|
||||
1 src/shared/fish-binary-requirement.ts
|
||||
1 src/shared/openssh-default-shell.ts
|
||||
1 src/shared/runtime-environment-sidecar.ts
|
||||
1 src/shared/runtime-environment-store-file.ts
|
||||
3 src/shared/secure-file.ts
|
||||
3 src/shared/secure-path-windows-acl.ts
|
||||
1 src/shared/secure-file.ts
|
||||
|
||||
@@ -36,9 +36,15 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
// Why macOS only: serve-sim drives the iOS Simulator, and its native addon is a Mach-O that
|
||||
// Windows signing rejects as a PE file.
|
||||
const DARWIN_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['serve-sim']
|
||||
/** The compiled binary each workspace Windows addon loads, relative to its package. */
|
||||
const WORKSPACE_WINDOWS_ADDON_BINARIES = [
|
||||
['@orca/windows-registry', 'build/Release/orca_windows_registry.node'],
|
||||
['@orca/windows-security', 'build/Release/orca_windows_security.node']
|
||||
]
|
||||
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = [
|
||||
'@vscode/windows-process-tree',
|
||||
'@orca/windows-registry'
|
||||
'@orca/windows-registry',
|
||||
'@orca/windows-security'
|
||||
]
|
||||
|
||||
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
|
||||
@@ -612,11 +618,21 @@ function assertPackagedNativeVariantsInstalled(electronPlatformName, electronArc
|
||||
}
|
||||
|
||||
// Why one package: @vscode/windows-process-tree is the only os: win32 npm addon;
|
||||
// @orca/windows-registry is a workspace link present on every host, so its presence proves nothing.
|
||||
// the @orca/windows-* addons are workspace links present on every host, so their presence proves nothing.
|
||||
const missingWindowsAddons = []
|
||||
if (electronPlatformName === 'win32' && !isInstalled('@vscode/windows-process-tree')) {
|
||||
missingWindowsAddons.push('@vscode/windows-process-tree')
|
||||
}
|
||||
// Why the binary, not the package: the link exists everywhere, and a missing or foreign-arch
|
||||
// .node would otherwise surface only at runtime (secure files unrestricted, orcad refusing to start).
|
||||
if (electronPlatformName === 'win32') {
|
||||
for (const [name, binary] of WORKSPACE_WINDOWS_ADDON_BINARIES) {
|
||||
const addon = join(nodeModulesDir, name, binary)
|
||||
if (!existsSync(addon) || readPeMachine(addon) !== PE_MACHINE[architecture]) {
|
||||
missingWindowsAddons.push(`${name} (${binary} for ${architecture})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === 0 && missingWindowsAddons.length === 0) {
|
||||
return
|
||||
|
||||
@@ -110,9 +110,6 @@ src/main/ssh/system-ssh-forward-process.ts
|
||||
src/main/ssh/system-ssh-operation-lifecycle.ts
|
||||
src/main/startup/ensure-virtual-display.ts
|
||||
src/main/startup/hydrate-shell-path.ts
|
||||
src/main/startup/windows-install-dir-acl-probe.ts
|
||||
src/main/startup/windows-user-data-acl.ts
|
||||
src/main/win32-utils.ts
|
||||
src/main/window/clipboard-ipc-handlers.ts
|
||||
src/main/windows-process-tree-kill.ts
|
||||
src/main/workspace-space-analysis.ts
|
||||
|
||||
@@ -33,6 +33,7 @@ import { smokeSessionScannerService } from './session-scanner-service-smoke.mjs'
|
||||
import { materializeWatcherPackage } from './orcad-watcher-package.mjs'
|
||||
import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs'
|
||||
import { stageOrcadWindowsCliLauncher } from './orcad-windows-cli-launcher.mjs'
|
||||
import { stageOrcadWindowsSecurity } from './orcad-windows-security.mjs'
|
||||
import {
|
||||
ORCAD_EMOJI_SHORTCODE_DATASET,
|
||||
ORCAD_CLI_ENTRY_FILENAME,
|
||||
@@ -163,6 +164,7 @@ stageNodePty(BUILD_TARGET)
|
||||
await stageParcelWatcher(BUILD_TARGET)
|
||||
stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET)
|
||||
stageOrcadWindowsCliLauncher(ROOT, OUT_DIR, BUILD_TARGET)
|
||||
stageOrcadWindowsSecurity(ROOT, OUT_DIR, BUILD_TARGET)
|
||||
const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET)
|
||||
mkdirSync(dirname(emojiDatasetOutput), { recursive: true })
|
||||
copyFileSync(
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Compile native/windows-security for an orcad Windows slot.
|
||||
*
|
||||
* orcad runs under its own pinned Node with no node_modules, and its secure-file writes and
|
||||
* data-root privacy check need this addon: without it the privacy check fails closed and orcad
|
||||
* refuses to start. N-API, so one build serves every Node. Arm64 cross-compiles from x64.
|
||||
*
|
||||
* Builds in a copy of the sources so the desktop's own build/Release (the host arch) survives.
|
||||
*
|
||||
* node config/scripts/build-windows-security-addon.mjs --arch=arm64
|
||||
*/
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
import { ORCAD_WINDOWS_SECURITY_FILENAME } from '../../src/shared/orcad-artifacts.ts'
|
||||
|
||||
const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)(
|
||||
'./windows-pe-machine.cjs'
|
||||
)
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..', '..')
|
||||
const SUPPORTED_ARCHES = ['x64', 'arm64']
|
||||
const PACKAGE_DIR = join(ROOT, 'native', 'windows-security')
|
||||
|
||||
export function windowsSecurityAddonOutput(root, arch) {
|
||||
return join(root, '.build', 'windows-security', arch, ORCAD_WINDOWS_SECURITY_FILENAME)
|
||||
}
|
||||
|
||||
export function buildWindowsSecurityAddon(arch, output = windowsSecurityAddonOutput(ROOT, arch)) {
|
||||
if (!SUPPORTED_ARCHES.includes(arch)) {
|
||||
throw new Error(`--arch must be one of ${SUPPORTED_ARCHES.join(', ')}; got ${arch}`)
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error(
|
||||
`The Windows security addon only builds on Windows; running on ${process.platform}.`
|
||||
)
|
||||
}
|
||||
// Inside the repo so binding.gyp's `require('node-addon-api')` resolves from root node_modules.
|
||||
const workDir = join(ROOT, '.build', 'windows-security', arch, 'gyp')
|
||||
rmSync(workDir, { recursive: true, force: true })
|
||||
mkdirSync(workDir, { recursive: true })
|
||||
copyFileSync(join(PACKAGE_DIR, 'binding.gyp'), join(workDir, 'binding.gyp'))
|
||||
cpSync(join(PACKAGE_DIR, 'src'), join(workDir, 'src'), { recursive: true })
|
||||
const result = runProcessSync({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'),
|
||||
'rebuild',
|
||||
`--arch=${arch}`
|
||||
],
|
||||
cwd: workDir,
|
||||
stdio: 'inherit',
|
||||
timeoutMs: null
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`node-gyp failed for ${arch} with exit ${result.code}`)
|
||||
}
|
||||
const built = join(workDir, 'build', 'Release', 'orca_windows_security.node')
|
||||
if (!existsSync(built)) {
|
||||
throw new Error(`node-gyp reported success but ${built} is missing.`)
|
||||
}
|
||||
// Why check the artifact: node-gyp can ignore --arch, and a host could not load the result.
|
||||
const machine = readPeMachine(built)
|
||||
if (machine !== PE_MACHINE[arch]) {
|
||||
throw new Error(`Built binary is ${describePeMachine(machine)}, expected ${arch}.`)
|
||||
}
|
||||
mkdirSync(resolve(output, '..'), { recursive: true })
|
||||
copyFileSync(built, output)
|
||||
console.log(`[windows-security] ${arch} -> ${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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"target_name": "orca_windows_security",
|
||||
"sources": ["src/addon.cc"],
|
||||
"libraries": ["advapi32.lib"],
|
||||
"include_dirs": ["<!@(node -p \"require('node-addon-api').include\")"],
|
||||
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS", "UNICODE", "_UNICODE"],
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": { "ExceptionHandling": 0 }
|
||||
},
|
||||
"conditions": [
|
||||
["OS!='win'", { "sources": [] , "type": "none" }]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
/** Every call throws an Error whose message names the Win32 call and its error code. */
|
||||
|
||||
export type GrantAccessOptions = {
|
||||
/** Children inherit the grant (OI|CI). Only meaningful on a directory. */
|
||||
inheritable: boolean
|
||||
/**
|
||||
* SET_ACCESS: drop every explicit rule for the trustee, allow and deny, then grant exactly
|
||||
* `accessMask`. Otherwise GRANT_ACCESS: add `accessMask` to what the trustee is already allowed.
|
||||
*/
|
||||
replace: boolean
|
||||
}
|
||||
|
||||
/** The process token's user SID, e.g. `S-1-5-21-…-1001`. */
|
||||
export declare function currentUserSid(): string
|
||||
|
||||
/**
|
||||
* One DACL entry as stored. `type` and `flags` are the raw ACE_HEADER bytes; `sid` is set only for
|
||||
* plain allow (0) and deny (1) ACEs and is null for every other type.
|
||||
*/
|
||||
export type WindowsAce = { type: number; flags: number; mask: number; sid: string | null }
|
||||
|
||||
/** `protected` is SE_DACL_PROTECTED. */
|
||||
export type WindowsDacl = { protected: boolean; aces: WindowsAce[] }
|
||||
|
||||
/** The path's DACL as structure, or null for a NULL DACL (which grants everyone everything). */
|
||||
export declare function getDaclAces(path: string): WindowsDacl | null
|
||||
|
||||
/**
|
||||
* Installs exactly the DACL in `sddl`. Its `P` flag picks protected or inheriting. A directory
|
||||
* propagates to every existing descendant, which is seconds on a large tree.
|
||||
*/
|
||||
export declare function setDaclSddl(path: string, sddl: string): void
|
||||
export declare function setDaclSddlAsync(path: string, sddl: string): Promise<void>
|
||||
|
||||
/** 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<void>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 <napi.h>
|
||||
#include <windows.h>
|
||||
#include <aclapi.h>
|
||||
#include <sddl.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<void, LocalDeleter>;
|
||||
|
||||
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<LPCWSTR>(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<LPWSTR>(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<LPCWSTR>(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<LPCWSTR>(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<LPWSTR>(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<LPWSTR>(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<LPCWSTR>(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<DWORD>(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<LPCWSTR>(path.c_str()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool ReadGrantOptions(const Napi::Value& value, bool* inheritable, bool* replace) {
|
||||
if (!value.IsObject()) {
|
||||
return false;
|
||||
}
|
||||
auto options = value.As<Napi::Object>();
|
||||
auto inherit = options.Get("inheritable");
|
||||
auto replaceValue = options.Get("replace");
|
||||
if (!inherit.IsBoolean() || !replaceValue.IsBoolean()) {
|
||||
return false;
|
||||
}
|
||||
*inheritable = inherit.As<Napi::Boolean>().Value();
|
||||
*replace = replaceValue.As<Napi::Boolean>().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<char16_t*>(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<BYTE> 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<TOKEN_USER*>(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<Napi::String>().Utf16Value();
|
||||
PSECURITY_DESCRIPTOR rawDescriptor = nullptr;
|
||||
PACL dacl = nullptr;
|
||||
DWORD status = GetNamedSecurityInfoW(reinterpret_cast<LPCWSTR>(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<ACE_HEADER*>(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<ACCESS_ALLOWED_ACE*>(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<Napi::String>().Utf16Value(),
|
||||
info[1].As<Napi::String>().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<Napi::Uint8Array>();
|
||||
auto result = CreateFileWithDacl(info[0].As<Napi::String>().Utf16Value(),
|
||||
info[1].As<Napi::String>().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<Napi::String>().Utf16Value();
|
||||
*sid = info[1].As<Napi::String>().Utf16Value();
|
||||
*mask = info[2].As<Napi::Number>().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<Win32Result()> 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<Win32Result()> 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<Napi::String>().Utf16Value();
|
||||
auto sddl = info[1].As<Napi::String>().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)
|
||||
@@ -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",
|
||||
|
||||
Generated
+9
@@ -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':
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -35,24 +35,12 @@ vi.mock('../../shared/secure-path-windows-acl', async (importOriginal) => {
|
||||
const acl = await importOriginal<typeof WindowsAcl>()
|
||||
return {
|
||||
...acl,
|
||||
bestEffortRestrictWindowsPath: (
|
||||
targetPath: string,
|
||||
isDirectory: boolean,
|
||||
onSettled?: (restricted: boolean) => void
|
||||
): void => {
|
||||
let finish!: () => void
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
restrictWindowsDirectory: (dirPath: string): Promise<boolean> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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<string, string>()
|
||||
|
||||
/**
|
||||
* 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<TimelineEntry>(BLOCKING_SPAWNS_PER_WRITE).fill('acl-spawn'),
|
||||
...Array<TimelineEntry>(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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void> } {
|
||||
let release = (): void => undefined
|
||||
const walkingTheTree = new Promise<void>((resolve) => {
|
||||
@@ -87,16 +87,10 @@ function reportProbePoisoned(): { finishRepair: () => Promise<void> } {
|
||||
{ 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<ProcessResult>
|
||||
return { ok: true, failedFileCount: 0 }
|
||||
})
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -111,20 +105,18 @@ function reportProbePoisoned(): { finishRepair: () => Promise<void> } {
|
||||
|
||||
/** A repair that settles, so `poison.stage` leaves 'pending' for a terminal verdict. */
|
||||
async function reportProbePoisonedWithSettledRepair(
|
||||
exitCode: number,
|
||||
granted: boolean,
|
||||
userDataPath?: string
|
||||
): Promise<void> {
|
||||
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<ProcessResult>
|
||||
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<void> {
|
||||
const options = recoveryOptions()
|
||||
@@ -152,9 +144,9 @@ async function gateFindsRepairBudgetSpent(): Promise<void> {
|
||||
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<ProcessResult>
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
function probe(options: WindowsInstallDirAclProbeOptions = {}): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve) => {
|
||||
probeWindowsInstallDirAcl({
|
||||
platform: 'win32',
|
||||
@@ -40,7 +40,8 @@ function probe(options: WindowsInstallDirAclProbeOptions): Promise<Record<string
|
||||
|
||||
/** Every target returns the same ACE set on top of the system baseline. */
|
||||
function probeWith(...aces: string[]): Promise<Record<string, unknown>> {
|
||||
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 <dir> /save` / `icacls <file> /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)
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
const saveFile = join(tmpdir(), `orca-install-acl-${randomUUID()}.txt`)
|
||||
return new Promise<string>((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<void> {
|
||||
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.
|
||||
|
||||
@@ -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<ProcessResult>
|
||||
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<ProcessSpec[]> {
|
||||
const specs: ProcessSpec[] = []
|
||||
const runProcessFn = async (spec: ProcessSpec): Promise<ProcessResult> => {
|
||||
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<InstallDirPackageGrantPass[]> {
|
||||
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<ProcessSpec[]>((resolve) => {
|
||||
const collected: ProcessSpec[] = []
|
||||
installFakeDacls(() => null)
|
||||
const specs = await new Promise<InstallDirPackageGrantPass[]>((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<never>(() => undefined)) as never,
|
||||
grants: grantsFrom(() => new Promise<never>(() => 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<void>((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<never>(() => undefined)) as Runner)
|
||||
recoveryOptions(userDataPath, () => new Promise<never>(() => 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<never>(() => undefined)) as Runner)
|
||||
recoveryOptions(userDataPath, () => new Promise<never>(() => undefined))
|
||||
)
|
||||
resetWindowsInstallDirAclRecoveryForTest()
|
||||
resetWindowsInstallDirAclRepairForTest()
|
||||
|
||||
const mode = await repairKnownPoisonedInstallDirBeforeWindow({
|
||||
...recoveryOptions(userDataPath, (() => new Promise<never>(() => undefined)) as Runner),
|
||||
...recoveryOptions(userDataPath, () => new Promise<never>(() => 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<never>(() => undefined)) as Runner)
|
||||
recoveryOptions(userDataPath, () => new Promise<never>(() => undefined))
|
||||
)
|
||||
resetWindowsInstallDirAclRecoveryForTest()
|
||||
resetWindowsInstallDirAclRepairForTest()
|
||||
@@ -430,7 +416,7 @@ describe('repairKnownPoisonedInstallDirBeforeWindow', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-acl-gate-'))
|
||||
startWindowsInstallDirAclRepairIfPoisoned(
|
||||
POISON_VERDICT,
|
||||
recoveryOptions(userDataPath, (() => new Promise<never>(() => undefined)) as Runner)
|
||||
recoveryOptions(userDataPath, () => new Promise<never>(() => 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<never>(() => undefined)) as Runner)
|
||||
recoveryOptions(userDataPath, () => new Promise<never>(() => 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<never>(() => undefined)) as Runner)
|
||||
recoveryOptions(userDataPath, () => new Promise<never>(() => 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<void>((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<void>((resolve) => {
|
||||
startWindowsInstallDirAclRepairIfPoisoned(POISON_VERDICT, {
|
||||
@@ -818,7 +798,7 @@ describe('a clean probe verdict', () => {
|
||||
)
|
||||
startWindowsInstallDirAclRepairIfPoisoned(
|
||||
POISON_VERDICT,
|
||||
recoveryOptions(userDataPath, (() => new Promise<never>(() => undefined)) as Runner)
|
||||
recoveryOptions(userDataPath, () => new Promise<never>(() => 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<never>(() => undefined)) as Runner)
|
||||
recoveryOptions(userDataPath, () => new Promise<never>(() => 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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <file> /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<void> {
|
||||
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<Record<string, unknown>> {
|
||||
function probeVerdict(): Promise<CrashReportBreadcrumbData> {
|
||||
resetWindowsInstallDirAclProbeForTest()
|
||||
return new Promise((resolve) => {
|
||||
probeWindowsInstallDirAcl({
|
||||
installDir,
|
||||
recordBreadcrumb: () => undefined,
|
||||
onDone: (data) => resolve(data as Record<string, unknown>)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 <target> /save <file>` 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 <target> /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 <target>` 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<InstallDirPackageGrantOutcome>
|
||||
|
||||
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<InstallDirPackageGrantOutcome> | Promise<InstallDirPackageGrantOutcome> = () => ({})
|
||||
): {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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}`])
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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<InstallDirPackageGrantOutcome>
|
||||
tree: (installDir: string) => Promise<InstallDirPackageGrantOutcome>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
return security.addon.grantAccessAsync(
|
||||
path,
|
||||
RESTRICTED_APP_PACKAGES_SID,
|
||||
WINDOWS_FILE_READ_EXECUTE,
|
||||
{
|
||||
inheritable,
|
||||
replace: false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function grantInstallDirPackageRoot(
|
||||
installDir: string
|
||||
): Promise<InstallDirPackageGrantOutcome> {
|
||||
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<InstallDirPackageGrantOutcome> {
|
||||
const security = loadWindowsSecurity()
|
||||
if ('error' in security) {
|
||||
return ADDON_UNAVAILABLE
|
||||
}
|
||||
let failedFileCount = 0
|
||||
let firstFailure: string | undefined
|
||||
const attempt = async (step: () => Promise<void> | void): Promise<void> => {
|
||||
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
|
||||
}
|
||||
@@ -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<ProcessResult>
|
||||
|
||||
function fakeRunner(reply: (spec: ProcessSpec) => Partial<ProcessResult> = () => ({})): {
|
||||
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<typeof fakePackageGrant> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 <file> /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<GrantOutcome> {
|
||||
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<void> {
|
||||
const record = args.recordBreadcrumb ?? recordDurableCrashBreadcrumb
|
||||
const installDir = args.installDir ?? dirname(process.execPath)
|
||||
@@ -221,27 +172,15 @@ async function runRepair(args: WindowsInstallDirAclRepairArgs): Promise<void> {
|
||||
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<void> {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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<typeof ensureWindowsUserDataAclGrant>[1]
|
||||
): Promise<WindowsAclGrantResult> {
|
||||
function awaitResult(userDataPath: string): Promise<WindowsAclGrantResult> {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (`<userData>\*`). 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<string[]> {
|
||||
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('; ') })
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
+27
-150
@@ -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<string> {
|
||||
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<string | null> | 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<string | null> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,12 +34,6 @@ vi.mock('node:fs', async (original) => {
|
||||
const real = await original<typeof fs>()
|
||||
return {
|
||||
...real,
|
||||
writeFileSync: (...args: Parameters<typeof fs.writeFileSync>) => {
|
||||
if (++state.writes === 2 && state.fault === 'before-restore') {
|
||||
throw new Error('Synthetic restore staging failure')
|
||||
}
|
||||
return real.writeFileSync(...args)
|
||||
},
|
||||
renameSync: (...args: Parameters<typeof fs.renameSync>) => {
|
||||
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<typeof WindowsAcl>()
|
||||
const files = await vi.importActual<typeof fs>('node:fs')
|
||||
return {
|
||||
...real,
|
||||
restrictWindowsPathSync: (...args: Parameters<typeof WindowsAcl.restrictWindowsPathSync>) => {
|
||||
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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<typeof Fs>('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 =>
|
||||
|
||||
@@ -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([
|
||||
'..',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
+33
-742
@@ -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<string, string>()
|
||||
|
||||
/** Paths whose verify pass should answer with a DACL that is not the intended one. */
|
||||
const forcedBadSddl = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* 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<typeof vi.spyOn>, 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<string>()
|
||||
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<void> {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFileTimestampTick(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
|
||||
function statMode(path: string): number {
|
||||
return statSync(path).mode & 0o777
|
||||
}
|
||||
|
||||
+62
-164
@@ -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<HardenedPathCacheEntry>(
|
||||
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<true>(
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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<string>()
|
||||
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<boolean> {
|
||||
// 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<boolean> {
|
||||
// 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<string | null> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 <script> <path> <sid>` leaves `$args`
|
||||
* empty, so the script died on its first statement and every caller was told
|
||||
* the path had been hardened. Asserting on the constructed arguments passed
|
||||
* happily throughout. Only reading the resulting ACL back off a real file
|
||||
* catches it, so that is what this does.
|
||||
* The half of the proof a fake addon cannot give: every verdict here is read back off a real
|
||||
* NTFS volume, including a relocated data root under C:\ that inherits Authenticated Users:Modify
|
||||
* — the case that makes these files worth restricting at all.
|
||||
*
|
||||
* Runs only on win32; skipped elsewhere.
|
||||
*/
|
||||
const describeOnWindows = process.platform === 'win32' ? describe : describe.skip
|
||||
|
||||
const EVERYONE_SID = 'S-1-1-0'
|
||||
const BUILTIN_ADMINISTRATORS_SID = 'S-1-5-32-544'
|
||||
/** High, System and Protected mandatory levels: the token is elevated. Medium (S-1-16-8192) is not. */
|
||||
const ELEVATED_INTEGRITY_SID = /\bS-1-16-(?:12288|16384|20480)\b/
|
||||
|
||||
/** The SID the production code will grant to, so a planted DACL can differ only in its flags. */
|
||||
function currentUserSid(): string {
|
||||
const result = runProcessSync({
|
||||
program: windowsSystem32Binary('whoami.exe'),
|
||||
args: ['/user', '/fo', 'csv', '/nh'],
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
return result.stdout.trim().split(/","/)[1]!.replace(/"$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Plants an exact DACL. Two invocations on purpose: combining `/inheritance:r` with `/grant:r`
|
||||
* leaves the argument order to icacls, and on Windows Server the inherited ACEs survive the
|
||||
* combined form as explicit ones -- which is a planted precondition that silently is not the
|
||||
* one written down. Removing inheritance first makes the grant the whole DACL.
|
||||
*/
|
||||
function plantDacl(path: string, grants: string[]): void {
|
||||
expect(icacls(path, '/inheritance:r', '/q').code).toBe(0)
|
||||
expect(icacls(path, ...grants.flatMap((grant) => ['/grant:r', grant]), '/q').code).toBe(0)
|
||||
}
|
||||
|
||||
function icacls(...args: string[]): { code: number | null; stdout: string } {
|
||||
const result = runProcessSync({
|
||||
program: windowsSystem32Binary('icacls.exe'),
|
||||
args,
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
return { code: result.code, stdout: result.stdout }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this process could rewrite a system file's DACL — decided before anything is written.
|
||||
*
|
||||
* `icacls <hosts> /save` is not the probe it looks like: `BUILTIN\Users` holds `(RX)`, so
|
||||
* READ_CONTROL succeeds unelevated and every machine would report elevated. The token's mandatory
|
||||
* integrity level is the thing that actually differs, and it is a SID rather than a localized
|
||||
* string, so it reads the same on a non-English Windows.
|
||||
*/
|
||||
function isElevated(): boolean {
|
||||
if (process.platform !== 'win32') {
|
||||
return false
|
||||
}
|
||||
const result = runProcessSync({
|
||||
program: windowsSystem32Binary('whoami.exe'),
|
||||
args: ['/groups', '/fo', 'csv', '/nh'],
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
if (ELEVATED_INTEGRITY_SID.test(result.stdout)) {
|
||||
return true
|
||||
}
|
||||
// Unelevated, Administrators is present only as "Group used for deny only".
|
||||
const administrators = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.includes(BUILTIN_ADMINISTRATORS_SID))
|
||||
return administrators?.includes('Enabled group') ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Principal:(flags)` entries of `icacls <path>`. The first line carries the path, which is
|
||||
* stripped by the exact string passed in so its own spaces cannot be mistaken for the separator.
|
||||
*/
|
||||
function readAclEntries(path: string): string[] {
|
||||
const arg = path.length < 260 ? path : `\\\\?\\${path}`
|
||||
const { stdout } = icacls(arg)
|
||||
const entries: string[] = []
|
||||
for (const [index, rawLine] of stdout.split(/\r?\n/).entries()) {
|
||||
const line = index === 0 ? rawLine.slice(arg.length) : rawLine
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed && index > 0) {
|
||||
break
|
||||
}
|
||||
if (trimmed.includes(':(')) {
|
||||
entries.push(trimmed)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* `toHaveLength` reports only the count, and vitest elides the array past a few items — which on a
|
||||
* host that lists a DACL differently is exactly the information needed. Name the entries.
|
||||
*/
|
||||
function listed(entries: string[]): string {
|
||||
return `icacls listed ${entries.length} entries: ${entries.join(' | ')}`
|
||||
}
|
||||
|
||||
describeOnWindows('restrictWindowsPathSync against a real filesystem', () => {
|
||||
const elevated = isElevated()
|
||||
describeOnWindows('Windows secure paths against a real filesystem', () => {
|
||||
let addon: WindowsSecurity['addon']
|
||||
let sid: string
|
||||
let root: string
|
||||
/** A relocated data root: `C:\orca-…` inherits BUILTIN\Users and Authenticated Users from C:\. */
|
||||
let relocatedRoot: string
|
||||
|
||||
const daclOf = (path: string) => addon.getDaclAces(path)
|
||||
const sidsOf = (path: string) => (daclOf(path)?.aces ?? []).map((ace) => ace.sid).sort()
|
||||
const INHERITABLE = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
|
||||
const INHERITED_ACE = 0x10
|
||||
const ORCA_PRINCIPALS = () => ['S-1-5-18', 'S-1-5-32-544', sid].sort()
|
||||
|
||||
beforeAll(() => {
|
||||
resetSecureFileWindowsUserSidForTests()
|
||||
const loaded = loadWindowsSecurity()
|
||||
if ('error' in loaded) {
|
||||
throw new Error(`the windows-security addon did not load: ${loaded.error}`)
|
||||
}
|
||||
addon = loaded.addon
|
||||
sid = loaded.userSid
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-acl-win32-'))
|
||||
// %TEMP% grants [user, SYSTEM, Administrators] (OI)(CI)(F) by default, and those
|
||||
// propagate into every fixture below. Strip them here so a planted DACL is exactly what
|
||||
// the test planted: on a host where the running user is also one of the three principals
|
||||
// a test grants, an inherited copy is otherwise indistinguishable from a planted one.
|
||||
plantDacl(root, [`*${currentUserSid()}:(OI)(CI)(F)`])
|
||||
relocatedRoot = join('C:\\', `orca-acl-test-${randomBytes(4).toString('hex')}`)
|
||||
mkdirSync(relocatedRoot)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetWindowsSecurityLoadForTests()
|
||||
__resetSecureFileHardenedPathsForTests()
|
||||
setSecurePathHardeningReporter(null)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// This suite plants hostile DACLs on purpose, and `removeTreeSync` only retries *transient*
|
||||
// locks. Should a repair regress, an `(OI)(CI)(IO)` grant leaves the tree permanently
|
||||
// undeletable — so restore inheritance first rather than leaking it into %TEMP% every run.
|
||||
icacls(root, '/reset', '/t', '/q')
|
||||
removeTreeSync(root)
|
||||
// This suite plants hostile DACLs on purpose; should a repair regress, an inherit-only grant
|
||||
// would leave the tree undeletable, so hand every entry back to this user first.
|
||||
for (const tree of [root, relocatedRoot]) {
|
||||
unlockTree(tree)
|
||||
removeTreeSync(tree)
|
||||
}
|
||||
})
|
||||
|
||||
it('actually applies the ACL to a real file, dropping inherited and foreign ACEs', () => {
|
||||
const file = join(root, 'credential.json')
|
||||
writeFileSync(file, '{"token":"secret"}')
|
||||
// A planted explicit ACE: /inheritance:r alone does not remove these.
|
||||
expect(icacls(file, '/grant', `*${EVERYONE_SID}:(R)`).code).toBe(0)
|
||||
function unlockTree(path: string): void {
|
||||
try {
|
||||
addon.setDaclSddl(path, `D:P(A;OICI;FA;;;${sid})`)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (lstatSync(path).isDirectory()) {
|
||||
for (const name of readdirSync(path)) {
|
||||
unlockTree(join(path, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const before = readAclEntries(file)
|
||||
expect(before.some((entry) => entry.startsWith('Everyone:'))).toBe(true)
|
||||
expect(before.some((entry) => entry.includes('(I)'))).toBe(true)
|
||||
it('starts from a relocated root that really is broader than Orca allows', () => {
|
||||
const verdict = findSecureDaclViolation(addon.getDaclAces(relocatedRoot), sid, true)
|
||||
expect(verdict).toMatch(/unexpected rule for S-1-5-(11|32-545)/)
|
||||
})
|
||||
|
||||
it('restricts a real file, dropping inherited and planted foreign rules', () => {
|
||||
const file = join(relocatedRoot, 'credential.json')
|
||||
writeFileSync(file, '{"token":"secret"}')
|
||||
addon.grantAccess(file, 'S-1-1-0', 0x120089, { inheritable: false, replace: false })
|
||||
expect(sidsOf(file)).toContain('S-1-1-0')
|
||||
|
||||
expect(restrictWindowsPathSync(file, false)).toBe(true)
|
||||
|
||||
const after = readAclEntries(file)
|
||||
// No inherited ACE survives: the DACL is protected.
|
||||
expect(after.every((entry) => !entry.includes('(I)'))).toBe(true)
|
||||
expect(after.some((entry) => entry.startsWith('Everyone:'))).toBe(false)
|
||||
// Exactly the three intended principals, each with FullControl.
|
||||
expect(after).toHaveLength(3)
|
||||
expect(after.every((entry) => entry.endsWith(':(F)'))).toBe(true)
|
||||
expect(daclOf(file)?.protected).toBe(true)
|
||||
expect(sidsOf(file)).toEqual(ORCA_PRINCIPALS())
|
||||
})
|
||||
|
||||
it('gives a real directory inheritable rules so files created inside stay restricted', () => {
|
||||
const dir = join(root, 'secure-dir')
|
||||
it('gives a directory inheritable rules so files created inside start restricted', () => {
|
||||
const dir = join(relocatedRoot, 'secure-dir')
|
||||
mkdirSync(dir)
|
||||
|
||||
expect(restrictWindowsPathSync(dir, true)).toBe(true)
|
||||
|
||||
const after = readAclEntries(dir)
|
||||
expect(after).toHaveLength(3)
|
||||
expect(after.every((entry) => entry.endsWith(':(OI)(CI)(F)'))).toBe(true)
|
||||
expect(after.every((entry) => !entry.includes('(I)'))).toBe(true)
|
||||
|
||||
// The point of the inheritance flags: a child written afterwards is already restricted.
|
||||
const child = join(dir, 'inherited.json')
|
||||
expect(daclOf(dir)?.aces.every((ace) => ace.flags === INHERITABLE)).toBe(true)
|
||||
const child = join(dir, 'plain.json')
|
||||
writeFileSync(child, '{}')
|
||||
const childEntries = readAclEntries(child)
|
||||
expect(childEntries).toHaveLength(3)
|
||||
expect(childEntries.every((entry) => entry.includes('(I)'))).toBe(true)
|
||||
expect(childEntries.some((entry) => entry.startsWith('Everyone:'))).toBe(false)
|
||||
expect(sidsOf(child)).toEqual(ORCA_PRINCIPALS())
|
||||
expect(daclOf(child)?.aces.every((ace) => (ace.flags & INHERITED_ACE) !== 0)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['(OI) without (CI), leaving subdirectories unprotected', 'OI'],
|
||||
['(OI)(CI)(IO), which grants nobody anything on the directory itself', 'OICIIO']
|
||||
])('repairs a directory whose rules are %s', (_label, flags) => {
|
||||
const dir = join(root, `wrong-flags-${flags}`)
|
||||
mkdirSync(dir)
|
||||
addon.setDaclSddl(dir, `D:P(A;${flags};FA;;;SY)(A;${flags};FA;;;BA)(A;${flags};FA;;;${sid})`)
|
||||
|
||||
expect(restrictWindowsPathSync(dir, true)).toBe(true)
|
||||
|
||||
expect(daclOf(dir)?.aces.every((ace) => ace.flags === INHERITABLE)).toBe(true)
|
||||
expect(() => writeFileSync(join(dir, 'child.json'), '{}')).not.toThrow()
|
||||
})
|
||||
|
||||
it('repairs a protected three-rule DACL that grants the wrong principal', () => {
|
||||
const file = join(root, 'substituted.json')
|
||||
writeFileSync(file, '{}')
|
||||
addon.setDaclSddl(file, 'D:P(A;;FA;;;WD)(A;;FA;;;SY)(A;;FA;;;BA)')
|
||||
|
||||
expect(restrictWindowsPathSync(file, false)).toBe(true)
|
||||
|
||||
expect(sidsOf(file)).toEqual(ORCA_PRINCIPALS())
|
||||
})
|
||||
|
||||
// No migration: what the icacls hardening left on disk is accepted exactly as it is.
|
||||
it('never rewrites a path the icacls-era hardening already locked', () => {
|
||||
const file = join(root, 'icacls-era.json')
|
||||
writeFileSync(file, '{}')
|
||||
addon.setDaclSddl(file, `D:PAI(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;${sid})`)
|
||||
const before = addon.getDaclAces(file)
|
||||
const writes = countingAddon()
|
||||
|
||||
expect(restrictWindowsPathSync(file, false)).toBe(true)
|
||||
|
||||
expect(writes.count).toBe(0)
|
||||
expect(addon.getDaclAces(file)).toEqual(before)
|
||||
})
|
||||
|
||||
// Paths reach this code from user-chosen workspace locations, so the quoting hazards that
|
||||
// ruled out interpolating them into a PowerShell command line get exercised for real.
|
||||
it.each([
|
||||
['spaces', 'a b c'],
|
||||
['single quote and dollar', "quo'te $var"],
|
||||
['backtick', 'back`tick'],
|
||||
['brackets', 'brack[et]s'],
|
||||
['semicolon and ampersand', 'semi;colon & amp'],
|
||||
['comma', 'com,ma'],
|
||||
['parentheses', 'paren(s)'],
|
||||
['caret and percent', 'car^et %PATH%']
|
||||
['caret and percent', 'car^et %PATH%'],
|
||||
['non-ASCII', 'résumé 資料']
|
||||
])('hardens a path containing %s', (_label, segment) => {
|
||||
const dir = join(root, segment)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
@@ -201,10 +174,7 @@ describeOnWindows('restrictWindowsPathSync against a real filesystem', () => {
|
||||
writeFileSync(file, '{}')
|
||||
|
||||
expect(restrictWindowsPathSync(file, false)).toBe(true)
|
||||
|
||||
const after = readAclEntries(file)
|
||||
expect(after).toHaveLength(3)
|
||||
expect(after.every((entry) => !entry.includes('(I)'))).toBe(true)
|
||||
expect(sidsOf(file)).toEqual(ORCA_PRINCIPALS())
|
||||
})
|
||||
|
||||
it('hardens a path longer than MAX_PATH', () => {
|
||||
@@ -214,191 +184,170 @@ describeOnWindows('restrictWindowsPathSync against a real filesystem', () => {
|
||||
}
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const file = join(dir, 'secret.json')
|
||||
expect(file.length).toBeGreaterThan(260)
|
||||
writeFileSync(file, '{}')
|
||||
|
||||
expect(restrictWindowsPathSync(file, false)).toBe(true)
|
||||
expect(readAclEntries(file)).toHaveLength(3)
|
||||
expect(sidsOf(`\\\\?\\${file}`)).toEqual(ORCA_PRINCIPALS())
|
||||
})
|
||||
|
||||
/**
|
||||
* The shape of a DACL is not the same question as who is on it. This one is protected, carries
|
||||
* exactly three non-inherited full-control rules, and grants Everyone — so it satisfies every
|
||||
* check that does not compare SIDs, and hardening would report success and leave it alone.
|
||||
*/
|
||||
it('repairs a protected three-rule DACL that grants the wrong principal', () => {
|
||||
const file = join(root, 'substituted.json')
|
||||
writeFileSync(file, '{"token":"secret"}')
|
||||
plantDacl(file, [`*${EVERYONE_SID}:(F)`, '*S-1-5-18:(F)', '*S-1-5-32-544:(F)'])
|
||||
|
||||
const before = readAclEntries(file)
|
||||
expect(before, listed(before)).toHaveLength(3)
|
||||
expect(before.every((entry) => !entry.includes('(I)'))).toBe(true)
|
||||
expect(before.some((entry) => entry.startsWith('Everyone:'))).toBe(true)
|
||||
|
||||
expect(restrictWindowsPathSync(file, false)).toBe(true)
|
||||
|
||||
const after = readAclEntries(file)
|
||||
expect(after, listed(after)).toHaveLength(3)
|
||||
expect(after.some((entry) => entry.startsWith('Everyone:'))).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* Wrong *flags* rather than a wrong principal. Both of these are protected, carry three
|
||||
* non-inherited full-control rules for exactly the right SIDs, and differ from correct only in
|
||||
* their inheritance flags — so a check that tests OI alone accepts them.
|
||||
*
|
||||
* That under-check was harmless while /reset + /grant ran unconditionally and repaired whatever
|
||||
* was there. Verify-first made it load-bearing: what verification accepts is now left alone.
|
||||
*/
|
||||
it.each([
|
||||
['(OI) without (CI), leaving subdirectories unprotected', '(OI)(F)'],
|
||||
['(OI)(CI)(IO), which grants nobody anything on the directory itself', '(OI)(CI)(IO)(F)']
|
||||
])('repairs a directory whose rules are %s', (_label, rights) => {
|
||||
const dir = join(root, `wrong-flags-${rights.replace(/[^A-Z]/g, '')}`)
|
||||
mkdirSync(dir)
|
||||
plantDacl(dir, [
|
||||
`*${currentUserSid()}:${rights}`,
|
||||
`*S-1-5-18:${rights}`,
|
||||
`*S-1-5-32-544:${rights}`
|
||||
])
|
||||
|
||||
const before = readAclEntries(dir)
|
||||
expect(before, listed(before)).toHaveLength(3)
|
||||
expect(before.every((entry) => !entry.includes('(I)'))).toBe(true)
|
||||
expect(before.every((entry) => entry.endsWith(rights))).toBe(true)
|
||||
|
||||
expect(restrictWindowsPathSync(dir, true)).toBe(true)
|
||||
|
||||
const after = readAclEntries(dir)
|
||||
expect(after, listed(after)).toHaveLength(3)
|
||||
expect(after.every((entry) => entry.endsWith(':(OI)(CI)(F)'))).toBe(true)
|
||||
|
||||
// The point of the (IO) case: before the repair the directory object grants nobody anything,
|
||||
// so Orca cannot write into the directory it just cached as hardened.
|
||||
const child = join(dir, 'child.json')
|
||||
expect(() => writeFileSync(child, '{}')).not.toThrow()
|
||||
expect(readAclEntries(child).every((entry) => entry.includes('(I)'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is idempotent: a second harden leaves the same DACL', () => {
|
||||
const file = join(root, 'idempotent.json')
|
||||
writeFileSync(file, '{}')
|
||||
|
||||
expect(restrictWindowsPathSync(file, false)).toBe(true)
|
||||
const first = readAclEntries(file)
|
||||
expect(restrictWindowsPathSync(file, false)).toBe(true)
|
||||
|
||||
expect(readAclEntries(file)).toEqual(first)
|
||||
})
|
||||
|
||||
/**
|
||||
* Verification writes a temp SDDL file. If it cannot, the ACL may well have been applied — but
|
||||
* it cannot be *proved*, so hardening must report failure rather than assume success. Fail
|
||||
* closed, and say so: a silently-unverifiable control is the shape of the original bug.
|
||||
*/
|
||||
it('reports failure, loudly, when verification cannot write its descriptor', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const file = join(root, 'unverifiable.json')
|
||||
writeFileSync(file, '{}')
|
||||
const realTemp = process.env.TEMP
|
||||
const realTmp = process.env.TMP
|
||||
// Point the descriptor save at a directory that cannot exist.
|
||||
process.env.TEMP = join(root, 'no-such-dir', 'nested')
|
||||
process.env.TMP = process.env.TEMP
|
||||
|
||||
try {
|
||||
expect(restrictWindowsPathSync(file, false)).toBe(false)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[secure-path.windows-acl] failed to restrict path',
|
||||
expect.objectContaining({ stage: 'verify' })
|
||||
)
|
||||
} finally {
|
||||
if (realTemp === undefined) {
|
||||
delete process.env.TEMP
|
||||
} else {
|
||||
process.env.TEMP = realTemp
|
||||
}
|
||||
if (realTmp === undefined) {
|
||||
delete process.env.TMP
|
||||
} else {
|
||||
process.env.TMP = realTmp
|
||||
}
|
||||
warn.mockRestore()
|
||||
}
|
||||
|
||||
// And the ACL itself was still applied, so the failure is a loss of proof, not of protection.
|
||||
expect(readAclEntries(file)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('reports failure for a path that does not exist', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
expect(restrictWindowsPathSync(join(root, 'absent.json'), false)).toBe(false)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[secure-path.windows-acl] failed to restrict path',
|
||||
expect.objectContaining({ stage: 'reset' })
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
/**
|
||||
* Skipped rather than branched: elevated, hardening *succeeds* here, so the case would assert
|
||||
* nothing and would instead rewrite `hosts`. `icacls /reset` is no undo — it drops all explicit
|
||||
* ACEs, and `hosts` ships with an explicit `NT AUTHORITY\SYSTEM:(F)`. Ephemeral on a CI runner;
|
||||
* permanent for anyone running this lane from an elevated shell. So: assert, or skip.
|
||||
*/
|
||||
it.skipIf(elevated)('reports failure for a path it has no permission to modify', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// Owned by TrustedInstaller; a non-elevated user cannot rewrite its DACL.
|
||||
const systemFile = windowsSystem32Binary('drivers\\etc\\hosts')
|
||||
|
||||
expect(restrictWindowsPathSync(systemFile, false)).toBe(false)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[secure-path.windows-acl] failed to restrict path',
|
||||
expect.objectContaining({ detail: expect.stringContaining('denied') })
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
/**
|
||||
* `void promise.then(onSettled)` attaches no rejection handler, so a throw from `onSettled` —
|
||||
* which runs *after* the promise resolved, outside every try/catch inside the apply — rejects a
|
||||
* promise nobody holds. Node's default turns that into a dead Electron main process, which
|
||||
* presents as an Orca crash rather than as the hardening problem it is.
|
||||
*/
|
||||
it('reports rather than crashes when the settlement callback throws', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
it('reports failure, not success, for a path that does not exist', () => {
|
||||
const reports: SecurePathHardeningReport[] = []
|
||||
setSecurePathHardeningReporter((entry) => reports.push(entry))
|
||||
const file = join(root, 'settle-throws.json')
|
||||
writeFileSync(file, '{}')
|
||||
|
||||
const unhandled: unknown[] = []
|
||||
const capture = (reason: unknown): void => {
|
||||
unhandled.push(reason)
|
||||
}
|
||||
process.on('unhandledRejection', capture)
|
||||
let settled = false
|
||||
try {
|
||||
bestEffortRestrictWindowsPath(file, false, () => {
|
||||
settled = true
|
||||
throw new Error('settlement callback exploded')
|
||||
})
|
||||
await vi.waitFor(() => expect(settled).toBe(true))
|
||||
// An unhandled rejection is raised a turn later, so give the loop one.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
} finally {
|
||||
process.off('unhandledRejection', capture)
|
||||
setSecurePathHardeningReporter(null)
|
||||
warn.mockRestore()
|
||||
}
|
||||
|
||||
expect(unhandled).toEqual([])
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
stage: 'settle',
|
||||
detail: expect.stringContaining('settlement callback exploded')
|
||||
})
|
||||
)
|
||||
expect(restrictWindowsPathSync(join(root, 'missing.json'), false)).toBe(false)
|
||||
expect(reports).toContainEqual(expect.objectContaining({ stage: 'apply' }))
|
||||
})
|
||||
|
||||
// The reporter is a caller-installed hook; one that throws must not take hardening down with it.
|
||||
it('keeps hardening alive when the installed reporter throws', () => {
|
||||
setSecurePathHardeningReporter(() => {
|
||||
throw new Error('caller bug')
|
||||
})
|
||||
|
||||
expect(restrictWindowsPathSync(join(root, 'missing-reporter.json'), false)).toBe(false)
|
||||
})
|
||||
|
||||
it('never exposes a staged or published credential under the relocated root', async () => {
|
||||
const dir = join(relocatedRoot, 'race')
|
||||
mkdirSync(dir)
|
||||
const target = join(dir, 'orca-e2ee-keypair.json')
|
||||
const observer = observeDacls(dir, 1500)
|
||||
await observer.ready
|
||||
|
||||
let restricted = 0
|
||||
const startedAt = performance.now()
|
||||
while (performance.now() - startedAt < 1000) {
|
||||
try {
|
||||
restricted += writeSecureFile(target, JSON.stringify({ secretKey: 'x'.repeat(64) })) ? 1 : 0
|
||||
} catch (error) {
|
||||
// The observer's own open handle can make a replace-rename fail; that is the racer, not us.
|
||||
expect(error).toMatchObject({ code: 'EPERM', syscall: 'rename' })
|
||||
}
|
||||
}
|
||||
const observed = await observer.result
|
||||
|
||||
expect(restricted).toBeGreaterThan(50)
|
||||
expect(observed.samples).toBeGreaterThan(50)
|
||||
const broad = Object.keys(observed.distinct).filter(
|
||||
(dacl) => findSecureDaclViolation(JSON.parse(dacl), sid, false) !== null
|
||||
)
|
||||
console.log(
|
||||
`[live-acl] ${restricted} writes, ${observed.samples} DACL samples, ` +
|
||||
`${Object.keys(observed.distinct).length} distinct, ${broad.length} broad`
|
||||
)
|
||||
expect(broad).toEqual([])
|
||||
expect(daclOf(target)?.protected).toBe(true)
|
||||
})
|
||||
|
||||
// A relock walks every descendant (~2 s on a 29k-entry userData), so it must never hold up a
|
||||
// write, must run once, and must not run again once the directory verifies.
|
||||
it('relocks a large relocated directory off the write path, exactly once', async () => {
|
||||
const dir = join(relocatedRoot, 'large')
|
||||
mkdirSync(dir)
|
||||
for (let index = 0; index < 4000; index++) {
|
||||
writeFileSync(join(dir, `cache-${index}`), 'x')
|
||||
}
|
||||
const writes = countingAddon()
|
||||
|
||||
const writeStartedAt = performance.now()
|
||||
expect(writeSecureFile(join(dir, 'secret.json'), '{}')).toBe(true)
|
||||
const writeMs = performance.now() - writeStartedAt
|
||||
writeSecureFile(join(dir, 'second.json'), '{}')
|
||||
|
||||
const relockStartedAt = performance.now()
|
||||
// The parent turns protected before propagation reaches its children, so wait on both ends.
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(daclOf(dir)?.protected).toBe(true)
|
||||
expect(sidsOf(join(dir, 'cache-0'))).toEqual(ORCA_PRINCIPALS())
|
||||
expect(sidsOf(join(dir, 'cache-3999'))).toEqual(ORCA_PRINCIPALS())
|
||||
},
|
||||
{ timeout: 60_000, interval: 20 }
|
||||
)
|
||||
const relockMs = performance.now() - relockStartedAt + writeMs
|
||||
console.log(
|
||||
`[live-acl] write ${writeMs.toFixed(1)}ms while relock of 4000 entries ~${relockMs.toFixed(0)}ms`
|
||||
)
|
||||
|
||||
expect(writes.asyncCount).toBe(1)
|
||||
|
||||
// A new process finds the directory already locked and leaves it alone.
|
||||
__resetSecureFileHardenedPathsForTests()
|
||||
const nextLaunch = countingAddon()
|
||||
writeSecureFile(join(dir, 'third.json'), '{}')
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(nextLaunch.asyncCount + nextLaunch.count).toBe(0)
|
||||
expect(readFileSync(join(dir, 'third.json'), 'utf-8')).toBe('{}')
|
||||
})
|
||||
|
||||
/** Installs the real addon behind a proxy that counts DACL rewrites. */
|
||||
function countingAddon(): { count: number; asyncCount: number } {
|
||||
const counts = { count: 0, asyncCount: 0 }
|
||||
setWindowsSecurityForTests({
|
||||
...addon,
|
||||
currentUserSid: () => sid,
|
||||
setDaclSddl: (path, sddl) => {
|
||||
counts.count += 1
|
||||
addon.setDaclSddl(path, sddl)
|
||||
},
|
||||
setDaclSddlAsync: (path, sddl) => {
|
||||
counts.asyncCount += 1
|
||||
return addon.setDaclSddlAsync(path, sddl)
|
||||
}
|
||||
})
|
||||
return counts
|
||||
}
|
||||
})
|
||||
|
||||
type ObservedDacls = { samples: number; distinct: Record<string, number> }
|
||||
|
||||
function observeDacls(
|
||||
dir: string,
|
||||
durationMs: number
|
||||
): { ready: Promise<void>; result: Promise<ObservedDacls> } {
|
||||
const worker = new Worker(
|
||||
`
|
||||
const { parentPort, workerData } = require('node:worker_threads')
|
||||
const { readdirSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const addon = require(workerData.addonPath)
|
||||
parentPort.postMessage('ready')
|
||||
const distinct = {}
|
||||
let samples = 0
|
||||
const deadline = Date.now() + workerData.durationMs
|
||||
while (Date.now() < deadline) {
|
||||
let names = []
|
||||
try { names = readdirSync(workerData.dir) } catch {}
|
||||
for (const name of names) {
|
||||
try {
|
||||
const sddl = JSON.stringify(addon.getDaclAces(join(workerData.dir, name)))
|
||||
samples += 1
|
||||
distinct[sddl] = (distinct[sddl] || 0) + 1
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
parentPort.postMessage({ samples, distinct })
|
||||
`,
|
||||
{
|
||||
eval: true,
|
||||
workerData: { dir, durationMs, addonPath: require.resolve('@orca/windows-security') }
|
||||
}
|
||||
)
|
||||
let markReady: () => void = () => undefined
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
markReady = resolve
|
||||
})
|
||||
const result = new Promise<ObservedDacls>((resolve, reject) => {
|
||||
worker.on('message', (message: unknown) => {
|
||||
if (message === 'ready') {
|
||||
markReady()
|
||||
return
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker above posts exactly this shape after 'ready'.
|
||||
resolve(message as ObservedDacls)
|
||||
void worker.terminate()
|
||||
})
|
||||
worker.on('error', reject)
|
||||
})
|
||||
return { ready, result }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { findSecureDaclViolation, hardenedDaclSddl } from './windows-secure-dacl'
|
||||
import { daclFromSddl } from './windows-security-addon.test-fixture'
|
||||
|
||||
const USER = 'S-1-5-21-432636774-4279371817-3971399515-1001'
|
||||
const fileViolation = (sddl: string) => findSecureDaclViolation(daclFromSddl(sddl), USER, false)
|
||||
const directoryViolation = (sddl: string) => findSecureDaclViolation(daclFromSddl(sddl), USER, true)
|
||||
|
||||
describe('findSecureDaclViolation', () => {
|
||||
it('accepts what Orca writes, for files and directories', () => {
|
||||
expect(fileViolation(hardenedDaclSddl(USER, false))).toBeNull()
|
||||
expect(directoryViolation(hardenedDaclSddl(USER, true))).toBeNull()
|
||||
})
|
||||
|
||||
// No migration: these are byte-for-byte what the icacls hardening left on this machine.
|
||||
it('accepts what the icacls-era hardening left on disk', () => {
|
||||
expect(fileViolation(`D:PAI(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;${USER})`)).toBeNull()
|
||||
expect(
|
||||
directoryViolation(`D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;${USER})`)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a protected DACL granting the user alone, and ignores deny rules', () => {
|
||||
expect(fileViolation(`D:P(A;;FA;;;${USER})`)).toBeNull()
|
||||
expect(fileViolation(`D:P(D;;FA;;;WD)(A;;FA;;;${USER})`)).toBeNull()
|
||||
expect(fileViolation(`D:P(A;;0x1f01ff;;;${USER})`)).toBeNull()
|
||||
expect(fileViolation(`D:P(A;;GA;;;${USER})`)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['explicit Everyone', `D:P(A;;FA;;;${USER})(A;;FR;;;WD)`, 'S-1-1-0'],
|
||||
[
|
||||
'inherited Authenticated Users (a relocated root)',
|
||||
`D:AI(A;ID;FA;;;${USER})(A;ID;0x1301bf;;;AU)`,
|
||||
'S-1-5-11'
|
||||
],
|
||||
[
|
||||
'inherit-only Users on a directory',
|
||||
`D:P(A;OICI;FA;;;${USER})(A;OICIIO;FR;;;BU)`,
|
||||
'S-1-5-32-545'
|
||||
]
|
||||
])('rejects %s', (_label, sddl, sid) => {
|
||||
expect(directoryViolation(sddl)).toBe(`unexpected rule for ${sid}`)
|
||||
})
|
||||
|
||||
// Object, callback and conditional ACEs can grant by rules this check cannot evaluate.
|
||||
it.each([
|
||||
['last', `D:P(A;;FA;;;SY)(A;;FA;;;${USER})(XA;;FA;;;WD;(@User.Department == "Finance"))`],
|
||||
['first', `D:P(XA;;FA;;;WD;(@User.Department == "Finance"))(A;;FA;;;SY)(A;;FA;;;${USER})`],
|
||||
['an object ACE', `D:P(A;;FA;;;${USER})(OA;;FR;;;WD)`]
|
||||
])('fails closed on an ACE it cannot evaluate: %s', (_label, sddl) => {
|
||||
expect(fileViolation(sddl)).toMatch(/unsupported ACE type/)
|
||||
})
|
||||
|
||||
// Loosening the parent reaches anything that still inherits, however tidy it looks today.
|
||||
it('requires protection, even when every inherited rule names an allowed principal', () => {
|
||||
expect(directoryViolation(`D:AI(A;OICIID;FA;;;SY)(A;OICIID;FA;;;${USER})`)).toMatch(
|
||||
/not protected/
|
||||
)
|
||||
expect(fileViolation(`D:AI(A;ID;FA;;;SY)(A;ID;FA;;;BA)(A;ID;FA;;;${USER})`)).toMatch(
|
||||
/not protected/
|
||||
)
|
||||
})
|
||||
|
||||
// An inherit-only rule grants the directory itself nothing, so Orca could not write into it.
|
||||
it('rejects a path the user cannot actually use', () => {
|
||||
const missing = `missing full control for ${USER}`
|
||||
expect(directoryViolation(`D:P(A;OICIIO;FA;;;${USER})(A;OICI;FA;;;SY)`)).toBe(missing)
|
||||
expect(directoryViolation(`D:P(A;;FA;;;${USER})`)).toBe(missing)
|
||||
expect(fileViolation(`D:P(A;;FR;;;${USER})`)).toBe(missing)
|
||||
})
|
||||
|
||||
it('fails closed on a NULL DACL, which grants everyone everything', () => {
|
||||
expect(findSecureDaclViolation(null, USER, false)).toMatch(/NULL DACL/)
|
||||
})
|
||||
|
||||
// The addon hands back raw SIDs, so the built-in Administrator (the CI runner) needs no alias.
|
||||
it('checks a RID-500 user by its raw SID', () => {
|
||||
const builtinAdmin = 'S-1-5-21-432636774-4279371817-3971399515-500'
|
||||
expect(
|
||||
findSecureDaclViolation(
|
||||
daclFromSddl(`D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;${builtinAdmin})`),
|
||||
builtinAdmin,
|
||||
true
|
||||
)
|
||||
).toBeNull()
|
||||
expect(fileViolation(`D:P(A;;FA;;;${builtinAdmin})(A;;FA;;;${USER})`)).toBe(
|
||||
`unexpected rule for ${builtinAdmin}`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { WINDOWS_FILE_ALL_ACCESS, type WindowsDacl } from './windows-security-addon'
|
||||
|
||||
/** SYSTEM and the local Administrators group: they can take ownership regardless, so denying them buys nothing. */
|
||||
export const LOCAL_SYSTEM_SID = 'S-1-5-18'
|
||||
export const BUILTIN_ADMINISTRATORS_SID = 'S-1-5-32-544'
|
||||
|
||||
export const ACCESS_ALLOWED_ACE_TYPE = 0
|
||||
export const ACCESS_DENIED_ACE_TYPE = 1
|
||||
export const OBJECT_INHERIT_ACE = 0x1
|
||||
export const CONTAINER_INHERIT_ACE = 0x2
|
||||
export const INHERIT_ONLY_ACE = 0x8
|
||||
const GENERIC_ALL = 0x10000000
|
||||
|
||||
/**
|
||||
* The DACL Orca installs on a secure path: protected, full control to the user, SYSTEM and
|
||||
* Administrators only. A directory's rules are inheritable so files created inside it start
|
||||
* restricted too.
|
||||
*/
|
||||
export function hardenedDaclSddl(userSid: string, isDirectory: boolean): string {
|
||||
const flags = isDirectory ? 'OICI' : ''
|
||||
return `D:P(A;${flags};FA;;;${LOCAL_SYSTEM_SID})(A;${flags};FA;;;${BUILTIN_ADMINISTRATORS_SID})(A;${flags};FA;;;${userSid})`
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenSSH's ACE rule (only the user, SYSTEM and Administrators may be allowed anything, inherited
|
||||
* or explicit) plus two of Orca's own: the DACL is protected, so loosening a parent cannot reach
|
||||
* the path, and the user can still use it. Returns why the DACL fails, or null when it passes.
|
||||
*
|
||||
* Why not "exactly the DACL Orca writes": what older builds wrote is just as private and must not
|
||||
* be rewritten.
|
||||
*/
|
||||
export function findSecureDaclViolation(
|
||||
dacl: WindowsDacl | null,
|
||||
userSid: string,
|
||||
isDirectory: boolean
|
||||
): string | null {
|
||||
if (!dacl) {
|
||||
return 'the path has a NULL DACL, which grants everyone everything'
|
||||
}
|
||||
// Object, callback and conditional ACEs can grant by rules this check cannot evaluate.
|
||||
const unsupported = dacl.aces.find(
|
||||
(ace) => ace.type !== ACCESS_ALLOWED_ACE_TYPE && ace.type !== ACCESS_DENIED_ACE_TYPE
|
||||
)
|
||||
if (unsupported) {
|
||||
return `unsupported ACE type ${unsupported.type}`
|
||||
}
|
||||
const allowed = new Set([userSid, LOCAL_SYSTEM_SID, BUILTIN_ADMINISTRATORS_SID])
|
||||
const stranger = dacl.aces.find(
|
||||
(ace) => ace.type === ACCESS_ALLOWED_ACE_TYPE && !allowed.has(ace.sid ?? '')
|
||||
)
|
||||
if (stranger) {
|
||||
return `unexpected rule for ${stranger.sid}`
|
||||
}
|
||||
if (!dacl.protected) {
|
||||
return 'DACL is not protected; the parent still propagates into it'
|
||||
}
|
||||
const inheritsToChildren = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
|
||||
// An inherit-only rule grants the directory itself nothing, so Orca could not write into it.
|
||||
const usable = dacl.aces.some(
|
||||
(ace) =>
|
||||
ace.type === ACCESS_ALLOWED_ACE_TYPE &&
|
||||
ace.sid === userSid &&
|
||||
(ace.flags & INHERIT_ONLY_ACE) === 0 &&
|
||||
((ace.mask & GENERIC_ALL) !== 0 ||
|
||||
(ace.mask & WINDOWS_FILE_ALL_ACCESS) === WINDOWS_FILE_ALL_ACCESS) &&
|
||||
(!isDirectory || (ace.flags & inheritsToChildren) === inheritsToChildren)
|
||||
)
|
||||
return usable ? null : `missing full control for ${userSid}`
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import type {
|
||||
GrantAccessOptions,
|
||||
WindowsAce,
|
||||
WindowsDacl,
|
||||
WindowsSecurityAddon
|
||||
} from './windows-security-addon'
|
||||
|
||||
export const FAKE_USER_SID = 'S-1-5-21-1-2-3-1001'
|
||||
|
||||
/** What an untouched path reads back as: inherited from a relocated root like `C:\orca-data`. */
|
||||
export const INHERITED_SDDL = `D:AI(A;ID;FA;;;SY)(A;ID;FA;;;BA)(A;ID;FA;;;${FAKE_USER_SID})(A;ID;0x1301bf;;;AU)`
|
||||
|
||||
const SID_ALIASES: Record<string, string> = {
|
||||
AC: 'S-1-15-2-1',
|
||||
AU: 'S-1-5-11',
|
||||
BA: 'S-1-5-32-544',
|
||||
BU: 'S-1-5-32-545',
|
||||
SY: 'S-1-5-18',
|
||||
WD: 'S-1-1-0'
|
||||
}
|
||||
const ACE_TYPES: Record<string, number> = { A: 0, D: 1, OA: 5, XA: 9 }
|
||||
const ACE_FLAGS: Record<string, number> = { OI: 0x1, CI: 0x2, NP: 0x4, IO: 0x8, ID: 0x10 }
|
||||
const RIGHTS: Record<string, number> = { FA: 0x1f01ff, FR: 0x120089, GA: 0x10000000 }
|
||||
|
||||
/**
|
||||
* Test-only: the structure the addon's getDaclAces returns for an SDDL fixture, so tests can keep
|
||||
* writing DACLs the way Windows prints them. Production never parses SDDL.
|
||||
*/
|
||||
export function daclFromSddl(sddl: string): WindowsDacl | null {
|
||||
if (sddl.includes('NO_ACCESS_CONTROL')) {
|
||||
return null
|
||||
}
|
||||
const match = /^D:([A-Z]*)(.*)$/.exec(sddl)
|
||||
if (!match) {
|
||||
throw new Error(`fixture SDDL is not a DACL: ${sddl}`)
|
||||
}
|
||||
const aces: WindowsAce[] = []
|
||||
// A conditional ACE's trailing expression holds parentheses; only its type matters here.
|
||||
for (const [, body] of match[2]!.matchAll(/\(((?:[^()]|\([^()]*\))*)\)/g)) {
|
||||
const [type = '', flags = '', rights = '', , , sid = ''] = body!.split(';')
|
||||
const plain = type === 'A' || type === 'D'
|
||||
aces.push({
|
||||
type: ACE_TYPES[type] ?? 0xff,
|
||||
flags: (flags.match(/../g) ?? []).reduce((all, token) => all | (ACE_FLAGS[token] ?? 0), 0),
|
||||
mask: RIGHTS[rights] ?? (Number.parseInt(rights, 16) || 0),
|
||||
sid: plain ? (SID_ALIASES[sid] ?? sid) : null
|
||||
})
|
||||
}
|
||||
return { protected: match[1]!.includes('P'), aces }
|
||||
}
|
||||
|
||||
export type FakeWindowsSecurityCall =
|
||||
| { op: 'get' | 'set' | 'setAsync'; path: string; addonPath: string; sddl?: string }
|
||||
| { op: 'create'; path: string; addonPath: string; sddl: string }
|
||||
| {
|
||||
op: 'grant' | 'grantAsync'
|
||||
path: string
|
||||
addonPath: string
|
||||
sid: string
|
||||
mask: number
|
||||
options: GrantAccessOptions
|
||||
}
|
||||
|
||||
export type FakeWindowsSecurity = WindowsSecurityAddon & {
|
||||
calls: FakeWindowsSecurityCall[]
|
||||
/** The SDDL each path reads back as; absent paths read as INHERITED_SDDL. */
|
||||
dacls: Map<string, string>
|
||||
/** Paths whose writes (set, create, grant) throw. */
|
||||
failing: Set<string>
|
||||
/** When set, setDaclSddlAsync parks here instead of settling. */
|
||||
holdAsync: boolean
|
||||
/** Settles every parked setDaclSddlAsync. */
|
||||
releaseAsync: () => void
|
||||
callsFor: (op: FakeWindowsSecurityCall['op'], path?: string) => FakeWindowsSecurityCall[]
|
||||
}
|
||||
|
||||
function win32Error(syscall: string): Error {
|
||||
return new Error(`${syscall} failed with Win32 error 5`)
|
||||
}
|
||||
|
||||
/** The path the test used: the addon wrapper namespaces (`\\?\`) every path on Windows. */
|
||||
function plainPath(addonPath: string): string {
|
||||
if (addonPath.startsWith('\\\\?\\UNC\\')) {
|
||||
return `\\\\${addonPath.slice(8)}`
|
||||
}
|
||||
return addonPath.startsWith('\\\\?\\') ? addonPath.slice(4) : addonPath
|
||||
}
|
||||
|
||||
/** An in-memory stand-in for native/windows-security; files it creates are real. */
|
||||
export function createFakeWindowsSecurity(): FakeWindowsSecurity {
|
||||
const parked: (() => void)[] = []
|
||||
const record = <T extends FakeWindowsSecurityCall['op']>(
|
||||
op: T,
|
||||
addonPath: string,
|
||||
rest: Record<string, unknown> = {}
|
||||
): string => {
|
||||
const path = plainPath(addonPath)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller passes exactly the fields its op's union member declares.
|
||||
fake.calls.push({ op, path, addonPath, ...rest } as FakeWindowsSecurityCall)
|
||||
return path
|
||||
}
|
||||
const fake: FakeWindowsSecurity = {
|
||||
calls: [],
|
||||
dacls: new Map(),
|
||||
failing: new Set(),
|
||||
holdAsync: false,
|
||||
releaseAsync: () => parked.splice(0).forEach((settle) => settle()),
|
||||
callsFor: (op, path) =>
|
||||
fake.calls.filter((call) => call.op === op && (path === undefined || call.path === path)),
|
||||
currentUserSid: () => FAKE_USER_SID,
|
||||
getDaclAces: (addonPath) => {
|
||||
const path = record('get', addonPath)
|
||||
return daclFromSddl(fake.dacls.get(path) ?? INHERITED_SDDL)
|
||||
},
|
||||
setDaclSddl: (addonPath, sddl) => {
|
||||
const path = record('set', addonPath, { sddl })
|
||||
if (fake.failing.has(path)) {
|
||||
throw win32Error('SetNamedSecurityInfoW')
|
||||
}
|
||||
fake.dacls.set(path, sddl)
|
||||
},
|
||||
setDaclSddlAsync: (addonPath, sddl) => {
|
||||
const path = record('setAsync', addonPath, { sddl })
|
||||
const settle = (resolve: () => void, reject: (error: Error) => void): void => {
|
||||
if (fake.failing.has(path)) {
|
||||
reject(win32Error('SetNamedSecurityInfoW'))
|
||||
return
|
||||
}
|
||||
fake.dacls.set(path, sddl)
|
||||
resolve()
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (fake.holdAsync) {
|
||||
parked.push(() => settle(resolve, reject))
|
||||
return
|
||||
}
|
||||
settle(resolve, reject)
|
||||
})
|
||||
},
|
||||
createFileWithDacl: (addonPath, sddl, data) => {
|
||||
const path = record('create', addonPath, { sddl })
|
||||
if (fake.failing.has(path)) {
|
||||
throw win32Error('CreateFileW')
|
||||
}
|
||||
writeFileSync(addonPath, data, { flag: 'wx' })
|
||||
fake.dacls.set(path, sddl)
|
||||
},
|
||||
grantAccess: (addonPath, sid, mask, options) => {
|
||||
const path = record('grant', addonPath, { sid, mask, options })
|
||||
if (fake.failing.has(path)) {
|
||||
throw win32Error('SetNamedSecurityInfoW')
|
||||
}
|
||||
},
|
||||
grantAccessAsync: (addonPath, sid, mask, options) => {
|
||||
const path = record('grantAsync', addonPath, { sid, mask, options })
|
||||
return fake.failing.has(path)
|
||||
? Promise.reject(win32Error('SetNamedSecurityInfoW'))
|
||||
: Promise.resolve()
|
||||
}
|
||||
}
|
||||
return fake
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { trustedPackagePath } from './windows-security-addon'
|
||||
|
||||
describe('trustedPackagePath', () => {
|
||||
const roots: string[] = []
|
||||
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })))
|
||||
|
||||
function layout(): { app: string; inside: string; beside: string; above: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-addon-trust-'))
|
||||
roots.push(root)
|
||||
const file = (path: string) => {
|
||||
mkdirSync(join(path, '..'), { recursive: true })
|
||||
writeFileSync(path, '')
|
||||
return path
|
||||
}
|
||||
return {
|
||||
app: join(root, 'app'),
|
||||
inside: file(join(root, 'app', 'node_modules', '@orca', 'windows-security', 'index.js')),
|
||||
// A sibling whose name merely starts with the app's must not count as inside it.
|
||||
beside: file(join(root, 'app-evil', 'node_modules', '@orca', 'windows-security', 'index.js')),
|
||||
// What a bare specifier finds by walking past the app, e.g. C:\node_modules on a shared host.
|
||||
above: file(join(root, 'node_modules', '@orca', 'windows-security', 'index.js'))
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts the package where the app ships it', () => {
|
||||
const { app, inside } = layout()
|
||||
expect(trustedPackagePath(inside, [app])).toContain('windows-security')
|
||||
})
|
||||
|
||||
it('refuses a resolution outside every app root', () => {
|
||||
const { app, beside, above } = layout()
|
||||
expect(() => trustedPackagePath(above, [app])).toThrow(/outside the app/)
|
||||
expect(() => trustedPackagePath(beside, [app])).toThrow(/outside the app/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { basename, isAbsolute, join, relative, toNamespacedPath } from 'node:path'
|
||||
import type * as WindowsSecurityModule from '@orca/windows-security'
|
||||
import { ORCAD_WINDOWS_SECURITY_FILENAME } from './orcad-artifacts'
|
||||
|
||||
export type { GrantAccessOptions, WindowsAce, WindowsDacl } from '@orca/windows-security'
|
||||
export type WindowsSecurityAddon = typeof WindowsSecurityModule
|
||||
|
||||
/** The addon (every path namespaced, async writes ordered per path) and the token's user SID. */
|
||||
export type WindowsSecurity = {
|
||||
addon: Omit<WindowsSecurityAddon, 'currentUserSid'>
|
||||
userSid: string
|
||||
}
|
||||
|
||||
/** FILE_ALL_ACCESS, what SDDL prints as `FA`. */
|
||||
export const WINDOWS_FILE_ALL_ACCESS = 0x1f01ff
|
||||
/** FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, what icacls calls `(RX)`. */
|
||||
export const WINDOWS_FILE_READ_EXECUTE = 0x1200a9
|
||||
|
||||
const requireFromHere = createRequire(__filename)
|
||||
|
||||
let loaded: WindowsSecurity | { error: string } | null = null
|
||||
|
||||
/**
|
||||
* The addon, or why it cannot load on this host. orcad ships the bare binary at its slot root,
|
||||
* beside its server bundles and two levels above its CLI bundle (`out/cli/index.js`). Desktop and
|
||||
* the CLI load the workspace package, but only from inside the app: a bare specifier otherwise
|
||||
* walks every parent up to `C:\node_modules`, which any local user may create.
|
||||
*/
|
||||
export function loadWindowsSecurity(): WindowsSecurity | { error: string } {
|
||||
loaded ??= loadOnce()
|
||||
return loaded
|
||||
}
|
||||
|
||||
function loadOnce(): WindowsSecurity | { error: string } {
|
||||
if (process.platform !== 'win32') {
|
||||
return { error: `unsupported on ${process.platform}` }
|
||||
}
|
||||
const failures: string[] = []
|
||||
const candidates = [
|
||||
() => join(__dirname, ORCAD_WINDOWS_SECURITY_FILENAME),
|
||||
() => join(__dirname, '..', '..', ORCAD_WINDOWS_SECURITY_FILENAME),
|
||||
() => trustedPackagePath(requireFromHere.resolve('@orca/windows-security'), appRoots())
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
// Reading the SID is also what makes the package load its deferred .node binary.
|
||||
return prepare(requireFromHere(candidate()))
|
||||
} catch (error) {
|
||||
failures.push(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
return { error: failures.join('; ') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the app's own node_modules live: the packaged resources directory, or this checkout
|
||||
* (electron-vite puts shared code one level deeper, in `out/main/chunks`).
|
||||
*/
|
||||
function appRoots(): string[] {
|
||||
const checkout =
|
||||
basename(__dirname) === 'chunks'
|
||||
? join(__dirname, '..', '..', '..')
|
||||
: join(__dirname, '..', '..')
|
||||
// Electron only, so not on Node's Process type; the CLI runs under Electron as Node too.
|
||||
return 'resourcesPath' in process && typeof process.resourcesPath === 'string'
|
||||
? [process.resourcesPath, checkout]
|
||||
: [checkout]
|
||||
}
|
||||
|
||||
/** `resolved` when its real location is inside one of `roots`; throws otherwise. */
|
||||
export function trustedPackagePath(resolved: string, roots: readonly string[]): string {
|
||||
const real = realpathSync(resolved)
|
||||
const inside = roots.some((root) => {
|
||||
const fromRoot = relative(realpathSync(root), real)
|
||||
return fromRoot !== '' && !fromRoot.startsWith('..') && !isAbsolute(fromRoot)
|
||||
})
|
||||
if (!inside) {
|
||||
throw new Error(`refusing @orca/windows-security outside the app: ${real}`)
|
||||
}
|
||||
return real
|
||||
}
|
||||
|
||||
/**
|
||||
* Every path is namespaced (`\\?\`) once, here: the Win32 security APIs are MAX_PATH-limited.
|
||||
*
|
||||
* Async DACL writes to one path run in order. A grant reads the DACL and writes back a merge, so
|
||||
* one racing a re-lock on another pool thread could land last and restore the broader DACL. A
|
||||
* synchronous grantDirAcl is not ordered against them, but it runs to completion on the main
|
||||
* thread, so restrictAsync's post-apply verify (a later main-thread continuation) sees its result:
|
||||
* a resurrected broad DACL fails that verify, which evicts the directory to retry under the budget.
|
||||
*/
|
||||
function prepare(raw: WindowsSecurityAddon): WindowsSecurity {
|
||||
const writes = new Map<string, Promise<unknown>>()
|
||||
const inPathOrder = (path: string, write: (namespaced: string) => Promise<void>) => {
|
||||
const namespaced = toNamespacedPath(path)
|
||||
const key = namespaced.toLowerCase()
|
||||
const run = () => write(namespaced)
|
||||
const next = (writes.get(key) ?? Promise.resolve()).then(run, run)
|
||||
const settled = next.catch(() => undefined)
|
||||
writes.set(key, settled)
|
||||
void settled.then(() => {
|
||||
if (writes.get(key) === settled) {
|
||||
writes.delete(key)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
return {
|
||||
userSid: raw.currentUserSid(),
|
||||
addon: {
|
||||
getDaclAces: (path) => raw.getDaclAces(toNamespacedPath(path)),
|
||||
setDaclSddl: (path, sddl) => raw.setDaclSddl(toNamespacedPath(path), sddl),
|
||||
setDaclSddlAsync: (path, sddl) =>
|
||||
inPathOrder(path, (namespaced) => raw.setDaclSddlAsync(namespaced, sddl)),
|
||||
createFileWithDacl: (path, sddl, data) =>
|
||||
raw.createFileWithDacl(toNamespacedPath(path), sddl, data),
|
||||
grantAccess: (path, sid, mask, options) =>
|
||||
raw.grantAccess(toNamespacedPath(path), sid, mask, options),
|
||||
grantAccessAsync: (path, sid, mask, options) =>
|
||||
inPathOrder(path, (namespaced) => raw.grantAccessAsync(namespaced, sid, mask, options))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs `addon` (a fake, or null for "cannot load") behind the same wrapping as the real one. */
|
||||
export function setWindowsSecurityForTests(addon: WindowsSecurityAddon | null): void {
|
||||
loaded = addon ? prepare(addon) : { error: 'disabled for tests' }
|
||||
}
|
||||
|
||||
/** Forgets the loaded addon and its SID. */
|
||||
export function resetWindowsSecurityLoadForTests(): void {
|
||||
loaded = null
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ACCESS_ALLOWED_ACE_TYPE,
|
||||
ACCESS_DENIED_ACE_TYPE,
|
||||
CONTAINER_INHERIT_ACE,
|
||||
OBJECT_INHERIT_ACE
|
||||
} from './windows-secure-dacl'
|
||||
import {
|
||||
loadWindowsSecurity,
|
||||
WINDOWS_FILE_ALL_ACCESS,
|
||||
type WindowsSecurity
|
||||
} from './windows-security-addon'
|
||||
|
||||
const INHERITED_ACE = 0x10
|
||||
import { removeTreeSync } from './windows-transient-lock-removal'
|
||||
|
||||
/**
|
||||
* The addon is Orca's own C++, so nothing upstream proves it moves DACLs the way the secure-file
|
||||
* policy assumes. These read every result back off a real NTFS volume.
|
||||
*/
|
||||
const describeOnWindows = process.platform === 'win32' ? describe : describe.skip
|
||||
|
||||
describeOnWindows('native windows-security addon against a real filesystem', () => {
|
||||
let addon: WindowsSecurity['addon']
|
||||
let sid: string
|
||||
let root: string
|
||||
|
||||
beforeAll(() => {
|
||||
const loaded = loadWindowsSecurity()
|
||||
if ('error' in loaded) {
|
||||
throw new Error(`the windows-security addon did not load: ${loaded.error}`)
|
||||
}
|
||||
addon = loaded.addon
|
||||
sid = loaded.userSid
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-windows-security-'))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
removeTreeSync(root)
|
||||
})
|
||||
|
||||
it('reports the token user as a domain-relative SID this process can use', () => {
|
||||
// S-1-5-21: a local or AD account; S-1-12-1: an Entra ID account.
|
||||
expect(sid).toMatch(/^S-1-(?:5-21|12-1)(?:-\d+){4}$/)
|
||||
const path = join(root, 'only-me.txt')
|
||||
addon.createFileWithDacl(path, `D:P(A;;FA;;;${sid})`, Buffer.from('mine'))
|
||||
// A DACL naming only that SID still lets this process read: it is our identity.
|
||||
expect(readFileSync(path, 'utf-8')).toBe('mine')
|
||||
})
|
||||
|
||||
it('creates a file that is restricted from its first instant, contents intact', () => {
|
||||
const path = join(root, 'born.json')
|
||||
addon.createFileWithDacl(path, `D:P(A;;FA;;;SY)(A;;FA;;;${sid})`, Buffer.from('{"k":1}'))
|
||||
|
||||
expect(readFileSync(path, 'utf-8')).toBe('{"k":1}')
|
||||
const dacl = addon.getDaclAces(path)
|
||||
expect(dacl?.protected).toBe(true)
|
||||
expect(dacl?.aces.map((ace) => [ace.type, ace.sid, ace.flags, ace.mask])).toEqual([
|
||||
[ACCESS_ALLOWED_ACE_TYPE, 'S-1-5-18', 0, WINDOWS_FILE_ALL_ACCESS],
|
||||
[ACCESS_ALLOWED_ACE_TYPE, sid, 0, WINDOWS_FILE_ALL_ACCESS]
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses to adopt an existing file, whose own DACL would survive', () => {
|
||||
const path = join(root, 'existing.json')
|
||||
writeFileSync(path, 'old')
|
||||
expect(() => addon.createFileWithDacl(path, `D:P(A;;FA;;;${sid})`, Buffer.from('new'))).toThrow(
|
||||
'CreateFileW failed with Win32 error 80'
|
||||
)
|
||||
expect(readFileSync(path, 'utf-8')).toBe('old')
|
||||
})
|
||||
|
||||
it('surfaces Win32 failures with their code instead of crashing', () => {
|
||||
expect(() => addon.getDaclAces(join(root, 'missing'))).toThrow(
|
||||
'GetNamedSecurityInfoW failed with Win32 error 2'
|
||||
)
|
||||
expect(() => addon.setDaclSddl(join(root, 'missing'), `D:P(A;;FA;;;${sid})`)).toThrow(
|
||||
'Win32 error 2'
|
||||
)
|
||||
// A missing DACL means "everyone, full control"; the addon must never install one.
|
||||
const path = join(root, 'null-dacl.txt')
|
||||
writeFileSync(path, '')
|
||||
expect(() => addon.setDaclSddl(path, 'O:BA')).toThrow('Win32 error 1338')
|
||||
})
|
||||
|
||||
// Structure, not policy: anything but a plain allow or deny comes back without a SID, so the
|
||||
// caller can refuse what it cannot evaluate.
|
||||
it('reads a conditional ACE back as an unsupported type with no SID', () => {
|
||||
const path = join(root, 'conditional.txt')
|
||||
writeFileSync(path, '')
|
||||
addon.setDaclSddl(path, `D:P(A;;FA;;;${sid})(XA;;FA;;;WD;(Member_of {SID(WD)}))`)
|
||||
|
||||
const aces = addon.getDaclAces(path)?.aces ?? []
|
||||
expect(aces[0]).toMatchObject({ type: ACCESS_ALLOWED_ACE_TYPE, sid })
|
||||
expect(aces[1]).toMatchObject({ sid: null })
|
||||
expect([ACCESS_ALLOWED_ACE_TYPE, ACCESS_DENIED_ACE_TYPE]).not.toContain(aces[1]?.type)
|
||||
})
|
||||
|
||||
// Why it matters: hardening passes only explicit rules, and must never smuggle in a
|
||||
// parent's broader grant by echoing an inherited ACE back.
|
||||
it('drops inherited ACEs supplied to setDaclSddl and recomputes them from the parent', () => {
|
||||
const child = join(root, 'inheriting')
|
||||
mkdirSync(child)
|
||||
addon.setDaclSddl(child, `D:(A;OICI;FA;;;${sid})(A;OICIID;FA;;;WD)`)
|
||||
|
||||
const dacl = addon.getDaclAces(child)
|
||||
expect(dacl?.aces.some((ace) => ace.sid === 'S-1-1-0')).toBe(false)
|
||||
expect(dacl?.aces.some((ace) => ace.sid === sid && (ace.flags & INHERITED_ACE) === 0)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps canonical order when a grant lands beside an explicit deny', () => {
|
||||
const path = join(root, 'canonical.txt')
|
||||
writeFileSync(path, '')
|
||||
addon.setDaclSddl(path, 'D:AI(D;;FA;;;BG)(A;;FA;;;SY)')
|
||||
|
||||
addon.grantAccess(path, sid, WINDOWS_FILE_ALL_ACCESS, { inheritable: false, replace: true })
|
||||
|
||||
const aces = addon.getDaclAces(path)?.aces ?? []
|
||||
const kinds = aces.map((ace) =>
|
||||
(ace.flags & INHERITED_ACE) !== 0
|
||||
? 'inherited'
|
||||
: ace.type === ACCESS_DENIED_ACE_TYPE
|
||||
? 'deny'
|
||||
: 'allow'
|
||||
)
|
||||
// Explicit deny, then explicit allows, then inherited: the order access checks rely on.
|
||||
expect(kinds.indexOf('deny')).toBe(0)
|
||||
expect(kinds.lastIndexOf('allow')).toBeLessThan(kinds.indexOf('inherited'))
|
||||
expect(aces).toContainEqual(
|
||||
expect.objectContaining({ type: ACCESS_ALLOWED_ACE_TYPE, sid, flags: 0 })
|
||||
)
|
||||
})
|
||||
|
||||
it('merges a grant without disturbing other principals or the protection state', () => {
|
||||
const path = join(root, 'merged.txt')
|
||||
writeFileSync(path, '')
|
||||
addon.setDaclSddl(path, `D:P(A;;FA;;;SY)(A;;FA;;;${sid})`)
|
||||
|
||||
addon.grantAccess(path, 'S-1-15-2-2', 0x1200a9, { inheritable: false, replace: false })
|
||||
|
||||
const dacl = addon.getDaclAces(path)
|
||||
expect(dacl?.protected).toBe(true)
|
||||
expect(dacl?.aces.map((ace) => ace.sid).sort()).toEqual([sid, 'S-1-15-2-2', 'S-1-5-18'].sort())
|
||||
})
|
||||
|
||||
// A NULL DACL already allows everyone; merging a grant into it would revoke every other principal.
|
||||
it('leaves a NULL DACL as it is when granting', () => {
|
||||
const path = join(root, 'null-dacl-grant.txt')
|
||||
addon.createFileWithDacl(path, 'D:NO_ACCESS_CONTROL', Buffer.from(''))
|
||||
expect(addon.getDaclAces(path)).toBeNull()
|
||||
|
||||
addon.grantAccess(path, sid, WINDOWS_FILE_ALL_ACCESS, { inheritable: false, replace: true })
|
||||
|
||||
expect(addon.getDaclAces(path)).toBeNull()
|
||||
})
|
||||
|
||||
it('reaches a path longer than MAX_PATH', () => {
|
||||
let dir = join(root, 'long')
|
||||
while (dir.length < 280) {
|
||||
dir = join(dir, 'x'.repeat(40))
|
||||
}
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const path = join(dir, 'secret.json')
|
||||
addon.createFileWithDacl(path, `D:P(A;;FA;;;${sid})`, Buffer.from('{}'))
|
||||
|
||||
addon.grantAccess(path, 'S-1-5-18', WINDOWS_FILE_ALL_ACCESS, {
|
||||
inheritable: false,
|
||||
replace: false
|
||||
})
|
||||
|
||||
expect(
|
||||
addon
|
||||
.getDaclAces(path)
|
||||
?.aces.map((ace) => ace.sid)
|
||||
.sort()
|
||||
).toEqual(['S-1-5-18', sid].sort())
|
||||
})
|
||||
|
||||
it('runs DACL writes on the thread pool and rejects with the Win32 code', async () => {
|
||||
const dir = join(root, 'async')
|
||||
mkdirSync(dir)
|
||||
await addon.setDaclSddlAsync(dir, `D:P(A;OICI;FA;;;${sid})`)
|
||||
expect(addon.getDaclAces(dir)?.protected).toBe(true)
|
||||
|
||||
await addon.grantAccessAsync(dir, 'S-1-5-18', WINDOWS_FILE_ALL_ACCESS, {
|
||||
inheritable: true,
|
||||
replace: true
|
||||
})
|
||||
expect(addon.getDaclAces(dir)?.aces).toContainEqual(
|
||||
expect.objectContaining({
|
||||
sid: 'S-1-5-18',
|
||||
flags: OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE,
|
||||
mask: WINDOWS_FILE_ALL_ACCESS
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
addon.setDaclSddlAsync(join(root, 'missing-dir'), `D:P(A;;FA;;;${sid})`)
|
||||
).rejects.toThrow('Win32 error 2')
|
||||
})
|
||||
})
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { localDomainSidOf, parseSddlDacl, resolveSddlSid } from './windows-security-descriptor'
|
||||
|
||||
const MACHINE_SID = 'S-1-5-21-432636774-4279371817-3971399515'
|
||||
const USER_SID = `${MACHINE_SID}-1001`
|
||||
/** The built-in Administrator: the account a CI runner and an Administrator-only box log in as. */
|
||||
const LOCAL_ADMIN_SID = `${MACHINE_SID}-500`
|
||||
|
||||
describe('parseSddlDacl', () => {
|
||||
it('reads a hardened file descriptor as icacls /save emits it', () => {
|
||||
const dacl = parseSddlDacl(`D:PAI(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;${USER_SID})`)
|
||||
|
||||
expect(dacl?.isProtected).toBe(true)
|
||||
expect(dacl?.aces).toEqual([
|
||||
{ type: 'A', flags: [], rights: 'FA', sid: 'S-1-5-32-544' },
|
||||
{ type: 'A', flags: [], rights: 'FA', sid: 'S-1-5-18' },
|
||||
{ type: 'A', flags: [], rights: 'FA', sid: USER_SID }
|
||||
])
|
||||
})
|
||||
|
||||
it('reads the inheritable flags a hardened directory carries', () => {
|
||||
const dacl = parseSddlDacl('D:PAI(A;OICI;FA;;;SY)')
|
||||
|
||||
expect(dacl?.aces[0]!.flags).toEqual(['OI', 'CI'])
|
||||
})
|
||||
|
||||
it('reports an unprotected descriptor and its inherited rules', () => {
|
||||
const dacl = parseSddlDacl(`D:(A;ID;FA;;;SY)(A;ID;FA;;;${USER_SID})`)
|
||||
|
||||
expect(dacl?.isProtected).toBe(false)
|
||||
expect(dacl?.aces.map((ace) => ace.flags)).toEqual([['ID'], ['ID']])
|
||||
})
|
||||
|
||||
// `OICIID` is three tokens, not a string to search: a substring test for 'ID' would also fire on
|
||||
// flag runs that merely happen to contain those letters in sequence.
|
||||
it('splits a flag run into two-letter tokens', () => {
|
||||
expect(parseSddlDacl('D:P(A;OICIID;FA;;;SY)')?.aces[0]!.flags).toEqual(['OI', 'CI', 'ID'])
|
||||
expect(parseSddlDacl('D:P(A;OICI;FA;;;SY)')?.aces[0]!.flags).not.toContain('ID')
|
||||
})
|
||||
|
||||
it('stops at a trailing SACL rather than absorbing its entries', () => {
|
||||
const dacl = parseSddlDacl('D:P(A;;FA;;;SY)S:AI(AU;SAFA;FA;;;WD)')
|
||||
|
||||
expect(dacl?.aces).toHaveLength(1)
|
||||
expect(dacl?.aces[0]!.sid).toBe('S-1-5-18')
|
||||
})
|
||||
|
||||
it('finds the DACL after an owner and group whose aliases end in D', () => {
|
||||
const dacl = parseSddlDacl('O:WDG:WDD:P(A;;FA;;;SY)')
|
||||
|
||||
expect(dacl?.isProtected).toBe(true)
|
||||
expect(dacl?.aces[0]!.sid).toBe('S-1-5-18')
|
||||
})
|
||||
|
||||
it('returns null when there is no DACL', () => {
|
||||
expect(parseSddlDacl('O:BAG:BA')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a truncated ACE rather than guessing its fields', () => {
|
||||
expect(parseSddlDacl('D:P(A;;FA)')).toBeNull()
|
||||
})
|
||||
|
||||
/**
|
||||
* The DACL a box whose user *is* the built-in Administrator reads back: icacls writes `LA`
|
||||
* where it wrote the raw SID for any other account, so identity checking has to resolve it or
|
||||
* the path it just hardened verifies as wrong and hardening reports failure forever.
|
||||
*/
|
||||
it('resolves the built-in Administrator alias against the machine SID', () => {
|
||||
const dacl = parseSddlDacl(`D:PAI(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;LA)`, MACHINE_SID)
|
||||
|
||||
expect(dacl?.aces.map((ace) => ace.sid)).toEqual(['S-1-5-32-544', 'S-1-5-18', LOCAL_ADMIN_SID])
|
||||
})
|
||||
|
||||
it('leaves the alias unresolved when no machine SID is known, so it matches nothing', () => {
|
||||
const dacl = parseSddlDacl('D:PAI(A;;FA;;;LA)')
|
||||
|
||||
expect(dacl?.aces[0]!.sid).toBe('LA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSddlSid', () => {
|
||||
it('resolves the aliases icacls substitutes for well-known SIDs', () => {
|
||||
expect(resolveSddlSid('WD')).toBe('S-1-1-0')
|
||||
expect(resolveSddlSid('BA')).toBe('S-1-5-32-544')
|
||||
expect(resolveSddlSid('SY')).toBe('S-1-5-18')
|
||||
expect(resolveSddlSid('AU')).toBe('S-1-5-11')
|
||||
})
|
||||
|
||||
it('passes a raw SID through unchanged', () => {
|
||||
expect(resolveSddlSid(USER_SID)).toBe(USER_SID)
|
||||
})
|
||||
|
||||
// An unknown alias must not silently compare equal to anything expected.
|
||||
it('passes an unrecognized alias through instead of dropping it', () => {
|
||||
expect(resolveSddlSid('ZZ')).toBe('ZZ')
|
||||
})
|
||||
|
||||
// No fixed table can hold these: the SID they name is built from the machine's own.
|
||||
it('builds the machine-relative aliases from the supplied machine SID', () => {
|
||||
expect(resolveSddlSid('LA', MACHINE_SID)).toBe(LOCAL_ADMIN_SID)
|
||||
expect(resolveSddlSid('LG', MACHINE_SID)).toBe(`${MACHINE_SID}-501`)
|
||||
})
|
||||
|
||||
it('keeps a machine-relative alias unresolved without a machine SID', () => {
|
||||
expect(resolveSddlSid('LA')).toBe('LA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('localDomainSidOf', () => {
|
||||
it('strips the RID off a domain-relative account SID', () => {
|
||||
expect(localDomainSidOf(USER_SID)).toBe(MACHINE_SID)
|
||||
expect(localDomainSidOf(LOCAL_ADMIN_SID)).toBe(MACHINE_SID)
|
||||
})
|
||||
|
||||
// SYSTEM and the built-in groups carry no machine authority to resolve `LA` against.
|
||||
it('returns null for SIDs that are not domain-relative', () => {
|
||||
expect(localDomainSidOf('S-1-5-18')).toBeNull()
|
||||
expect(localDomainSidOf('S-1-5-32-544')).toBeNull()
|
||||
expect(localDomainSidOf('S-1-1-0')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Parses the DACL out of an SDDL string, as produced by `icacls <path> /save`.
|
||||
*
|
||||
* Why SDDL rather than the human-readable `icacls <path>` listing: the listing prints resolved
|
||||
* account *names*, which are localized and cannot be compared against a SID. Checking only the
|
||||
* shape of that listing — rule count, inheritance marker, rights — accepts a DACL that grants
|
||||
* full control to the wrong principal entirely. SDDL carries raw SIDs, so identity is checkable.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Aliases naming an account by RID within the machine's *own* SID, so no constant can hold them:
|
||||
* the built-in Administrator is `S-1-5-21-<machine>-500`, and icacls prints `LA` for it. A box
|
||||
* whose interactive user is that account (a CI runner, an Administrator-only install) therefore
|
||||
* reads back an ACE no fixed table can match.
|
||||
*/
|
||||
const LOCAL_DOMAIN_RELATIVE_RIDS: Record<string, number> = {
|
||||
LA: 500,
|
||||
LG: 501
|
||||
}
|
||||
|
||||
/** `S-1-5-21-x-y-z-<rid>` split into the machine/domain authority and its RID. */
|
||||
const DOMAIN_RELATIVE_SID_PATTERN = /^(S-1-5-21(?:-\d+){3})-\d+$/
|
||||
|
||||
/**
|
||||
* The machine/domain authority an account SID belongs to, or null when the SID is not
|
||||
* domain-relative (`S-1-5-18` and the `S-1-5-32-*` built-ins never are).
|
||||
*/
|
||||
export function localDomainSidOf(accountSid: string): string | null {
|
||||
return DOMAIN_RELATIVE_SID_PATTERN.exec(accountSid.toUpperCase())?.[1] ?? null
|
||||
}
|
||||
|
||||
/** The two-letter aliases SDDL substitutes for well-known SIDs. */
|
||||
const SDDL_SID_ALIASES: Record<string, string> = {
|
||||
AN: 'S-1-5-7',
|
||||
AU: 'S-1-5-11',
|
||||
BA: 'S-1-5-32-544',
|
||||
BG: 'S-1-5-32-546',
|
||||
BU: 'S-1-5-32-545',
|
||||
IU: 'S-1-5-4',
|
||||
LS: 'S-1-5-19',
|
||||
NS: 'S-1-5-20',
|
||||
NU: 'S-1-5-2',
|
||||
SY: 'S-1-5-18',
|
||||
WD: 'S-1-1-0'
|
||||
}
|
||||
|
||||
export type WindowsAce = {
|
||||
/** `A` for allow, `D` for deny, plus the audit types. */
|
||||
type: string
|
||||
/** Two-letter inheritance/audit tokens, e.g. `['OI', 'CI']` or `['ID']`. */
|
||||
flags: string[]
|
||||
rights: string
|
||||
/**
|
||||
* Always a raw SID: aliases are resolved, unknown tokens are passed through upper-cased.
|
||||
* Machine-relative aliases resolve only when `localDomainSid` is supplied, so an unresolved
|
||||
* `LA` compares unequal to every real SID and the caller fails closed.
|
||||
*/
|
||||
sid: string
|
||||
}
|
||||
|
||||
export type WindowsDacl = {
|
||||
/** True when the DACL carries the `P` flag, i.e. inheritance from the parent is blocked. */
|
||||
isProtected: boolean
|
||||
aces: WindowsAce[]
|
||||
}
|
||||
|
||||
export function parseSddlDacl(sddl: string, localDomainSid?: string): WindowsDacl | null {
|
||||
// ACE bodies never contain parentheses, so the group stops cleanly at a following `S:` SACL.
|
||||
const dacl = /D:([A-Z]*)((?:\([^()]*\))*)/.exec(sddl)
|
||||
if (!dacl) {
|
||||
return null
|
||||
}
|
||||
const aces: WindowsAce[] = []
|
||||
for (const group of dacl[2]!.matchAll(/\(([^()]*)\)/g)) {
|
||||
const fields = group[1]!.split(';')
|
||||
if (fields.length < 6) {
|
||||
return null
|
||||
}
|
||||
aces.push({
|
||||
type: fields[0]!.toUpperCase(),
|
||||
flags: splitAceFlags(fields[1]!),
|
||||
rights: fields[2]!.toUpperCase(),
|
||||
sid: resolveSddlSid(fields[5]!, localDomainSid)
|
||||
})
|
||||
}
|
||||
return { isProtected: dacl[1]!.includes('P'), aces }
|
||||
}
|
||||
|
||||
/**
|
||||
* ACE flags are a run of two-letter tokens (`OICIID`), not a free-form string. Splitting them
|
||||
* keeps a substring search from reading `ID` out of an adjacent pair.
|
||||
*/
|
||||
function splitAceFlags(flags: string): string[] {
|
||||
const tokens: string[] = []
|
||||
for (let index = 0; index + 1 < flags.length; index += 2) {
|
||||
tokens.push(flags.slice(index, index + 2).toUpperCase())
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
export function resolveSddlSid(token: string, localDomainSid?: string): string {
|
||||
const upper = token.toUpperCase()
|
||||
const rid = LOCAL_DOMAIN_RELATIVE_RIDS[upper]
|
||||
if (rid !== undefined) {
|
||||
return localDomainSid ? `${localDomainSid}-${rid}` : upper
|
||||
}
|
||||
return SDDL_SID_ALIASES[upper] ?? upper
|
||||
}
|
||||
Reference in New Issue
Block a user