Skip to content
Open
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
12 changes: 11 additions & 1 deletion sdk/typescript/src/bulk-scan-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { execFile as execFileCallback } from "node:child_process";
import { createHash } from "node:crypto";
import { lstat, mkdir, writeFile } from "node:fs/promises";
import { chmod, lstat, mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { stdin } from "node:process";
import { Writable } from "node:stream";
import { promisify } from "node:util";
import { confirm, input, search } from "@inquirer/prompts";
import { Octokit } from "@octokit/core";
import Papa from "papaparse";
import { requirePrivateScanOutput } from "./runtime.js";
import { resolveTrustedExecutable } from "./trusted-executable.js";

const execFile = promisify(execFileCallback);
Expand Down Expand Up @@ -178,6 +179,15 @@ export async function runBulkScanWizard(
signal?.throwIfAborted();

await mkdir(outputDir, { recursive: true, mode: 0o700 });
let prepared = await lstat(outputDir);
if (!prepared.isDirectory() || prepared.isSymbolicLink()) {
throw new Error("The scan output must be a non-symlink directory.");
}
if (process.platform !== "win32" && (prepared.mode & 0o777) !== 0o700) {
await chmod(outputDir, 0o700);
prepared = await lstat(outputDir);
}
await requirePrivateScanOutput(prepared, outputDir);
await writeFile(
inputPath,
`${Papa.unparse(
Expand Down
18 changes: 12 additions & 6 deletions sdk/typescript/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
ScanManifest,
} from "./models.js";
import {
requirePrivateOutputDirectory,
requirePrivateScanOutput,
requireSecureOutputAncestry,
} from "./runtime.js";
import type { NormalizedTarget, ScanMode } from "./targets.js";
Expand Down Expand Up @@ -586,8 +586,9 @@ async function requireScanRoot(
throw new Error("not a directory");
}
try {
requirePrivateOutputDirectory(returned, canonical);
await requireSecureOutputAncestry(canonical);
const secured = await requirePrivateScanOutput(returned, canonical);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reapplying the ACL for every artifact check

On Windows, every requireCheckedScanFile call invokes requireScanRoot, so this line starts PowerShell and reapplies the directory DACL for each canonical document, sealed artifact, finding writeup, and hardening report. A scan containing many artifacts can therefore launch hundreds or thousands of PowerShell processes—and potentially propagate the inheritable ACL through the tree each time—turning contract validation into minutes of work or a timeout. Secure the root once per load and use an identity/ACL verification-only check for subsequent file validations.

Useful? React with 👍 / 👎.

await requireSecureOutputAncestry(secured.path);
return { path: secured.path, metadata: secured.metadata };
} catch (error) {
throw new ContractValidationError(
error instanceof Error
Expand All @@ -596,7 +597,6 @@ async function requireScanRoot(
{ cause: error },
);
}
return { path: canonical, metadata: returned };
} catch (error) {
throwIfAborted(signal);
if (error instanceof ContractValidationError) throw error;
Expand All @@ -622,8 +622,14 @@ async function verifyScanRoot(
) {
throw new Error("scan directory changed while reading");
}
requirePrivateOutputDirectory(current, root.path);
await requireSecureOutputAncestry(root.path);
const secured = await requirePrivateScanOutput(current, root.path);
if (
secured.metadata.dev !== root.metadata.dev ||
secured.metadata.ino !== root.metadata.ino
) {
throw new Error("scan directory changed while reading");
}
await requireSecureOutputAncestry(secured.path);
} catch (error) {
throwIfAborted(signal);
throw new ContractValidationError(
Expand Down
11 changes: 11 additions & 0 deletions sdk/typescript/src/multiscan.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { execFile as execFileCallback } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
chmod,
lstat,
mkdir,
open,
Expand All @@ -18,6 +19,7 @@ import type { CodexSecurity } from "./api.js";
import type { CodexSecurityConfig } from "./config.js";
import type { ScanCost } from "./cost.js";
import { redactedErrorMessage } from "./errors.js";
import { requirePrivateScanOutput } from "./runtime.js";
import type { ScanMode } from "./targets.js";
import { resolveTrustedExecutable } from "./trusted-executable.js";

Expand Down Expand Up @@ -257,6 +259,15 @@ async function ensureOutputDirectory(path: string): Promise<void> {
throw new Error("Multiscan output directories must not be symbolic links.");
}
await mkdir(path, { recursive: true, mode: 0o700 });
let prepared = await lstat(path);
if (!prepared.isDirectory() || prepared.isSymbolicLink()) {
throw new Error("Multiscan output must be a non-symlink directory.");
}
if (process.platform !== "win32" && (prepared.mode & 0o777) !== 0o700) {
await chmod(path, 0o700);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check directory ownership before changing its mode

When multiscan is run as root against an existing output directory owned by another UID and its mode is not already 0700, this chmod succeeds and changes the directory's permissions, but the immediately following requirePrivateScanOutput rejects it because it is not owned by the effective UID. The failed command therefore leaves a foreign or host-mounted directory modified; the wizard repeats the ordering at bulk-scan-discovery.ts:186-190. Validate ownership before normalizing the mode so rejected directories remain untouched.

Useful? React with 👍 / 👎.

prepared = await lstat(path);
}
await requirePrivateScanOutput(prepared, path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Continue with the rebound output path

When the selected output path contains a replaceable symlink/junction ancestor, this discards the canonical path returned by requirePrivateScanOutput, and runMultiscan subsequently creates the lock, checkouts, and artifacts through the original alias. An actor able to retarget that ancestor after validation can therefore redirect all later writes outside the ACL-hardened directory; the wizard has the same issue at bulk-scan-discovery.ts:190-192 because inputPath is based on the original path. Fresh evidence at this head is that the new rebinding helper now returns secured.path, but both new call sites still ignore it; return and propagate that path before performing any writes.

Useful? React with 👍 / 👎.

}

async function appendReceipt(path: string, receipt: string): Promise<void> {
Expand Down
110 changes: 98 additions & 12 deletions sdk/typescript/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,18 @@ export async function requirePrivateCredentialHome(
}

async function secureWindowsCredentialHome(path: string): Promise<void> {
await secureWindowsPrivateDirectory(path, "Credential");
}

async function secureWindowsScanOutput(path: string): Promise<void> {
await secureWindowsPrivateDirectory(path, "Scan output");
}

/** Restrict a Windows directory ACL to the current user, then verify. */
async function secureWindowsPrivateDirectory(
path: string,
label: "Credential" | "Scan output",
): Promise<void> {
const systemRoot = process.env["SystemRoot"] ?? "C:\\Windows";
const powershell = join(
systemRoot,
Expand All @@ -279,7 +291,8 @@ async function secureWindowsCredentialHome(path: string): Promise<void> {
);
const script = [
"$ErrorActionPreference = 'Stop'",
"$path = [Environment]::GetEnvironmentVariable('CODEX_SECURITY_CREDENTIAL_ACL_PATH', 'Process')",
"$path = [Environment]::GetEnvironmentVariable('CODEX_SECURITY_PRIVATE_ACL_PATH', 'Process')",
"$label = [Environment]::GetEnvironmentVariable('CODEX_SECURITY_PRIVATE_ACL_LABEL', 'Process')",
"$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()",
"if ($null -eq $identity.User) { throw 'Unable to identify the current Windows user' }",
"$acl = New-Object System.Security.AccessControl.DirectorySecurity",
Expand All @@ -290,17 +303,18 @@ async function secureWindowsCredentialHome(path: string): Promise<void> {
"$acl.SetAccessRule($rule)",
"[System.IO.Directory]::SetAccessControl($path, $acl)",
"$verified = [System.IO.Directory]::GetAccessControl($path)",
"if (-not $verified.AreAccessRulesProtected) { throw 'Credential ACL still inherits access rules' }",
'if (-not $verified.AreAccessRulesProtected) { throw "$label ACL still inherits access rules" }',
"$unexpected = @($verified.Access | Where-Object { $_.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow -and $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -ne $identity.User.Value })",
"if ($unexpected.Count -ne 0) { throw 'Credential ACL grants access to another identity' }",
'if ($unexpected.Count -ne 0) { throw "$label ACL grants access to another identity" }',
].join("; ");
await execFile(
powershell,
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
{
env: {
...process.env,
CODEX_SECURITY_CREDENTIAL_ACL_PATH: path,
CODEX_SECURITY_PRIVATE_ACL_PATH: path,
CODEX_SECURITY_PRIVATE_ACL_LABEL: label,
},
encoding: "utf8",
windowsHide: true,
Expand Down Expand Up @@ -704,11 +718,9 @@ export async function validateOutputDir(
`Scan output directory is not empty: ${path}. To keep the existing results and start a new scan, add --archive-existing.`,
);
}
requirePrivateOutputDirectory(metadata, path);
await requireSecureOutputAncestry(path);
const canonical = await realpath(path);
requireModelSafeOutputDir(canonical);
return canonical;
const secured = await requirePrivateScanOutput(metadata, path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the output location before replacing its ACL

On Windows, an existing empty directory is ACL-hardened here before the API checks whether the canonical output is an allowed location: api.ts:1260-1265 calls validateOutputDir and only afterward runs requireOutputOutsideRepository. If a user accidentally selects an empty directory inside the scanned repository, the scan is correctly rejected but its DACL has already been replaced with a current-user-only ACL, potentially removing access for collaborators or services. Defer this mutating ACL operation until all location validation has succeeded.

Useful? React with 👍 / 👎.

await requireSecureOutputAncestry(secured.path);
return secured.path;
}

let parent = dirname(path);
Expand Down Expand Up @@ -829,6 +841,10 @@ export async function prepareOutputDir(
export async function validatePreparedOutputDir(
path: string,
validateLocation?: (path: string) => void,
options: {
platform?: NodeJS.Platform;
secureWindowsOutput?: (path: string) => Promise<void>;
} = {},
): Promise<string> {
const metadata = await lstat(path);
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
Expand All @@ -843,9 +859,79 @@ export async function validatePreparedOutputDir(
`Scan output directory must be empty: ${path}`,
);
}
requirePrivateOutputDirectory(metadata, path);
await requireSecureOutputAncestry(canonical);
return canonical;
const secured = await requirePrivateScanOutput(metadata, path, options);
await requireSecureOutputAncestry(secured.path);
return secured.path;
}

/**
* Enforce that scan output stays private to the current user.
* On Windows this applies and verifies a current-user-only ACL (same boundary
* as credential homes), then re-binds the path to the same directory identity.
* On POSIX this checks mode/owner and re-binds the canonical path.
*/
export async function requirePrivateScanOutput(
metadata: Stats,
path: string,
options: {
platform?: NodeJS.Platform;
secureWindowsOutput?: (path: string) => Promise<void>;
} = {},
): Promise<{ path: string; metadata: Stats }> {
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new OutputDirectoryError(`Scan output is not a directory: ${path}`);
}
if ((options.platform ?? process.platform) !== "win32") {
requirePrivateOutputDirectory(metadata, path);
return await bindPrivateScanOutputPath(path, metadata);
}

try {
await (options.secureWindowsOutput ?? secureWindowsScanOutput)(path);
} catch (error) {
throw new OutputDirectoryError(
`Unable to create a private Windows scan output directory: ${path}`,
{ cause: error },
);
}
return await bindPrivateScanOutputPath(path, metadata);
}

async function bindPrivateScanOutputPath(
path: string,
expected: Pick<Stats, "dev" | "ino">,
): Promise<{ path: string; metadata: Stats }> {
let after: Stats;
try {
after = await lstat(path);
} catch (error) {
throw new OutputDirectoryError(
`Unable to inspect scan output directory: ${path}`,
{ cause: error },
);
}
if (!after.isDirectory() || after.isSymbolicLink()) {
throw new OutputDirectoryError(`Scan output is not a directory: ${path}`);
}
if (after.dev !== expected.dev || after.ino !== expected.ino) {
throw new OutputDirectoryError(
`Scan output directory was replaced: ${path}`,
);
}
const canonical = await realpath(path);
requireModelSafeOutputDir(canonical);
const canonicalMetadata = await lstat(canonical);
if (
!canonicalMetadata.isDirectory() ||
canonicalMetadata.isSymbolicLink() ||
canonicalMetadata.dev !== expected.dev ||
canonicalMetadata.ino !== expected.ino
) {
throw new OutputDirectoryError(
`Scan output directory was replaced: ${canonical}`,
);
}
return { path: canonical, metadata: canonicalMetadata };
}

export function requirePrivateOutputDirectory(
Expand Down
6 changes: 5 additions & 1 deletion sdk/typescript/tests-ts/multiscan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
import {
access,
appendFile,
chmod,
mkdir,
mkdtemp,
readFile,
Expand Down Expand Up @@ -631,7 +632,10 @@ describe("multiscan", () => {
const preserved = join(external, "victim", "keep.txt");
await mkdir(join(external, "victim"), { recursive: true });
await writeFile(preserved, "preserved\n");
if (directory) await mkdir(paths.output);
if (directory) {
await mkdir(paths.output, { mode: 0o700 });
if (process.platform !== "win32") await chmod(paths.output, 0o700);
}
await symlink(
external,
directory ? join(paths.output, directory) : paths.output,
Expand Down
Loading