diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index f8f1f1edb..37858dee5 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -82,6 +82,7 @@ const CREDENTIAL_LOCK_POLL_MILLISECONDS = 25; const INCOMPLETE_CREDENTIAL_LOCK_MILLISECONDS = 30_000; const MAX_PROCESS_ID = 2_147_483_647; const MAX_WINDOWS_CREDENTIAL_ACL_STDERR = 64 * 1024; +const WINDOWS_CREDENTIAL_DESCENDANTS_CHANGED_EXIT_CODE = 2; export interface PluginInstall { pluginRoot: string; @@ -416,6 +417,8 @@ class RepairableWindowsCredentialOwnerError extends Error { } } +class WindowsCredentialDescendantsChangedError extends Error {} + /** Inspect a Windows DACL without translating locale-specific account names. */ export function inspectWindowsCredentialAcl( descriptor: string, @@ -651,7 +654,13 @@ export async function verifyStableWindowsCredentialDescendants( } if (descendants === 0 && options.inspectEmpty !== true) return; - if ((await inspectDescriptors()) === descendants) return; + try { + if ((await inspectDescriptors()) === descendants) return; + } catch (error) { + if (!(error instanceof WindowsCredentialDescendantsChangedError)) { + throw error; + } + } } throw new Error("Windows credential descendants could not be verified"); @@ -675,6 +684,7 @@ export async function streamWindowsCredentialAclDescriptors( if (remaining > 0) stderr += chunk.slice(0, remaining); }); + let descendantsChanged = false; const completion = new Promise((resolve, reject) => { child.once("error", reject); child.once("close", (code, signal) => { @@ -682,6 +692,11 @@ export async function streamWindowsCredentialAclDescriptors( resolve(); return; } + if (code === WINDOWS_CREDENTIAL_DESCENDANTS_CHANGED_EXIT_CODE) { + descendantsChanged = true; + resolve(); + return; + } const reason = signal === null ? `exit code ${code}` : `signal ${signal}`; reject( Object.assign( @@ -713,6 +728,12 @@ export async function streamWindowsCredentialAclDescriptors( throw error; } + if (descendantsChanged) { + // Finish descriptor callbacks before allowing another snapshot attempt. + throw new WindowsCredentialDescendantsChangedError( + "Windows credential descendants changed during ACL inspection", + ); + } return descriptors; } @@ -864,9 +885,9 @@ async function secureWindowsCredentialHome(path: string): Promise { "$path = $env:CODEX_SECURITY_CREDENTIAL_ACL_PATH", "while ($true) { $parent = Microsoft.PowerShell.Management\\Split-Path -Path $path -Parent; if (-not $parent -or $parent -eq $path) { break }; Microsoft.PowerShell.Security\\Get-Acl -LiteralPath $parent | Microsoft.PowerShell.Utility\\Select-Object -ExpandProperty Sddl; $path = $parent }", "Microsoft.PowerShell.Security\\Get-Acl -LiteralPath $env:CODEX_SECURITY_CREDENTIAL_ACL_PATH | Microsoft.PowerShell.Utility\\Select-Object -ExpandProperty Sddl", - // A temporary descendant can disappear after enumeration. Its missing - // descriptor reduces the count and retries the stable snapshot. - "Microsoft.PowerShell.Management\\Get-ChildItem -LiteralPath $env:CODEX_SECURITY_CREDENTIAL_ACL_PATH -Recurse -Force | Microsoft.PowerShell.Core\\ForEach-Object { try { Microsoft.PowerShell.Security\\Get-Acl -LiteralPath $_.FullName | Microsoft.PowerShell.Utility\\Select-Object -ExpandProperty Sddl } catch { if ($_.FullyQualifiedErrorId -notlike 'GetAcl_PathNotFound,*') { throw } } }", + // Temporary descendants can disappear during enumeration or ACL reads. + // Retry interrupted enumeration as well as incomplete descriptor counts. + `try { Microsoft.PowerShell.Management\\Get-ChildItem -LiteralPath $env:CODEX_SECURITY_CREDENTIAL_ACL_PATH -Recurse -Force | Microsoft.PowerShell.Core\\ForEach-Object { try { Microsoft.PowerShell.Security\\Get-Acl -LiteralPath $_.FullName | Microsoft.PowerShell.Utility\\Select-Object -ExpandProperty Sddl } catch { if ($_.FullyQualifiedErrorId -notlike 'GetAcl_PathNotFound,*') { throw } } } } catch { if ($_.FullyQualifiedErrorId -eq 'System.IO.FileNotFoundException,Microsoft.PowerShell.Commands.GetChildItemCommand') { exit ${WINDOWS_CREDENTIAL_DESCENDANTS_CHANGED_EXIT_CODE} }; throw }`, ].join("; "); const resolvePrincipalScript = [ "$ErrorActionPreference = 'Stop'", diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 776adf60c..396057bdd 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -2904,6 +2904,71 @@ describe("runtime directories and plugin Python boundary", () => { }); }); + test("retries interrupted Windows credential ACL enumeration", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + const inspectionCount = join(root, "inspection-count"); + const temporary = join(home, ".auth-temporary"); + await mkdir(home); + await writeFile(join(home, "auth.json"), "credential\n"); + await writeFile(temporary, "temporary credential\n"); + const sid = "S-1-5-21-111-222-333-1001"; + const directory = `O:${sid}G:SYD:P(A;OICI;FA;;;${sid})`; + const file = `O:${sid}G:SYD:P(A;;FA;;;${sid})`; + const descriptors: string[] = []; + for (let ancestor = dirname(home); ; ancestor = dirname(ancestor)) { + descriptors.push(directory); + if (ancestor === dirname(ancestor)) break; + } + descriptors.push(directory, file); + const script = [ + 'const fs = require("node:fs")', + `fs.appendFileSync(${JSON.stringify(inspectionCount)}, "inspection\\n")`, + `process.stdout.write(${JSON.stringify(`${descriptors.join("\n")}\n`)})`, + `if (fs.existsSync(${JSON.stringify(temporary)})) { fs.unlinkSync(${JSON.stringify(temporary)}); process.exitCode = 2 }`, + ].join("; "); + + await expect( + inspectWindowsCredentialAclSnapshot(home, sid, { + command: process.execPath, + args: ["--eval", script], + }), + ).resolves.toMatchObject({ + home: { owner: sid, protected: true }, + descendantsArePrivate: true, + }); + expect(await readFile(inspectionCount, "utf8")).toBe( + "inspection\ninspection\n", + ); + }); + + test.each([ + [2, 3, "Windows credential descendants could not be verified"], + [1, 1, "Windows credential ACL inspection failed with exit code 1"], + ] as const)( + "rejects Windows ACL subprocess exit %i after %i attempts", + async (exitCode, attempts, message) => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + const inspectionCount = join(root, "inspection-count"); + await mkdir(home); + const script = [ + `require("node:fs").appendFileSync(${JSON.stringify(inspectionCount)}, "inspection\\n")`, + `process.exitCode = ${exitCode}`, + ].join("; "); + + await expect( + inspectWindowsCredentialAclSnapshot(home, "S-1-5-21-111-222-333-1001", { + command: process.execPath, + args: ["--eval", script], + }), + ).rejects.toThrow(message); + expect(await readFile(inspectionCount, "utf8")).toBe( + "inspection\n".repeat(attempts), + ); + }, + ); + test("rejects unsafe Windows credential ancestry during combined ACL inspection", async () => { const root = await temporaryDirectory(); const home = join(root, "home"); diff --git a/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts b/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts index 0a74c093a..91cc240d7 100644 --- a/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts +++ b/sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts @@ -1,7 +1,8 @@ -import { spawnSync } from "node:child_process"; +import { execFile } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { promisify } from "node:util"; import { expect, test } from "bun:test"; import { resolvePluginPython } from "../src/runtime.js"; import { PLUGIN_ROOT } from "./plugin-root.js"; @@ -34,13 +35,12 @@ test.skipIf(process.platform !== "win32")( "args = argparse.Namespace(repository=None, scan_root=sys.argv[2].upper(), target_id=None, mode=None, status=None, query=None, limit=None, offset=0)", "print(json.dumps(history.list_scans(connection, args)))", ].join("\n"); - const result = spawnSync( + const result = await promisify(execFile)( python, ["-I", "-B", "-c", probe, join(PLUGIN_ROOT, "scripts"), scanRoot], - { encoding: "utf8", timeout: 10_000 }, + { encoding: "utf8", timeout: 10_000, windowsHide: true }, ); - expect(result.status, result.stderr).toBe(0); expect(result.stderr).toBe(""); expect(JSON.parse(result.stdout)).toMatchObject({ scans: [{ scanId: "scan" }],