diff --git a/sdk/typescript/src/multiscan.ts b/sdk/typescript/src/multiscan.ts index d5d8f6e5..6cd7ae37 100644 --- a/sdk/typescript/src/multiscan.ts +++ b/sdk/typescript/src/multiscan.ts @@ -360,16 +360,18 @@ function notifyProgress( } async function ensureOutputDirectory(path: string): Promise { - const metadata = await lstat(path).catch((error: NodeJS.ErrnoException) => { - if (error.code !== "ENOENT") throw error; - return undefined; - }); + const metadata = await lstat(path, { bigint: true }).catch( + (error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return undefined; + }, + ); if (metadata?.isSymbolicLink()) { throw new Error("Multiscan output directories must not be symbolic links."); } await mkdir(path, { recursive: true, mode: 0o700 }); const canonical = await realpath(path); - const directory = await lstat(canonical); + const directory = await lstat(canonical, { bigint: true }); if ( metadata !== undefined && (directory.dev !== metadata.dev || directory.ino !== metadata.ino) @@ -377,13 +379,13 @@ async function ensureOutputDirectory(path: string): Promise { throw new Error("Multiscan output directories changed during preparation."); } if (process.platform === "win32") return canonical; - if ((directory.mode & 0o022) !== 0) { + if ((directory.mode & 0o022n) !== 0n) { throw new Error( "Multiscan output directories must not be group- or world-writable.", ); } const owner = process.geteuid?.(); - if (owner !== undefined && directory.uid !== owner) { + if (owner !== undefined && directory.uid !== BigInt(owner)) { throw new Error( "Multiscan output directories must be owned by the current user.", ); @@ -419,6 +421,7 @@ async function acquireLock(output: string): Promise<() => Promise> { await rm(stale, { recursive: true, force: true }); } } + // Windows file IDs can exceed JavaScript's safe integer range. const createdLock = await lstat(path, { bigint: true }); const owner = `${JSON.stringify({ pid: process.pid, diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 5c94defb..6d3304f2 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -1,6 +1,12 @@ import { execFile as execFileCallback, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { constants, existsSync, readdirSync, type Stats } from "node:fs"; +import { + constants, + existsSync, + readdirSync, + type BigIntStats, + type Stats, +} from "node:fs"; import { chmod, cp, @@ -167,7 +173,7 @@ export async function prepareCodexSecurityCredentialHome( throw error; } if ((process.umask() & 0o700) !== 0) await chmod(path, 0o700); - const metadata = await lstat(path); + const metadata = await lstat(path, { bigint: true }); if (!metadata.isDirectory() || metadata.isSymbolicLink()) { throw new OutputDirectoryError( `Codex Security credential home is not a directory: ${path}`, @@ -202,17 +208,17 @@ export async function requireSecureCredentialHome( options: { platform?: NodeJS.Platform; secureWindowsHome?: (path: string) => Promise; - metadata?: Stats; - expectedDevice?: number; - expectedInode?: number; + metadata?: BigIntStats; + expectedDevice?: bigint; + expectedInode?: bigint; validateWindowsAcl?: boolean; } = {}, -): Promise { +): Promise { const platform = options.platform ?? process.platform; let metadata = options.metadata; if (metadata === undefined) { try { - metadata = await lstat(path); + metadata = await lstat(path, { bigint: true }); } catch (error) { throw new OutputDirectoryError( `Unable to inspect the Codex Security credential home: ${path}`, @@ -227,7 +233,7 @@ export async function requireSecureCredentialHome( } const canonical = await realpath(path); requireModelSafeOutputDir(canonical); - const canonicalMetadata = await lstat(canonical); + const canonicalMetadata = await lstat(canonical, { bigint: true }); if ( canonicalMetadata.dev !== metadata.dev || canonicalMetadata.ino !== metadata.ino @@ -252,17 +258,19 @@ export async function requireSecureCredentialHome( `Codex Security credential home was replaced: ${canonical}`, ); } - if (platform === "win32") { - if (options.validateWindowsAcl !== false) { - await requirePrivateCredentialHome(metadata, canonical, { + if (platform !== "win32" || options.validateWindowsAcl !== false) { + await requirePrivateCredentialHome( + { mode: Number(metadata.mode), uid: Number(metadata.uid) }, + canonical, + { platform, secureWindowsHome: options.secureWindowsHome, - }); - } - return metadata; + }, + ); + } + if (platform !== "win32") { + await requireSecureOutputAncestry(canonical); } - await requirePrivateCredentialHome(metadata, canonical, { platform }); - await requireSecureOutputAncestry(canonical); return metadata; } @@ -2546,9 +2554,10 @@ async function isRegularFile(path: string): Promise { async function sameFile(left: string, right: string): Promise { try { + // NTFS file IDs can exceed JavaScript's safe integer range. const [leftMetadata, rightMetadata] = await Promise.all([ - stat(left), - stat(right), + stat(left, { bigint: true }), + stat(right, { bigint: true }), ]); return ( leftMetadata.dev === rightMetadata.dev && diff --git a/sdk/typescript/tests-ts/multiscan.test.ts b/sdk/typescript/tests-ts/multiscan.test.ts index 9f229fe0..328f2359 100644 --- a/sdk/typescript/tests-ts/multiscan.test.ts +++ b/sdk/typescript/tests-ts/multiscan.test.ts @@ -1828,6 +1828,58 @@ describe("multiscan", () => { expect(await readdir(external)).toEqual(["attempt-1"]); }); + test("rejects an output directory replaced during preparation when numeric identities collide", async () => { + const paths = await fixture(); + const source = await repository(paths.root, "output-identity-race"); + await writeFile( + paths.input, + `id,repository,revision\nrace,${source.path},${source.revision}\n`, + ); + await mkdir(paths.output, { mode: 0o700 }); + const originalLstat = filesystem.lstat; + const canonicalOutput = await realpath(paths.output); + const firstExactIdentity = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + let outputInspections = 0; + const inspectOutput = spyOn(filesystem, "lstat").mockImplementation( + async (path, options) => { + const stats = await originalLstat(path, options as never); + if (String(path) !== paths.output && String(path) !== canonicalOutput) { + return stats as never; + } + const exactIdentity = + firstExactIdentity + (outputInspections++ === 0 ? 0n : 1n); + return Object.assign( + Object.create(Object.getPrototypeOf(stats)), + stats, + { + ino: + typeof stats.ino === "bigint" + ? exactIdentity + : Number(exactIdentity), + }, + ) as never; + }, + ); + let scans = 0; + + try { + await expect( + runMultiscan( + options( + paths, + client(async (_repository, scanOptions = {}) => { + scans += 1; + return await completedScan(scanOptions.outputDir!); + }), + ), + ), + ).rejects.toThrow("changed during preparation"); + expect(scans).toBe(0); + } finally { + inspectOutput.mockRestore(); + } + }); + testPosix( "rejects other-user-writable campaigns while preserving readable existing campaigns", async () => { diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 0031e323..1d2c42e5 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -29,7 +29,7 @@ import { } from "node:path"; import { promisify } from "node:util"; import { brotliDecompressSync } from "node:zlib"; -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { strToU8, zipSync } from "fflate"; import { BUNDLED_PLUGIN_VERSION, @@ -61,6 +61,7 @@ import { planOutputArchive, prepareCodexSecurityCredentialHome, preparePersistentOutputRoot, + preserveCodexSecurityPluginRegistration, requirePrivateCredentialHome, requirePrivateCredentialFile, requirePrivateOutputDirectory, @@ -1498,6 +1499,51 @@ describe("plugin runtime preparation", () => { ]); }); + test("does not preserve a different marketplace when numeric identities collide", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + const marketplace = join(home, "sdk-marketplace"); + const differentSource = join(home, "different-marketplace"); + await mkdir(marketplace, { recursive: true }); + await mkdir(differentSource); + await writeFile( + join(home, "config.toml"), + `[marketplaces.codex-security-sdk]\nsource_type = "local"\nsource = ${JSON.stringify(differentSource)}\n[plugins."codex-security@codex-security-sdk"]\nenabled = true\n`, + ); + const originalStat = fsPromises.stat; + const firstExactIdentity = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + const inspectMarketplaces = spyOn(fsPromises, "stat").mockImplementation( + async (path, options) => { + const stats = await originalStat(path, options as never); + const value = String(path); + if (value !== marketplace && value !== differentSource) { + return stats as never; + } + const exactIdentity = + firstExactIdentity + (value === marketplace ? 1n : 0n); + return Object.assign( + Object.create(Object.getPrototypeOf(stats)), + stats, + { + ino: + typeof stats.ino === "bigint" + ? exactIdentity + : Number(exactIdentity), + }, + ) as never; + }, + ); + const config = { model: "comparison-model" }; + + try { + expect(await preserveCodexSecurityPluginRegistration(home, config)).toBe( + config, + ); + } finally { + inspectMarketplaces.mockRestore(); + } + }); + test("refreshes cached plugins before forwarding delegated scan attribution", async () => { const root = await temporaryDirectory(); const previous = await plugin(join(root, "previous"), "0.1.19"); @@ -2043,7 +2089,7 @@ describe("runtime directories and plugin Python boundary", () => { const home = await prepareCodexSecurityCredentialHome({ CODEX_SECURITY_STATE_DIR: join(root, "state"), }); - const stale = await lstat(home); + const stale = await lstat(home, { bigint: true }); await rename(home, join(root, "original-home")); await mkdir(home, { mode: 0o700 }); @@ -2988,6 +3034,47 @@ describe("runtime directories and plugin Python boundary", () => { } }); + test("rejects replacement credential homes when numeric identities collide", async () => { + const root = await temporaryDirectory(); + const home = join(root, "home"); + await mkdir(home); + const canonicalHome = await realpath(home); + const originalLstat = fsPromises.lstat; + const firstExactIdentity = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + let homeInspections = 0; + const inspectHome = spyOn(fsPromises, "lstat").mockImplementation( + async (path, options) => { + const stats = await originalLstat(path, options as never); + if (String(path) !== home && String(path) !== canonicalHome) { + return stats as never; + } + const exactIdentity = + firstExactIdentity + (homeInspections++ === 0 ? 0n : 1n); + return Object.assign( + Object.create(Object.getPrototypeOf(stats)), + stats, + { + ino: + typeof stats.ino === "bigint" + ? exactIdentity + : Number(exactIdentity), + }, + ) as never; + }, + ); + + try { + await expect( + requireSecureCredentialHome(home, { + platform: "win32", + secureWindowsHome: async () => {}, + }), + ).rejects.toThrow("credential home was replaced"); + } finally { + inspectHome.mockRestore(); + } + }); + test("revalidates the Windows credential ACL every time the home is used", async () => { const root = await temporaryDirectory(); const home = join(root, "home");