-
Notifications
You must be signed in to change notification settings - Fork 577
fix: apply Windows private ACLs to scan output directories #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
387896d
d2fa431
55b9aac
b196fa2
695fffd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
|
|
@@ -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"; | ||
|
|
||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When multiscan is run as root against an existing output directory owned by another UID and its mode is not already Useful? React with 👍 / 👎. |
||
| prepared = await lstat(path); | ||
| } | ||
| await requirePrivateScanOutput(prepared, path); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the selected output path contains a replaceable symlink/junction ancestor, this discards the canonical Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| async function appendReceipt(path: string, receipt: string): Promise<void> { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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", | ||
|
|
@@ -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, | ||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Windows, an existing empty directory is ACL-hardened here before the API checks whether the canonical output is an allowed location: Useful? React with 👍 / 👎. |
||
| await requireSecureOutputAncestry(secured.path); | ||
| return secured.path; | ||
| } | ||
|
|
||
| let parent = dirname(path); | ||
|
|
@@ -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()) { | ||
|
|
@@ -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( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On Windows, every
requireCheckedScanFilecall invokesrequireScanRoot, 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 👍 / 👎.