Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions sdk/typescript/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -675,13 +684,19 @@ export async function streamWindowsCredentialAclDescriptors(
if (remaining > 0) stderr += chunk.slice(0, remaining);
});

let descendantsChanged = false;
const completion = new Promise<void>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => {
if (code === 0) {
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(
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -864,9 +885,9 @@ async function secureWindowsCredentialHome(path: string): Promise<void> {
"$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'",
Expand Down
65 changes: 65 additions & 0 deletions sdk/typescript/tests-ts/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
8 changes: 4 additions & 4 deletions sdk/typescript/tests-ts/workbench-scan-root-alias.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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" }],
Expand Down
Loading