From 972ed0ed38242d4275c6f9e1a1af676143bd79b3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:36:58 +0900 Subject: [PATCH 1/2] fix(windows): grant secret ACLs to effective token SID Resolve the current token SID instead of trusting USERDOMAIN and USERNAME on workgroup hosts. Keep identity lookup failures separate from icacls timeouts.\n\nRefs #1149 --- src/lib/windows-secret-acl.ts | 49 +++--- src/lib/windows-user-principal.ts | 215 +++++++++++++++++++++++++++ tests/windows-secret-acl.test.ts | 75 ++++++++++ tests/windows-user-principal.test.ts | 131 ++++++++++++++++ 4 files changed, 448 insertions(+), 22 deletions(-) create mode 100644 src/lib/windows-user-principal.ts create mode 100644 tests/windows-user-principal.test.ts diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 9549d3987..aad34f444 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -31,6 +31,10 @@ import { existsSync, statSync } from "node:fs"; import { env, platform } from "node:process"; +import { + resolveCurrentWindowsPrincipal, + resolveCurrentWindowsPrincipalAsync, +} from "./windows-user-principal"; const hardenedDirectories = new Map(); const hardenedPaths = new Map(); @@ -407,18 +411,23 @@ function icaclsError(step: string, result: IcaclsResult): NodeJS.ErrnoException return err; } -/** - * Return the current Windows username from the environment. - * Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous. - * The value is used directly in icacls arguments, so it must be present. - */ -function currentWindowsUser(): string | undefined { - const username = env["USERNAME"]; - const domain = env["USERDOMAIN"]; - if (!username) return undefined; - // USERDOMAIN is the machine/domain name; USERNAME is the account name. - // icacls accepts "DOMAIN\User" or just "User" for local accounts. - return domain ? `${domain}\\${username}` : username; +// POSIX CI deliberately drives the Windows ACL branch through platformOverride. +// The synthetic SID exists only for that test seam; production Windows always +// resolves the effective token and never falls back to USERDOMAIN/USERNAME. +const FORCED_NON_WINDOWS_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001"; + +function currentWindowsPrincipal(deadline: number): string { + if (platformOverride === "win32" && platform !== "win32") { + return FORCED_NON_WINDOWS_TEST_PRINCIPAL; + } + return resolveCurrentWindowsPrincipal(deadline - nowFn()); +} + +async function currentWindowsPrincipalAsync(deadline: number): Promise { + if (platformOverride === "win32" && platform !== "win32") { + return FORCED_NON_WINDOWS_TEST_PRINCIPAL; + } + return resolveCurrentWindowsPrincipalAsync(deadline - nowFn()); } /** @@ -438,10 +447,7 @@ function grantAce(user: string, directory: boolean): string { } function runIcacls(targetPath: string, directory: boolean, deadline: number): void { - const user = currentWindowsUser(); - if (!user) { - throw new Error("Cannot determine current Windows user for ACL hardening"); - } + const principal = currentWindowsPrincipal(deadline); // The deadline is owned by hardenEntry (total budget incl. retry + verification). const run = (step: string, args: string[]): IcaclsResult => { @@ -458,7 +464,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo // Step 1: grant current user full control BEFORE any destructive ACL change. // If this fails, inheritance is untouched and the writer keeps inherited access. - runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]); + runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]); // Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE // from step 1 survives this transition, so a later failure still leaves cleanup access. @@ -487,10 +493,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo /** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise { - const user = currentWindowsUser(); - if (!user) { - throw new Error("Cannot determine current Windows user for ACL hardening"); - } + const principal = await currentWindowsPrincipalAsync(deadline); const run = async (step: string, args: string[]): Promise => { const remaining = deadline - nowFn(); @@ -504,7 +507,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: if (!result.success) throw icaclsError(step, result); }; - await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]); + await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]); await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]); const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]); @@ -538,6 +541,8 @@ function sanitizeDiagnostics(error: unknown): string { return `ACL hardening failed (${code}) — permission denied running icacls`; case "EICACLS": return "ACL hardening failed (EICACLS) — icacls command error; filesystem may not support per-user NTFS ACLs"; + case "EACLIDENTITY": + return "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved"; default: return `ACL hardening failed${code ? ` (${code})` : ""} — filesystem may not support per-user NTFS ACLs`; } diff --git a/src/lib/windows-user-principal.ts b/src/lib/windows-user-principal.ts new file mode 100644 index 000000000..e94c45f7d --- /dev/null +++ b/src/lib/windows-user-principal.ts @@ -0,0 +1,215 @@ +/** + * Resolve the effective Windows token to the locale-independent SID form that + * icacls accepts ("*S-1-..."). Environment values such as USERDOMAIN are not + * an authority for the current token: on workgroup machines USERDOMAIN may be + * the literal WORKGROUP even though the account belongs to the local computer. + */ + +import { resolveTrustedWindowsPowerShellExe } from "./windows-elevation"; + +const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i; +const SID_EXPRESSION = + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value"; + +export interface WindowsPrincipalLookupResult { + success: boolean; + exitCode: number | null; + timedOut: boolean; + stdout: string; +} + +export type WindowsPrincipalRunner = ( + timeoutMs: number, +) => WindowsPrincipalLookupResult; + +export type AsyncWindowsPrincipalRunner = ( + timeoutMs: number, +) => Promise; + +const POWERSHELL_ARGS = [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + SID_EXPRESSION, +] as const; + +function windowsPrincipalPowerShellCommand(): string[] { + return [resolveTrustedWindowsPowerShellExe(), ...POWERSHELL_ARGS]; +} + +/** Test-only readback of the exact trusted executable and static arguments. */ +export function windowsPrincipalPowerShellCommandForTests(): string[] { + return windowsPrincipalPowerShellCommand(); +} + +function defaultWindowsPrincipalRunner(timeoutMs: number): WindowsPrincipalLookupResult { + const result = Bun.spawnSync(windowsPrincipalPowerShellCommand(), { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + timeout: Math.max(1, timeoutMs), + windowsHide: true, + }); + return { + success: result.success, + exitCode: result.exitCode, + timedOut: result.exitedDueToTimeout ?? false, + stdout: result.stdout ? result.stdout.toString() : "", + }; +} + +async function defaultAsyncWindowsPrincipalRunner( + timeoutMs: number, +): Promise { + const proc = Bun.spawn(windowsPrincipalPowerShellCommand(), { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { proc.kill(); } catch { /* already exited */ } + }, Math.max(1, timeoutMs)); + let exitCode: number | null = null; + try { + exitCode = await proc.exited; + } finally { + clearTimeout(timer); + } + const stdout = proc.stdout + ? await new Response(proc.stdout).text().catch(() => "") + : ""; + return { + success: !timedOut && exitCode === 0, + exitCode: timedOut ? null : exitCode, + timedOut, + stdout, + }; +} + +let principalRunner: WindowsPrincipalRunner = defaultWindowsPrincipalRunner; +let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrincipalRunner; +let cachedPrincipal: string | null = null; +let asyncLookupInFlight: Promise | null = null; + +function identityError(reason: string): NodeJS.ErrnoException { + const error = new Error(`Windows effective-account SID lookup ${reason}`) as NodeJS.ErrnoException; + // Keep identity lookup failures distinct from icacls timeouts. In particular, + // they must not populate windows-secret-acl's destination timeout memo. + error.code = "EACLIDENTITY"; + return error; +} + +function principalFromResult(result: WindowsPrincipalLookupResult): string { + if (!result.success) { + throw identityError(result.timedOut + ? "timed out" + : `exited ${result.exitCode ?? "null"}`); + } + const sid = result.stdout.trim(); + if (!SID_PATTERN.test(sid)) { + throw identityError(sid ? "returned an invalid SID" : "returned an empty SID"); + } + return `*${sid.toUpperCase()}`; +} + +/** Resolve and process-cache the effective token SID for synchronous ACL paths. */ +export function resolveCurrentWindowsPrincipal(timeoutMs: number): string { + if (cachedPrincipal) return cachedPrincipal; + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + let result: WindowsPrincipalLookupResult; + try { + result = principalRunner(timeoutMs); + } catch { + throw identityError("could not start"); + } + const principal = principalFromResult(result); + cachedPrincipal = principal; + return principal; +} + +async function waitForExistingLookup( + lookup: Promise, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + lookup, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(identityError("timed out while awaiting the shared lookup")), + Math.max(1, timeoutMs), + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Async counterpart. Concurrent callers share one owned child lookup; a later + * caller may exhaust its own budget without cancelling the lookup owned by the + * first caller. The first caller owns that child and its process timeout; a + * later, longer budget deliberately does not extend an already-running child. + */ +export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Promise { + if (cachedPrincipal) return cachedPrincipal; + if (asyncLookupInFlight) return waitForExistingLookup(asyncLookupInFlight, timeoutMs); + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + + const lookup = (async (): Promise => { + let result: WindowsPrincipalLookupResult; + try { + result = await asyncPrincipalRunner(timeoutMs); + } catch { + throw identityError("could not start"); + } + const principal = principalFromResult(result); + cachedPrincipal = principal; + return principal; + })(); + asyncLookupInFlight = lookup; + try { + return await lookup; + } finally { + if (asyncLookupInFlight === lookup) asyncLookupInFlight = null; + } +} + +/** Test seam: replace the sync resolver process and clear its successful cache. */ +export function setWindowsPrincipalRunnerForTests( + runner: WindowsPrincipalRunner | null, +): void { + if (asyncLookupInFlight) { + throw new Error("Cannot replace the Windows principal runner while a lookup is in flight."); + } + principalRunner = runner ?? defaultWindowsPrincipalRunner; + cachedPrincipal = null; +} + +/** Test seam: replace the async resolver process and clear its successful cache. */ +export function setAsyncWindowsPrincipalRunnerForTests( + runner: AsyncWindowsPrincipalRunner | null, +): void { + if (asyncLookupInFlight) { + throw new Error("Cannot replace the Windows principal runner while a lookup is in flight."); + } + asyncPrincipalRunner = runner ?? defaultAsyncWindowsPrincipalRunner; + cachedPrincipal = null; +} + +/** Test seam: clear only process-local principal state. */ +export function resetWindowsPrincipalForTests(): void { + if (asyncLookupInFlight) { + throw new Error("Cannot reset the Windows principal while a lookup is in flight."); + } + cachedPrincipal = null; +} diff --git a/tests/windows-secret-acl.test.ts b/tests/windows-secret-acl.test.ts index 9ac911a14..b669aa773 100644 --- a/tests/windows-secret-acl.test.ts +++ b/tests/windows-secret-acl.test.ts @@ -36,6 +36,10 @@ import { atomicWriteFile } from "../src/config"; import { hardenStableLockFile } from "../src/codex/native-main-lock-file"; import { nativeMainClaimPath, withNativeMainSharedClaim } from "../src/codex/native-main-claim"; import { NATIVE_MAIN_OWNER_DB, retainNativeMainOwner } from "../src/codex/native-main-owner"; +import { + resetWindowsPrincipalForTests, + setWindowsPrincipalRunnerForTests, +} from "../src/lib/windows-user-principal"; let testDir = ""; @@ -126,6 +130,77 @@ describe("hardenSecretPath – required mode (required: true)", () => { }); }); +describe("effective Windows principal integration", () => { + test("the owner grant uses a numeric SID even when USERDOMAIN says WORKGROUP", () => { + const filePath = join(testDir, "workgroup-secret.json"); + writeFileSync(filePath, "data", "utf-8"); + const oldDomain = process.env.USERDOMAIN; + const oldUser = process.env.USERNAME; + process.env.USERDOMAIN = "WORKGROUP"; + process.env.USERNAME = "not-authoritative"; + resetHardenedStateForTests(); + setPlatformForTests("win32"); + const seen: string[][] = []; + setIcaclsRunnerForTests(args => { + seen.push(args); + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + try { + expect(hardenSecretPath(filePath, { required: true })).toEqual({ ok: true }); + const grant = seen.find(args => args.includes("/grant:r")); + expect(grant).toBeDefined(); + expect(grant![2]).toMatch(/^\*S-1-(?:\d+-)+\d+:\(F\)$/i); + expect(grant![2]).not.toContain("WORKGROUP"); + } finally { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + if (oldDomain === undefined) delete process.env.USERDOMAIN; + else process.env.USERDOMAIN = oldDomain; + if (oldUser === undefined) delete process.env.USERNAME; + else process.env.USERNAME = oldUser; + } + }); + + test("identity lookup failure is fail-closed but never memoized as an icacls timeout", () => { + if (process.platform !== "win32") return; + const requiredPath = join(testDir, "identity-required.json"); + const optionalPath = join(testDir, "identity-optional.json"); + writeFileSync(requiredPath, "required", "utf-8"); + writeFileSync(optionalPath, "optional", "utf-8"); + let identityCalls = 0; + let icaclsCalls = 0; + setWindowsPrincipalRunnerForTests(() => { + identityCalls += 1; + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + }); + setIcaclsRunnerForTests(() => { + icaclsCalls += 1; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + try { + expect(() => hardenSecretPath(requiredPath, { required: true })) + .toThrow(/EACLIDENTITY/); + expect(hardenSecretPath(optionalPath, { required: false })).toEqual({ + ok: false, + diagnostics: + "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved", + }); + expect(identityCalls).toBe(2); + expect(icaclsCalls).toBe(0); + expect(timedOutSecretPathCountForTests()).toBe(0); + } finally { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + setWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + } + }); +}); + describe("ephemeral harden success memo lifecycle", () => { test("forgetHardenedSecretPath releases only the actual temp and a second temp hardens again", () => { // Earlier cases in this file harden real paths under the win32 override and diff --git a/tests/windows-user-principal.test.ts b/tests/windows-user-principal.test.ts new file mode 100644 index 000000000..c0d01cc57 --- /dev/null +++ b/tests/windows-user-principal.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { + resetWindowsPrincipalForTests, + resolveCurrentWindowsPrincipal, + resolveCurrentWindowsPrincipalAsync, + setAsyncWindowsPrincipalRunnerForTests, + setWindowsPrincipalRunnerForTests, + windowsPrincipalPowerShellCommandForTests, +} from "../src/lib/windows-user-principal"; +import { setTrustedWindowsElevationExecutablesForTests } from "../src/lib/windows-elevation"; + +const ok = (stdout = "S-1-5-21-111-222-333-1001\r\n") => ({ + success: true, + exitCode: 0, + timedOut: false, + stdout, +}); + +afterEach(() => { + setWindowsPrincipalRunnerForTests(null); + setAsyncWindowsPrincipalRunnerForTests(null); + setTrustedWindowsElevationExecutablesForTests(null); + resetWindowsPrincipalForTests(); +}); + +describe("Windows effective ACL principal", () => { + test("builds a hidden non-interactive command from the trusted PowerShell path", () => { + const trusted = "C:\\trusted-system32\\WindowsPowerShell\\v1.0\\powershell.exe"; + setTrustedWindowsElevationExecutablesForTests({ powershell: trusted }); + expect(windowsPrincipalPowerShellCommandForTests()).toEqual([ + trusted, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", + ]); + }); + + test("the default trusted runner resolves the real token on Windows", () => { + if (process.platform !== "win32") return; + expect(resolveCurrentWindowsPrincipal(5_000)).toMatch(/^\*S-1-(?:\d+-)+\d+$/i); + }); + + test("the default trusted async runner settles and resolves the real token on Windows", async () => { + if (process.platform !== "win32") return; + expect(await resolveCurrentWindowsPrincipalAsync(5_000)) + .toMatch(/^\*S-1-(?:\d+-)+\d+$/i); + }); + + test("uses the token SID and normalizes it for icacls, independent of WORKGROUP env", () => { + const oldDomain = process.env.USERDOMAIN; + const oldUser = process.env.USERNAME; + process.env.USERDOMAIN = "WORKGROUP"; + process.env.USERNAME = "not-the-token-authority"; + setWindowsPrincipalRunnerForTests(() => ok()); + try { + expect(resolveCurrentWindowsPrincipal(1_000)).toBe("*S-1-5-21-111-222-333-1001"); + } finally { + if (oldDomain === undefined) delete process.env.USERDOMAIN; + else process.env.USERDOMAIN = oldDomain; + if (oldUser === undefined) delete process.env.USERNAME; + else process.env.USERNAME = oldUser; + } + }); + + test("caches only a successful lookup", () => { + let calls = 0; + setWindowsPrincipalRunnerForTests(() => { + calls += 1; + return ok(); + }); + expect(resolveCurrentWindowsPrincipal(1_000)).toMatch(/^\*S-1-/); + expect(resolveCurrentWindowsPrincipal(1_000)).toMatch(/^\*S-1-/); + expect(calls).toBe(1); + }); + + test("invalid output fails closed and is retried rather than cached", () => { + let calls = 0; + setWindowsPrincipalRunnerForTests(() => { + calls += 1; + return ok("WORKGROUP\\user\n"); + }); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + resolveCurrentWindowsPrincipal(1_000); + throw new Error("expected identity refusal"); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("EACLIDENTITY"); + } + } + expect(calls).toBe(2); + }); + + test("a resolver timeout stays EACLIDENTITY rather than entering the icacls timeout class", () => { + setWindowsPrincipalRunnerForTests(() => ({ + success: false, + exitCode: null, + timedOut: true, + stdout: "", + })); + try { + resolveCurrentWindowsPrincipal(1_000); + throw new Error("expected identity refusal"); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("EACLIDENTITY"); + } + }); + + test("concurrent async callers share one owned lookup", async () => { + let calls = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + setAsyncWindowsPrincipalRunnerForTests(async () => { + calls += 1; + await gate; + return ok(); + }); + + const first = resolveCurrentWindowsPrincipalAsync(2_000); + const second = resolveCurrentWindowsPrincipalAsync(2_000); + await Bun.sleep(0); + expect(calls).toBe(1); + release(); + await expect(first).resolves.toBe("*S-1-5-21-111-222-333-1001"); + await expect(second).resolves.toBe("*S-1-5-21-111-222-333-1001"); + }); +}); From fca2cd71277fef1af373881692823b4f496da6c4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 7 Aug 2026 21:25:18 +0900 Subject: [PATCH 2/2] fix(windows): make the ACL identity boundary testable and machine-readable Builds on the contributor fix by luvs01 (#1180), which replaced the USERDOMAIN\USERNAME ACL principal with the effective token SID. Three things that fix left open: The synthetic principal POSIX CI needs lived in windows-secret-acl.ts and was chosen before the injected runner. That ordering made a lookup FAILURE unreachable outside Windows, so the two cases that defend the fail-closed boundary and the timedOutPaths isolation were guarded with `if (process.platform !== "win32") return;` and never ran on Linux or macOS. A test that silently returns on two of three CI platforms is not coverage of a security boundary. The synthetic value moves to the resolver as its own seam, runner selection becomes explicit > synthetic > default, and both guards are gone. sanitizedAclError re-attaches only allow-listed codes, and EACLIDENTITY was not among them. A required-mode harden therefore threw with the cause in the message but `error.code === undefined`, so no caller could branch on "the SID could not be resolved" versus "icacls stalled". The existing test matched the message and hid this. The absence of a name-shaped fallback is now stated as the fix rather than left as an omission. `DOMAIN\User` has a valid shape, but shape is not evidence of the token's subject, and both variables are writable by whatever launched us. runIcacls grants the principal Full Control and then removes inheritance, so a wrong principal either leaves another account holding the secret or strands the file with no usable ACE. An independent audit rejected an earlier draft of this change that restored that fallback for the optional read path. Coverage now runs the sync and async paths across required and optional on every platform, and asserts zero icacls invocations when the environment names a plausible-looking account. Ablation: reverting the runner ordering makes identityCalls 0 and the required harden succeed (2 red); dropping EACLIDENTITY from the allow-list makes both toMatchObject assertions fail (2 red). Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> Closes #1149 --- .../081_windows_acl_userdomain_adopt_1180.md | 167 ++++++++++++++++++ src/lib/windows-secret-acl.ts | 53 ++++-- src/lib/windows-user-principal.ts | 68 +++++++ tests/windows-secret-acl.test.ts | 117 +++++++++++- 4 files changed, 386 insertions(+), 19 deletions(-) create mode 100644 devlog/_plan/260807_untouched_bug_stack/081_windows_acl_userdomain_adopt_1180.md diff --git a/devlog/_plan/260807_untouched_bug_stack/081_windows_acl_userdomain_adopt_1180.md b/devlog/_plan/260807_untouched_bug_stack/081_windows_acl_userdomain_adopt_1180.md new file mode 100644 index 000000000..06f7e2cf0 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/081_windows_acl_userdomain_adopt_1180.md @@ -0,0 +1,167 @@ +# 081 — #1149 재계획: 기여자 PR #1180 채택 + 개선 + +`080` 은 우리가 처음부터 구현하는 전제로 썼다. 그 사이 기여자 PR +[#1180](https://github.com/lidge-jun/opencodex/pull/1180) (`luvs01`, +`agent/fix-windows-acl-effective-sid`, head `df6989c17`) 이 같은 결함을 거의 +같은 설계로 이미 고쳐놨다. 처음부터 다시 쓰는 것은 기여자 저작을 버리는 +행위이고, 우리 계획이 요구한 제약을 그 PR 이 대부분 이미 만족한다. + +## #1180 이 080 의 제약을 어디까지 지켰나 + +| 080 제약 | #1180 | +|---|---| +| `whoami` 신규 작성 금지 | 지킴 — `[WindowsIdentity]::GetCurrent().User.Value` | +| 제3의 System32 리졸버 금지 | 지킴 — `resolveTrustedWindowsPowerShellExe()` 재사용 | +| sync/async 양쪽 | 지킴 — `resolveCurrentWindowsPrincipal{,Async}` | +| SID 타임아웃이 `timedOutPaths` 오염 금지 | 지킴 — 별도 코드 `EACLIDENTITY` | +| 성공만 캐시 | 지킴 — `principalFromResult` 통과 후에만 `cachedPrincipal` | +| harden 예산에서 차감 | 부분 — 남은 예산을 자식 timeout 으로 넘기지만, 실행 파일 리졸브와 spawn 준비는 그 timeout 이 시작되기 전에 일어난다 (아래 D) | +| `required:true` fail-closed | 지킴 | + +`user-identity.ts` 를 직접 재사용하는 대신 저수준 프리미티브를 새로 뽑은 것도 +`080` 의 "extract a neutral primitive" 와 같은 결론이다. 그쪽은 도메인 전용 +예외를 던지고, 무자격 `powershell.exe` 를 띄우며, 타임아웃도 `windowsHide` 도 +없고, 동기 전용이다. + +## 감사에서 뒤집힌 것 — 폴백 복원안 철회 + +이 문서의 첫 판은 optional read path 에 `USERDOMAIN\USERNAME` 폴백을 복원하자고 +했다. 독립 감사가 P1 으로 되돌렸고, 그 논증이 옳다. + +`DOMAIN\User` 라는 **형태**는 그 계정이 현재 토큰의 주체라는 **증거가 아니다**. +두 환경변수 모두 우리를 띄운 프로세스가 쓸 수 있다. 그리고 optional 경로도 +`required` 와 똑같은 파괴적 시퀀스를 돈다: + +``` +/grant:r :(F) ← 이 시점에 잘못된 계정이 Full Control 을 얻는다 +/inheritance:r ← 상속 ACE 를 전부 끊는다 +/remove:g ← Everyone/Users/Authenticated Users 만 지운다 +``` + +공격자가 고른 이름이 다른 실제 사용자로 해석되면 그 사용자의 ACE 가 시크릿에 +남고, 현재 사용자는 방금 끊긴 상속 접근을 잃는다. 고른 이름이 `BUILTIN\Users` +로 해석되면 3단계가 방금 만든 ACE 를 지워서 파일이 접근 불가가 된다. + +"optional 은 status quo 라서 안전하다" 는 논증은 성립하지 않는다. status quo 가 +안전했던 게 아니라, status quo 가 바로 #1149 가 신고한 결함이다. + +**따라서 optional SID 실패는 icacls 를 한 번도 실행하지 않고 끝낸다** — #1180 의 +동작 그대로다. 이름 폴백이 언젠가 필요하다면 환경변수가 아니라 토큰 SID 를 OS +의 신뢰된 API 로 이름 변환하는 별도 권위 경로여야 하고, 그건 이 유닛의 범위가 +아니다. + +## 우리가 얹는 것 + +### (A) 테스트 전용 상수가 프로덕션 파일 한가운데 있다 + +```ts +const FORCED_NON_WINDOWS_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001"; + +function currentWindowsPrincipal(deadline: number): string { + if (platformOverride === "win32" && platform !== "win32") { + return FORCED_NON_WINDOWS_TEST_PRINCIPAL; + } + ... +``` + +**이것은 보안 결함이 아니다.** 감사가 정확히 지적한 대로, 프로덕션 Windows 에서는 +`platform !== "win32"` 가 거짓이라 이 분기에 도달할 수 없고, POSIX 에서도 +테스트 전용 setter 를 호출해야 켜진다. 위생 문제이며, 그 이상으로 포장하지 않는다. + +옮기는 진짜 이유는 (B) 다. 합성값이 프로덕션 모듈에 있는 한 실패 주입이 불가능하다. + +이 분기가 필요한 이유 자체는 실재한다. POSIX CI 는 `setPlatformForTests("win32")` +로 ACL 분기를 강제로 돌리는데, 그 호스트에는 PowerShell 도 System32 도 없다. +이미 그렇게 도는 테스트가 7개 파일 30여 곳이다. + +**해결:** 합성 SID 를 `windows-user-principal.ts` 의 테스트 seam 으로 옮기고, +`setPlatformForTests` 가 그 seam 을 켜고 끈다. `windows-secret-acl.ts` 에는 +`FORCED_NON_WINDOWS_TEST_PRINCIPAL` 상수도, 그것을 고르는 분기도 남지 않는다. + +### (B) 실패 경로 테스트가 POSIX CI 에서 통째로 스킵된다 + +```ts +test("identity lookup failure is fail-closed but never memoized as an icacls timeout", () => { + if (process.platform !== "win32") return; +``` + +`timedOutPaths` 오염 금지는 `080` 이 명시적으로 요구한 제약인데, 그것을 지키는 +유일한 테스트가 Linux/macOS 러너에서 한 줄도 실행되지 않는다. 원인은 (A) 다 — +합성 principal 이 runner 보다 먼저 반환하므로 POSIX 에서는 실패를 주입할 방법이 +없었다. + +**해결:** 어느 runner 를 쓸지 고를 때 명시적 override 가 합성값을 이기게 한다. + +``` +runner 선택: explicit override > synthetic(test) > default +성공 캐시: 선택된 경로와 무관하게 그대로 authoritative +``` + +"override 가 캐시보다 먼저" 라는 뜻이 아니다 — 성공한 조회는 여전히 캐시되고 +재사용된다. 바뀌는 것은 캐시가 비어 있을 때 **무엇을 실행하느냐** 뿐이다. +그러면 실패 주입 테스트가 세 플랫폼 전부에서 돈다. 스킵 가드를 제거한다. + +### (C) `required` 경계에서 `EACLIDENTITY` 코드가 소실된다 + +`sanitizedAclError` (`src/lib/windows-secret-acl.ts:557-566`) 는 허용 목록에 든 +코드만 재부착한다: + +```ts +if (code === "ETIMEDOUT" || code === "EICACLS" || code === "EACCES" || code === "EPERM") { + error.code = code; +} +``` + +`EACLIDENTITY` 가 없다. #1180 은 `sanitizeDiagnostics` 에는 케이스를 추가했으므로 +**메시지 문자열**에는 남지만, `required: true` 가 던지는 오류의 `error.code` 는 +`undefined` 다. 호출자가 원인을 프로그램적으로 구분할 수 없다. + +#1180 의 테스트가 이걸 가린다: `.toThrow(/EACLIDENTITY/)` 는 메시지만 본다. + +**해결:** 허용 목록에 `EACLIDENTITY` 를 추가하고, 테스트를 코드 검사로 바꾼다. + +### (D) 예산 caveat 을 문서로 정직하게 남긴다 + +`080` 은 "lookup 을 harden 예산에 차감" 을 요구했다. #1180 은 남은 예산을 자식 +프로세스 timeout 으로 넘기지만, 그 timeout 이 시작되기 전에 두 가지가 일어난다: +`resolveTrustedWindowsPowerShellExe()` 의 `GetSystemDirectoryW` FFI 호출, 그리고 +`Bun.spawn` 반환 이후에야 걸리는 async 타이머. + +통상 작지만 hard bound 는 아니다. 남는 위험은 잘못된 권한 부여가 아니라 — +두 작업 모두 ACL 이 바뀌기 전에 끝난다 — 예산을 조금 넘길 수 있는 가용성 +문제다. 강제하려면 runner 계약과 동기 실행 모델까지 손대야 해서 채택 개선과 +분리한다. 대신 `windows-user-principal.ts` 상단에 caveat 을 명시해서, 다음에 이 +예산을 조이는 사람이 착각하지 않게 한다. + +## 변경 파일 + +- `src/lib/windows-user-principal.ts` — 합성 seam 추가, override 우선순위, 예산 caveat +- `src/lib/windows-secret-acl.ts` — 합성 상수/분기 제거, `EACLIDENTITY` 허용 목록 추가 +- `tests/windows-user-principal.test.ts` — override 우선순위 케이스 +- `tests/windows-secret-acl.test.ts` — 스킵 가드 제거, sync/async × required/optional 행렬 + +## 수용 기준 + +1. `windows-secret-acl.ts` 전체에 `FORCED_NON_WINDOWS_TEST_PRINCIPAL` 문자열도, + 합성 principal 을 고르는 `platformOverride` 분기도 없다 (`rg` 로 확인 가능). +2. SID 실패 + `required: true` → 던져진 오류가 `toMatchObject({ code: "EACLIDENTITY" })` + 를 만족하고, `timedOutSecretPathCountForTests() === 0`, icacls 호출 0회. + **POSIX 러너에서 실제로 실행된다** (스킵 가드 없음). +3. SID 실패 + `required: false` → `{ ok: false, diagnostics }` 반환, icacls 호출 0회, + ACL 변경 없음. 환경변수 폴백 없음. +4. 2·3 이 sync (`hardenSecretPath`) 와 async (`hardenSecretPathAsync`) 양쪽에 + 동일하게 성립한다. +5. ablation — 각각 되돌렸을 때 red 가 되는 테스트를 명시한다: + - (A)+(B) 우선순위를 `synthetic → override` 로 되돌리면: 주입한 실패 runner 가 + 호출되지 않아 `identityCalls === 0` 이 되고, required 하든이 성공해버려 + 기준 2 가 **red**. + - (C) 허용 목록에서 `EACLIDENTITY` 를 빼면: `error.code` 가 `undefined` 가 되어 + 기준 2 의 `toMatchObject` 가 **red**. + - 철회한 환경변수 폴백을 되살리면: 기준 3 의 icacls 호출 0회 assertion 이 + **red**. 이 mutation 을 명시해 두는 이유는, 폴백 철회가 이 유닛에서 가장 + 되돌아오기 쉬운 결정이기 때문이다. + +## 커밋 구성 + +기여자 커밋 `df6989c17` 을 cherry-pick 해서 저작을 보존하고, 그 위에 개선 +커밋을 얹는다. #1180 은 대체 PR 번호를 남기고 close 한다. diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index aad34f444..0ac11927a 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -34,6 +34,7 @@ import { env, platform } from "node:process"; import { resolveCurrentWindowsPrincipal, resolveCurrentWindowsPrincipalAsync, + setSyntheticWindowsPrincipalForTests, } from "./windows-user-principal"; const hardenedDirectories = new Map(); @@ -340,9 +341,21 @@ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null): asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner; } -/** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */ +/** + * Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. + * + * Faking win32 on a host without System32 also has to supply a principal, or + * every forced-branch test would fail on the identity lookup instead of + * exercising icacls. The synthetic value is registered with the resolver, not + * chosen here, so a test that injects its own runner still wins. + */ +const SYNTHETIC_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001"; + export function setPlatformForTests(value: string | null): void { platformOverride = value; + setSyntheticWindowsPrincipalForTests( + value === "win32" && platform !== "win32" ? SYNTHETIC_TEST_PRINCIPAL : null, + ); } /** Test seam: injectable clock for deadline tests (no real sleeps). */ @@ -411,22 +424,26 @@ function icaclsError(step: string, result: IcaclsResult): NodeJS.ErrnoException return err; } -// POSIX CI deliberately drives the Windows ACL branch through platformOverride. -// The synthetic SID exists only for that test seam; production Windows always -// resolves the effective token and never falls back to USERDOMAIN/USERNAME. -const FORCED_NON_WINDOWS_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001"; - +/** + * The ACL principal is the effective token SID and nothing else. + * + * There is no name-shaped fallback here, and that absence is the fix for #1149 + * rather than an omission. `USERDOMAIN\USERNAME` has the right shape but is not + * evidence of the current token's subject, and both variables are writable by + * the process that launched us. Granting Full Control to a wrong principal and + * then running `/inheritance:r` is destructive in both directions: another + * account can be left holding the secret, or the file can be left with no ACE + * the current user can use. When the SID cannot be resolved we decline. + * + * Non-Windows hosts that force this branch through `setPlatformForTests` get + * their principal from `setSyntheticWindowsPrincipalForTests`, which lives with + * the resolver so an injected runner can still take precedence over it. + */ function currentWindowsPrincipal(deadline: number): string { - if (platformOverride === "win32" && platform !== "win32") { - return FORCED_NON_WINDOWS_TEST_PRINCIPAL; - } return resolveCurrentWindowsPrincipal(deadline - nowFn()); } async function currentWindowsPrincipalAsync(deadline: number): Promise { - if (platformOverride === "win32" && platform !== "win32") { - return FORCED_NON_WINDOWS_TEST_PRINCIPAL; - } return resolveCurrentWindowsPrincipalAsync(deadline - nowFn()); } @@ -559,7 +576,17 @@ function sanitizedAclError(diagnostics: string, cause: unknown): NodeJS.ErrnoExc const code = cause && typeof cause === "object" && "code" in cause ? String((cause as { code?: unknown }).code) : ""; - if (code === "ETIMEDOUT" || code === "EICACLS" || code === "EACCES" || code === "EPERM") { + // EACLIDENTITY belongs here for the same reason as the rest: a caller that + // catches a required-mode failure has to tell "the SID could not be resolved" + // apart from "icacls stalled". Without it the code was dropped and only the + // message carried the cause, which no caller can branch on. + if ( + code === "ETIMEDOUT" || + code === "EICACLS" || + code === "EACCES" || + code === "EPERM" || + code === "EACLIDENTITY" + ) { error.code = code; } return error; diff --git a/src/lib/windows-user-principal.ts b/src/lib/windows-user-principal.ts index e94c45f7d..93da3efd5 100644 --- a/src/lib/windows-user-principal.ts +++ b/src/lib/windows-user-principal.ts @@ -3,6 +3,21 @@ * icacls accepts ("*S-1-..."). Environment values such as USERDOMAIN are not * an authority for the current token: on workgroup machines USERDOMAIN may be * the literal WORKGROUP even though the account belongs to the local computer. + * + * There is deliberately NO name-shaped fallback. A `DOMAIN\User` string has a + * valid shape, but shape is not evidence that the account is the current + * token's subject, and both environment variables are writable by whatever + * launched us. A wrong principal here is not cosmetic: `runIcacls` grants it + * Full Control and then removes inheritance, so a wrong grant either leaves a + * different account holding the secret or strands the file with no usable ACE. + * When the SID cannot be resolved, the caller declines to touch the ACL at all. + * + * Budget caveat: callers pass their REMAINING harden budget, which becomes the + * child process timeout. Trusted-executable resolution (a `GetSystemDirectoryW` + * FFI call) and spawn setup happen before that timeout starts, and the async + * timer only arms once `Bun.spawn` returns. Both are small in practice, but the + * lookup is not bounded by the deadline to the microsecond. Tightening that + * would mean passing an absolute deadline through the runner interface. */ import { resolveTrustedWindowsPowerShellExe } from "./windows-elevation"; @@ -97,6 +112,39 @@ let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrinc let cachedPrincipal: string | null = null; let asyncLookupInFlight: Promise | null = null; +/** + * POSIX CI drives the Windows ACL branch through `setPlatformForTests("win32")`, + * on hosts that have neither System32 nor PowerShell. Those runs need SOME + * principal, so this seam supplies a synthetic one. + * + * It lives here rather than in `windows-secret-acl.ts` for one reason that is + * not cosmetic: an explicitly injected runner must be able to beat it. When the + * synthetic value was chosen first, in the ACL module, a test could not inject a + * lookup FAILURE on POSIX at all — so the fail-closed and memo-isolation cases + * were guarded with `if (process.platform !== "win32") return;` and never ran + * outside Windows. Resolution order below is what makes those cases executable + * on every runner. + */ +let syntheticPrincipalForTests: string | null = null; + +/** + * Test seam: supply the principal used when no runner was injected and the host + * is not really Windows. Pass null to disable. + */ +export function setSyntheticWindowsPrincipalForTests(principal: string | null): void { + syntheticPrincipalForTests = principal; + cachedPrincipal = null; +} + +/** True when an explicit runner override is installed and must take precedence. */ +function hasSyncRunnerOverride(): boolean { + return principalRunner !== defaultWindowsPrincipalRunner; +} + +function hasAsyncRunnerOverride(): boolean { + return asyncPrincipalRunner !== defaultAsyncWindowsPrincipalRunner; +} + function identityError(reason: string): NodeJS.ErrnoException { const error = new Error(`Windows effective-account SID lookup ${reason}`) as NodeJS.ErrnoException; // Keep identity lookup failures distinct from icacls timeouts. In particular, @@ -120,7 +168,23 @@ function principalFromResult(result: WindowsPrincipalLookupResult): string { /** Resolve and process-cache the effective token SID for synchronous ACL paths. */ export function resolveCurrentWindowsPrincipal(timeoutMs: number): string { + // Order matters: an explicitly injected runner outranks the synthetic value, + // so a test can inject a FAILURE on a POSIX host. See the seam comment above. + if (hasSyncRunnerOverride()) { + if (cachedPrincipal) return cachedPrincipal; + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + let overridden: WindowsPrincipalLookupResult; + try { + overridden = principalRunner(timeoutMs); + } catch { + throw identityError("could not start"); + } + const principal = principalFromResult(overridden); + cachedPrincipal = principal; + return principal; + } if (cachedPrincipal) return cachedPrincipal; + if (syntheticPrincipalForTests) return syntheticPrincipalForTests; if (timeoutMs <= 0) throw identityError("had no remaining deadline"); let result: WindowsPrincipalLookupResult; try { @@ -161,8 +225,11 @@ async function waitForExistingLookup( * later, longer budget deliberately does not extend an already-running child. */ export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Promise { + const overridden = hasAsyncRunnerOverride(); if (cachedPrincipal) return cachedPrincipal; if (asyncLookupInFlight) return waitForExistingLookup(asyncLookupInFlight, timeoutMs); + // Same precedence rule as the sync path: an injected runner beats the synthetic. + if (!overridden && syntheticPrincipalForTests) return syntheticPrincipalForTests; if (timeoutMs <= 0) throw identityError("had no remaining deadline"); const lookup = (async (): Promise => { @@ -212,4 +279,5 @@ export function resetWindowsPrincipalForTests(): void { throw new Error("Cannot reset the Windows principal while a lookup is in flight."); } cachedPrincipal = null; + syntheticPrincipalForTests = null; } diff --git a/tests/windows-secret-acl.test.ts b/tests/windows-secret-acl.test.ts index b669aa773..77580d239 100644 --- a/tests/windows-secret-acl.test.ts +++ b/tests/windows-secret-acl.test.ts @@ -38,6 +38,7 @@ import { nativeMainClaimPath, withNativeMainSharedClaim } from "../src/codex/nat import { NATIVE_MAIN_OWNER_DB, retainNativeMainOwner } from "../src/codex/native-main-owner"; import { resetWindowsPrincipalForTests, + setAsyncWindowsPrincipalRunnerForTests, setWindowsPrincipalRunnerForTests, } from "../src/lib/windows-user-principal"; @@ -162,8 +163,15 @@ describe("effective Windows principal integration", () => { } }); - test("identity lookup failure is fail-closed but never memoized as an icacls timeout", () => { - if (process.platform !== "win32") return; + // These ran only on Windows until the resolver learned to let an injected + // runner outrank the synthetic POSIX principal. A case that silently returns + // on two of three CI platforms is not coverage of a fail-closed boundary, and + // the timedOutPaths isolation it asserts is the whole reason the identity + // failure carries its own error code. + const IDENTITY_DIAGNOSTIC = + "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved"; + + test("a failed identity lookup fails closed, runs no icacls, and never enters the timeout memo", () => { const requiredPath = join(testDir, "identity-required.json"); const optionalPath = join(testDir, "identity-optional.json"); writeFileSync(requiredPath, "required", "utf-8"); @@ -181,15 +189,28 @@ describe("effective Windows principal integration", () => { resetHardenedStateForTests(); setPlatformForTests("win32"); try { - expect(() => hardenSecretPath(requiredPath, { required: true })) - .toThrow(/EACLIDENTITY/); + // required: throws, and the CODE survives sanitization so a caller can + // branch on the cause rather than parse the message. + let thrown: NodeJS.ErrnoException | undefined; + try { + hardenSecretPath(requiredPath, { required: true }); + } catch (error) { + thrown = error as NodeJS.ErrnoException; + } + expect(thrown).toBeDefined(); + expect(thrown).toMatchObject({ code: "EACLIDENTITY" }); + + // optional: soft-fails WITHOUT touching the ACL. No name-shaped fallback. expect(hardenSecretPath(optionalPath, { required: false })).toEqual({ ok: false, - diagnostics: - "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved", + diagnostics: IDENTITY_DIAGNOSTIC, }); + + // The injected runner actually ran — this is what regressed to 0 when the + // synthetic principal was chosen first. expect(identityCalls).toBe(2); expect(icaclsCalls).toBe(0); + // An identity failure is not an icacls timeout: the path stays retryable. expect(timedOutSecretPathCountForTests()).toBe(0); } finally { setIcaclsRunnerForTests(null); @@ -199,6 +220,90 @@ describe("effective Windows principal integration", () => { resetWindowsPrincipalForTests(); } }); + + test("the async harden path applies the same identity policy", async () => { + const requiredPath = join(testDir, "identity-required-async.json"); + const optionalPath = join(testDir, "identity-optional-async.json"); + writeFileSync(requiredPath, "required", "utf-8"); + writeFileSync(optionalPath, "optional", "utf-8"); + let identityCalls = 0; + let icaclsCalls = 0; + setAsyncWindowsPrincipalRunnerForTests(async () => { + identityCalls += 1; + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + }); + setAsyncIcaclsRunnerForTests(async () => { + icaclsCalls += 1; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + try { + let thrown: NodeJS.ErrnoException | undefined; + try { + await hardenSecretPathAsync(requiredPath, { required: true }); + } catch (error) { + thrown = error as NodeJS.ErrnoException; + } + expect(thrown).toBeDefined(); + expect(thrown).toMatchObject({ code: "EACLIDENTITY" }); + + expect(await hardenSecretPathAsync(optionalPath, { required: false })).toEqual({ + ok: false, + diagnostics: IDENTITY_DIAGNOSTIC, + }); + + expect(identityCalls).toBe(2); + expect(icaclsCalls).toBe(0); + expect(timedOutSecretPathCountForTests()).toBe(0); + } finally { + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + setAsyncWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + } + }); + + test("no name-shaped principal reaches icacls when the environment names a plausible account", () => { + const filePath = join(testDir, "no-name-fallback.json"); + writeFileSync(filePath, "data", "utf-8"); + const oldDomain = process.env.USERDOMAIN; + const oldUser = process.env.USERNAME; + // A shape a reader would accept at a glance. It is still not the token. + process.env.USERDOMAIN = "CORP"; + process.env.USERNAME = "administrator"; + const seen: string[][] = []; + setWindowsPrincipalRunnerForTests(() => ({ + success: false, + exitCode: 1, + timedOut: false, + stdout: "", + })); + setIcaclsRunnerForTests(args => { + seen.push(args); + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + try { + expect(hardenSecretPath(filePath, { required: false })).toEqual({ + ok: false, + diagnostics: IDENTITY_DIAGNOSTIC, + }); + expect(seen).toEqual([]); + } finally { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + setWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + if (oldDomain === undefined) delete process.env.USERDOMAIN; + else process.env.USERDOMAIN = oldDomain; + if (oldUser === undefined) delete process.env.USERNAME; + else process.env.USERNAME = oldUser; + } + }); }); describe("ephemeral harden success memo lifecycle", () => {