diff --git a/README.md b/README.md index 293ae1eb..45a26fd9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,19 @@ For CI, set `OPENAI_API_KEY` or `CODEX_API_KEY` instead of signing in. Environme passed directly to the current scan and are never stored in Codex's credential home or system keyring. +Azure OpenAI API-key scans use the Azure deployment name as the model: + +```bash +export AZURE_OPENAI_API_KEY="" +npx @openai/codex-security scan . \ + --azure-endpoint https://my-resource.openai.azure.com \ + --model my-security-deployment +``` + +A resource-root endpoint is normalized to `/openai/v1`. +Azure OpenAI currently uses environment API-key authentication rather than +`codex-security login`. + Local sign-in honors Codex's configured credential backend, including a system keyring required by a managed device. Codex Security keeps login and scan credentials in the same private, persistent state directory. diff --git a/SECURITY.md b/SECURITY.md index b2ca5bf7..692401b9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -72,8 +72,12 @@ restrictions still apply. Scan and workbench subprocesses can inherit your environment. The workbench removes `OPENAI_API_KEY` and `CODEX_API_KEY`, but it does not remove every credential. Other variables, such as `GITHUB_TOKEN` or `AWS_SECRET_ACCESS_KEY`, -can remain available to local subprocesses. Run a scan with only the -environment credentials it needs. +can remain available to local subprocesses. For Azure scans, +`AZURE_OPENAI_API_KEY` is removed from plugin-Python, workbench, and bootstrap +helper processes, supplied separately to the parent Codex model runtime, and +added to the Codex shell-environment exclusions. Host tools such as Git can +still inherit ambient variables. Run a scan with only the environment +credentials it needs. ### Security boundaries diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 9e1f4a51..5d15a7ec 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -66,6 +66,27 @@ Pass runtime configuration to the `CodexSecurity` constructor: | `pluginPath` | Use a Codex Security plugin directory or ZIP instead of the bundled plugin. | | `pythonPath` | Select the Python interpreter before consulting `PYTHON`. | | `codexOverrides` | Deep-merge supported settings into the isolated Codex configuration. | +| `azureOpenAI` | Select an Azure OpenAI resource endpoint for API-key scans. | + +For Azure OpenAI, set `AZURE_OPENAI_API_KEY` and use the deployment name as +`model`: + +```ts +import { CodexSecurity } from "@openai/codex-security"; + +const security = new CodexSecurity({ + azureOpenAI: { + endpoint: "https://my-resource.openai.azure.com", + }, + codexOverrides: { + model: "my-security-deployment", + }, +}); +``` + +`azureOpenAI` uses Azure's v1 API. A resource-root endpoint is normalized to +`/openai/v1`; a URL ending in `/openai` gets `/v1` appended. Endpoints must use +HTTPS and must not contain credentials, query parameters, or fragments. Pass scan configuration to `security.run(repository, options)` or `security.preflight(repository, options)`: @@ -127,6 +148,17 @@ $env:OPENAI_API_KEY = "" npx @openai/codex-security scan C:\code\repository ``` +Azure OpenAI uses `AZURE_OPENAI_API_KEY` directly from the environment; it is +not stored by `codex-security login`. Pass `--azure-endpoint` and use the Azure +deployment name with `--model`. The integration currently supports API-key +authentication only; it does not support Microsoft Entra ID, managed identity, +or `--auth chatgpt`. + +For repeatable Azure rate-limit failures, review the deployment's TPM/RPM +allocation in Foundry, reallocate or request more quota, or use a deployment +with capacity in another region. See Microsoft's +[Azure OpenAI quota guide](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/quota). + Check or remove the stored sign-in with `npx @openai/codex-security login status` and `npx @openai/codex-security logout`. Codex Security keeps its sign-in in a private, stable Codex home at `$CODEX_SECURITY_STATE_DIR/codex-home`, or at @@ -150,9 +182,10 @@ npx @openai/codex-security scan . --auth api-key ``` `--auth chatgpt` uses the stored sign-in and ignores `OPENAI_API_KEY` and -`CODEX_API_KEY`. `--auth api-key` requires one of those environment variables. -Omit `--auth`, or pass `--auth auto`, to preserve automatic API-key precedence -for existing CI and unattended scans. The SDK accepts the same selection as +`CODEX_API_KEY`. For OpenAI, `--auth api-key` requires one of those variables; +with `--azure-endpoint`, it requires `AZURE_OPENAI_API_KEY`. Omit `--auth`, or +pass `--auth auto`, to preserve automatic API-key precedence for existing CI +and unattended scans. The SDK accepts the same selection as `security.run(repository, { auth: "chatgpt" })` and `security.preflight(repository, { auth: "chatgpt" })`. @@ -183,9 +216,11 @@ npx @openai/codex-security scan /path/to/repository --output-dir /path/outside/r npx @openai/codex-security scan /path/to/repository --dry-run npx @openai/codex-security scan /path/to/repository --fail-on-severity high npx @openai/codex-security scan /path/to/repository --max-cost 5 +npx @openai/codex-security scan /path/to/repository --azure-endpoint https://my-resource.openai.azure.com --model my-security-deployment npx @openai/codex-security install-hook npx @openai/codex-security bulk-scan npx @openai/codex-security bulk-scan --model gpt-5.6-terra --effort high +npx @openai/codex-security bulk-scan repositories.csv --output-dir /path/outside/repositories/security-scans --azure-endpoint https://my-resource.openai.azure.com --model my-security-deployment npx @openai/codex-security bulk-scan repositories.csv --output-dir /path/outside/repositories/security-scans --workers 4 npx @openai/codex-security scans list /path/to/repository npx @openai/codex-security scans list --scan-root /path/outside/repository/results @@ -240,9 +275,10 @@ result to stdout and a coverage warning to stderr, including in report-only mode. Scans use `gpt-5.6-sol` with extra-high reasoning effort by default. OpenAI is -the implied provider. Use `--model gpt-5.6-terra` to switch models and -`--effort minimal|low|medium|high|xhigh` to set reasoning effort. Repeat -`--codex KEY=VALUE` for other Codex settings; existing +the implied provider unless `--azure-endpoint` configures Azure OpenAI. Use +`--model gpt-5.6-terra` to switch OpenAI models; with Azure, `--model` is the +deployment name. Use `--effort minimal|low|medium|high|xhigh` to set reasoning +effort. Repeat `--codex KEY=VALUE` for other Codex settings; existing `--codex 'model_reasoning_effort="high"'` overrides remain supported. ### Runtime configuration and worker limits @@ -297,7 +333,8 @@ multi-agent v2 must remain enabled. The legacy `agents.max_threads` setting and `features.multi_agent_v2.enabled=false` are incompatible and rejected. `validate` and `patch` accept `--effort` and only the `model` and `model_reasoning_effort` `--codex` keys; they do not accept general scan -runtime overrides. +runtime overrides. Azure's `--azure-endpoint` option is currently available to +`scan` and `bulk-scan`; it is not available to `validate` or `patch`. These overrides do not change the scan's approval policy or filesystem permissions. See [Local security model](#local-security-model). @@ -338,6 +375,7 @@ The CLI and SDK recognize the following user-configurable environment: | Variable | Effect | | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `OPENAI_API_KEY`, `CODEX_API_KEY` | Scan authentication; `OPENAI_API_KEY` wins when both are present. | +| `AZURE_OPENAI_API_KEY` | Azure OpenAI scan authentication when an Azure endpoint is configured. | | `CODEX_SECURITY_STATE_DIR` | Override the private scan-history, workbench, and default artifact directory. | | `CODEX_HOME` | Set the ambient Codex home for file-backed sign-in and default state; defaults to `~/.codex`. | | `PYTHON` | Select a Python interpreter when `--python` or SDK `pythonPath` is not set. | @@ -373,14 +411,17 @@ token and worker counts, estimated cost, the results directory, and the next useful command. Progress and summaries use stderr; structured scan results remain on stdout. -Each scan records its model, tokens, and estimated cost in its JSON result, -scan history, and bulk-scan receipt. Estimates use +Each scan records its model and token usage in its JSON result, scan history, +and bulk-scan receipt. For the built-in OpenAI provider, it also records an +estimated cost using [standard API token prices](https://developers.openai.com/api/docs/models/compare), including cached input and cache writes; fees and surcharges are not included. Use `--max-cost USD` to stop a scan, including its delegated workers, when its running cost exceeds the limit. Partial results are preserved. Requests -already in progress can finish above the limit. +already in progress can finish above the limit. Cost estimates and cost limits +are not available for non-OpenAI providers: Azure scans report a null estimated +cost and reject CLI `--max-cost` or SDK `maxCostUsd`. Run `npx @openai/codex-security scan --help` or `npx @openai/codex-security bulk-scan --help` for the complete CLI references. @@ -419,7 +460,9 @@ least eight characters. Scan history uses the existing Codex Security workbench database at `$CODEX_HOME/state/plugins/codex-security/workbench.sqlite3`. Set `CODEX_SECURITY_STATE_DIR` to place the database elsewhere. Scan credentials -are never stored in the scan configuration. +are never stored in the scan configuration. An Azure recipe retains only the +normalized endpoint and replay-safe scan configuration; the provider definition +is reconstructed, and the API-key value is never stored. The scan sandbox permits writes to the selected state directory so SQLite can maintain its database and journal files. If the host itself cannot write to the @@ -434,7 +477,8 @@ a false positive and explain why. Later scans dismiss a matching finding only when the same reason still applies. `scans rerun SCAN_ID` repeats the original configuration against the current -checkout so a fixed vulnerability can be checked again. +checkout so a fixed vulnerability can be checked again. Before rerunning an +Azure scan, set `AZURE_OPENAI_API_KEY` again in the current environment. `scans match BEFORE_SCAN_ID AFTER_SCAN_ID` links findings with the same root cause; `scans match --all` matches all completed scans of the current repository, @@ -530,8 +574,11 @@ make them more restrictive. Independently enforced host and network restrictions still apply. Scan and workbench subprocesses can inherit your environment, including -unrelated API tokens and cloud credentials. Start a scan with only the -credentials it needs. +unrelated API tokens and cloud credentials. For Azure scans, +`AZURE_OPENAI_API_KEY` is removed from plugin-Python, workbench, and bootstrap +helper processes, supplied separately to the parent Codex model runtime, and +excluded from model-run shell commands. Host tools such as Git can still +inherit ambient variables. Start a scan with only the credentials it needs. The scanner must stay within the target and output paths you authorize and must not disclose private data beyond the operation you requested. Its results diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index d2381d3d..3278d8d0 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -13,6 +13,7 @@ import { type AccountStatus, } from "./auth.js"; import { + AZURE_OPENAI_PROVIDER_ID, mergedCodexConfig, scanModelConfiguration, type CodexSecurityConfig, @@ -28,6 +29,7 @@ import { import { AuthenticationRequiredError, CodexSecurityError, + ConfigurationError, IncompleteScanError, OutputDirectoryError, OutputInsideProtectedRootError, @@ -39,7 +41,11 @@ import { prepareKnowledgeBase, type PreparedKnowledgeBase, } from "./knowledge-base.js"; -import { ScanResult, type TurnResultMetadata } from "./result.js"; +import { + createScanResult, + ScanResult, + type TurnResultMetadata, +} from "./result.js"; import type { SeverityLevel } from "./models.js"; import { workerStatusFromEvent, @@ -152,10 +158,17 @@ export type ScanAuthentication = method: "api_key"; source: "OPENAI_API_KEY" | "CODEX_API_KEY"; verified: false; + provider?: never; } | { method: "stored_credentials"; verified: false; + } + | { + method: "api_key"; + provider: "azure"; + source: "AZURE_OPENAI_API_KEY"; + verified: false; }; export interface ScanReconnectDetails { @@ -271,7 +284,9 @@ export class CodexSecurity { ); const configuration = await mergedCodexConfig(this.config); const model = scanModelConfiguration(configuration); - validateScanCostLimit(options.maxCostUsd, model.model); + const azureConfigured = this.config.azureOpenAI !== undefined; + const provider = scanModelProvider(configuration, azureConfigured); + validateScanCostLimit(options.maxCostUsd, model.model, provider.id); const archiveDir = options.archiveExisting === true ? await planOutputArchive(inputs.outputDir) @@ -289,6 +304,8 @@ export class CodexSecurity { authentication: scanAuthentication( this.#dependencies.environment, options.auth, + configuration, + azureConfigured, ), ...model, ...(options.maxCostUsd === undefined @@ -360,14 +377,22 @@ export class CodexSecurity { } checkOpen(); + const effectiveConfig = await mergedCodexConfig(this.config); + const azureConfigured = this.config.azureOpenAI !== undefined; const authentication = scanAuthentication( this.#dependencies.environment, options.auth, + effectiveConfig, + azureConfigured, ); const scanEnvironment = selectedScanEnvironment( this.#dependencies.environment, options.auth, ); + const localScanEnvironment = withoutProviderApiKey( + scanEnvironment, + authentication, + ); if ( authentication.method === "stored_credentials" && this.#dependencies.prepareRuntime === undefined @@ -389,13 +414,18 @@ export class CodexSecurity { (path) => requireOutputOutsideRepository(protectedRoot, path, "runtime"), options.auth, + authentication, ); if ( runtime === previousRuntime && runtime.persistentCredentialHome === true && this.#dependencies.prepareRuntime === undefined ) { - await this.#refreshPersistentRuntime(runtime, scanEnvironment, signal); + await this.#refreshPersistentRuntime( + runtime, + localScanEnvironment, + signal, + ); } const runtimeHome = await realpath(runtime.codexHome); requireOutputOutsideRepository(protectedRoot, runtimeHome, "runtime"); @@ -424,7 +454,8 @@ export class CodexSecurity { : null; } const apiKey = - authentication.method === "api_key" + authentication.method === "api_key" && + !isProviderApiKeyAuthentication(authentication) ? environmentApiKey(this.#dependencies.environment) : null; if (apiKey !== null) { @@ -444,7 +475,11 @@ export class CodexSecurity { ? "stored_credentials" : null; } - if (!runtime.credentialsAvailable && apiKey === null) { + if ( + !runtime.credentialsAvailable && + apiKey === null && + !isProviderApiKeyAuthentication(authentication) + ) { throw new AuthenticationRequiredError( "No credentials were found. Run 'codex-security login', use " + "'codex-security login --device-auth' on a remote or headless machine, or set " + @@ -461,7 +496,7 @@ export class CodexSecurity { this.#dependencies.resolvePluginPython ?? resolvePluginPython )({ configuredPath: this.config.pythonPath, - environment: scanEnvironment, + environment: localScanEnvironment, protectedRoot, signal, }); @@ -533,13 +568,13 @@ export class CodexSecurity { mode, pluginVersion: runtime.plugin.version, }; - const effectiveConfig = - runtime.effectiveConfig ?? (await mergedCodexConfig(this.config)); - const { model } = scanModelConfiguration(effectiveConfig); - validateScanCostLimit(options.maxCostUsd, model); + const scanConfig = runtime.effectiveConfig ?? effectiveConfig; + const { model } = scanModelConfiguration(scanConfig); + const modelProvider = scanModelProvider(scanConfig, azureConfigured).id; + validateScanCostLimit(options.maxCostUsd, model, modelProvider); const tracker = new ScanCostTracker({ codexHome: runtime.codexHome, - model, + model: modelProvider === "openai" ? model : undefined, maxCostUsd: options.maxCostUsd, onCost: (cost) => { notifyObserver( @@ -566,7 +601,8 @@ export class CodexSecurity { mode, expectation.repositoryRevision, runtime.plugin.version, - effectiveConfig, + scanConfig, + azureConfigured, options.failureSeverity, knowledgeBase?.sources, options.maxCostUsd, @@ -575,7 +611,10 @@ export class CodexSecurity { python, pluginRoot: runtime.plugin.pluginRoot, environment: { - ...selectedScanEnvironment(runtime.environment, options.auth), + ...withoutProviderApiKey( + selectedScanEnvironment(runtime.environment, options.auth), + authentication, + ), CODEX_SECURITY_STATE_DIR: stateDirectory, }, signal, @@ -730,7 +769,10 @@ export class CodexSecurity { ...pluginExecutionEnvironment( python, withoutCodexHome( - selectedScanEnvironment(runtime.environment, options.auth), + withoutProviderApiKey( + selectedScanEnvironment(runtime.environment, options.auth), + authentication, + ), ), ), CODEX_HOME: runtime.codexHome, @@ -738,9 +780,13 @@ export class CodexSecurity { }; const codex = this.#dependencies.createCodex({ ...(apiKey === null ? {} : { apiKey }), - env: definedEnvironment( - selectedScanEnvironment(environment, "chatgpt"), - ), + env: definedEnvironment({ + ...selectedScanEnvironment(environment, "chatgpt"), + ...providerApiKeyEnvironment( + this.#dependencies.environment, + authentication, + ), + }), config: { default_permissions: SCAN_PERMISSION_PROFILE, allow_login_shell: false, @@ -782,6 +828,8 @@ export class CodexSecurity { expectation, workbenchValidated: true, model, + modelProvider: modelProvider === "openai" ? undefined : modelProvider, + estimateCost: modelProvider === "openai", onThreadStarted: (threadId) => tracker.start(threadId), onFinalize: async (usage) => { const snapshot = await tracker.stop(usage); @@ -945,19 +993,41 @@ export class CodexSecurity { } public async account(): Promise { - return await this.#runOperation(async (runtime, signal) => { - const apiKey = environmentApiKey(this.#dependencies.environment); - if (apiKey !== null) { + return await this.#trackOperation(async () => { + const configuration = await mergedCodexConfig(this.config); + this.#requireOpen(); + const provider = scanModelProvider( + configuration, + this.config.azureOpenAI !== undefined, + ); + if (provider.kind === "environment") { + const configured = + environmentValue(this.#dependencies.environment, provider.envKey) !== + undefined; return { - authenticated: true, - details: "Authenticated with an API key.", + authenticated: configured, + details: configured + ? `Authenticated with an API key from ${provider.envKey} for model provider ${provider.id}.` + : `Not authenticated for model provider ${provider.id}; set ${provider.envKey}.`, }; } - return await accountStatus( - this.#codexCommand(), - runtime.environment, - signal, - ); + const signal = this.#abortController.signal; + const runtime = await this.#ensureRuntime(signal); + this.#requireOpen(); + const apiKey = environmentApiKey(this.#dependencies.environment); + const result = + apiKey !== null + ? { + authenticated: true, + details: "Authenticated with an API key.", + } + : await accountStatus( + this.#codexCommand(), + runtime.environment, + signal, + ); + this.#requireOpen(); + return result; }); } @@ -1072,12 +1142,15 @@ export class CodexSecurity { temporaryRoot?: string, validateLocation?: (path: string) => void, auth: ScanAuthMode = "auto", + authentication?: ScanAuthentication, ): Promise { this.#requireOpen(); if (this.#runtime !== null) { const usePersistentCredentials = - scanAuthentication(this.#dependencies.environment, auth).method === - "stored_credentials"; + ( + authentication ?? + scanAuthentication(this.#dependencies.environment, auth) + ).method === "stored_credentials"; if ( this.#dependencies.prepareRuntime !== undefined || this.#runtime.persistentCredentialHome === undefined || @@ -1097,6 +1170,7 @@ export class CodexSecurity { temporaryRoot, validateLocation, auth, + authentication, ); this.#runtimePromise = runtimePromise; void runtimePromise.catch(() => { @@ -1140,6 +1214,7 @@ export class CodexSecurity { mergedConfig, codexSecurityStateDirectory(environment), runtime.codexHome, + this.config.azureOpenAI !== undefined, ), ); await writeCodexConfig(join(runtime.codexHome, "config.toml"), config); @@ -1205,17 +1280,20 @@ export class CodexSecurity { temporaryRoot?: string, validateLocation?: (path: string) => void, auth: ScanAuthMode = "auto", + authentication?: ScanAuthentication, ): Promise { if (this.#dependencies.prepareRuntime !== undefined) { return await this.#dependencies.prepareRuntime(this.config, signal); } - const processEnvironment = selectedScanEnvironment( - this.#dependencies.environment, - auth, + const resolvedAuthentication = + authentication ?? + scanAuthentication(this.#dependencies.environment, auth); + const processEnvironment = withoutProviderApiKey( + selectedScanEnvironment(this.#dependencies.environment, auth), + resolvedAuthentication, ); const persistentCredentialHome = - scanAuthentication(this.#dependencies.environment, auth).method === - "stored_credentials"; + resolvedAuthentication.method === "stored_credentials"; const codexHome = persistentCredentialHome ? await prepareCodexSecurityCredentialHome( processEnvironment, @@ -1247,6 +1325,7 @@ export class CodexSecurity { mergedConfig, codexSecurityStateDirectory(processEnvironment), persistentCredentialHome ? codexHome : undefined, + this.config.azureOpenAI !== undefined, ), ); await writeCodexConfig(join(codexHome, "config.toml"), codexConfig); @@ -1260,11 +1339,15 @@ export class CodexSecurity { environment: withoutCodexHome(processEnvironment), signal, }); - const credentialsAvailable = await initialCredentialsAvailable( - processEnvironment, - ambientHome, - codexHome, - ); + const credentialsAvailable = isProviderApiKeyAuthentication( + resolvedAuthentication, + ) + ? false + : await initialCredentialsAvailable( + processEnvironment, + ambientHome, + codexHome, + ); return { codexHome, persistentCredentialHome, @@ -1339,6 +1422,8 @@ interface ScanEventRunOptions { expectation: ScanExpectation; workbenchValidated?: boolean; model?: string; + modelProvider?: string; + estimateCost?: boolean; onFinalize?: (usage: unknown) => Promise; onThreadStarted?: (threadId: string) => void; onScanStarted?: () => void; @@ -1451,6 +1536,9 @@ export async function runScanEvents( finalResponse, usage, ...(options.model === undefined ? {} : { model: options.model }), + ...(options.modelProvider === undefined + ? {} + : { modelProvider: options.modelProvider }), }, threadId, options.scanDir, @@ -1458,6 +1546,7 @@ export async function runScanEvents( options.expectation, options.signal, options.workbenchValidated, + options.estimateCost, ); if (options.signal.aborted) { throw new ScanInterruptedError( @@ -1561,10 +1650,12 @@ function scanRecipe( repositoryRevision: string | null, pluginVersion: string, effectiveConfig: JsonObject, + azureConfigured: boolean, failOnSeverity?: SeverityLevel, knowledgeBasePaths?: string[], maxCostUsd?: number, ): JsonObject { + const provider = scanModelProvider(effectiveConfig, azureConfigured); return { repository, target: { @@ -1578,18 +1669,147 @@ function scanRecipe( mode, ...(repositoryRevision === null ? {} : { repositoryRevision }), pluginVersion, - config: scanPreflightCodexConfig(effectiveConfig), + config: scanReplayCodexConfig(effectiveConfig, azureConfigured), + ...(provider.kind === "openai" + ? {} + : { + azureOpenAI: { + endpoint: provider.replayableDefinition["base_url"] as string, + }, + }), ...(failOnSeverity === undefined ? {} : { failOnSeverity }), ...(knowledgeBasePaths === undefined ? {} : { knowledgeBasePaths }), ...(maxCostUsd === undefined ? {} : { maxCostUsd }), }; } +export function scanReplayCodexConfig( + config: JsonObject, + azureConfigured = false, +): JsonObject { + const result = scanPreflightCodexConfig(config); + const provider = scanModelProvider(config, azureConfigured); + if (provider.kind === "openai") return result; + const model = config["model"]; + if ( + typeof model !== "string" || + model.length === 0 || + model.length > 256 || + /[\u0000-\u001f\u007f]/u.test(model) + ) { + throw new ConfigurationError( + "The Azure deployment name cannot be stored safely for reruns.", + ); + } + result["model"] = model; + delete result["model_provider"]; + delete result["model_providers"]; + return result; +} + +function replayableAzureProviderDefinition( + definition: Readonly, +): JsonObject { + const allowedKeys = new Set([ + "name", + "base_url", + "env_key", + "wire_api", + "request_max_retries", + "stream_max_retries", + "stream_idle_timeout_ms", + ]); + if (Object.keys(definition).some((key) => !allowedKeys.has(key))) { + throw new ConfigurationError( + "The azure model provider contains settings that cannot be stored safely for reruns.", + ); + } + + const replayable: JsonObject = {}; + const name = definition["name"]; + if (name !== undefined) { + if ( + typeof name !== "string" || + name.length === 0 || + name.length > 256 || + /[\u0000-\u001f\u007f]/u.test(name) + ) { + throw new ConfigurationError( + "The azure model provider has an invalid name.", + ); + } + replayable["name"] = name; + } + const baseUrl = replayableProviderBaseUrl(definition["base_url"]); + if (baseUrl === undefined) { + throw new ConfigurationError( + "The azure model provider must use an HTTPS /openai/v1 base_url without credentials, query parameters, or a fragment.", + ); + } + replayable["base_url"] = baseUrl; + replayable["env_key"] = "AZURE_OPENAI_API_KEY"; + replayable["wire_api"] = "responses"; + for (const key of [ + "request_max_retries", + "stream_max_retries", + "stream_idle_timeout_ms", + ]) { + const value = definition[key]; + if (value === undefined) continue; + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < 0 || + value > 1_000_000 + ) { + throw new ConfigurationError( + `The azure model provider has an invalid ${key}.`, + ); + } + replayable[key] = value; + } + return replayable; +} + +function replayableProviderBaseUrl(value: unknown): string | undefined { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 2_048 || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + return undefined; + } + try { + const url = new URL(value); + if ( + url.protocol !== "https:" || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" || + url.pathname.replace(/\/+$/u, "").toLowerCase() !== "/openai/v1" + ) { + return undefined; + } + url.pathname = "/openai/v1"; + return url.toString(); + } catch { + return undefined; + } +} + function validateScanCostLimit( maxCostUsd: number | undefined, model: string, + modelProvider = "openai", ): void { if (maxCostUsd === undefined) return; + if (modelProvider !== "openai") { + throw new ConfigurationError( + `A scan cost limit is not available for the configured model provider: ${modelProvider}.`, + ); + } if (estimateScanCost(model, { input_tokens: 0, output_tokens: 0 }) === null) { throw new CodexSecurityError( `A scan cost limit is not available for the configured model: ${model}.`, @@ -1605,6 +1825,7 @@ async function collectResult( expectation: ScanExpectation, signal: AbortSignal, workbenchValidated = false, + estimateCost?: boolean, ): Promise { const required = [ "scan-manifest.json", @@ -1643,21 +1864,55 @@ async function collectResult( } catch (error) { if (signal.aborted) throw signal.reason ?? error; } - return new ScanResult({ - manifest, - findings, - coverage, - scanDir, - threadId, - turnResult, - sarifPath, - }); + return createScanResult( + { + manifest, + findings, + coverage, + scanDir, + threadId, + turnResult, + sarifPath, + }, + estimateCost, + ); } export function scanAuthentication( environment: ProcessEnvironment, auth: ScanAuthMode = "auto", + config?: Readonly, + azureConfigured = false, ): ScanAuthentication { + const provider = + azureConfigured && config === undefined + ? { + kind: "environment" as const, + id: "azure" as const, + configId: AZURE_OPENAI_PROVIDER_ID, + envKey: "AZURE_OPENAI_API_KEY" as const, + replayableDefinition: {}, + } + : scanModelProvider(config, azureConfigured); + if (provider.kind === "environment") { + if (auth === "chatgpt") { + throw new ConfigurationError( + `The ${provider.id} model provider does not use ChatGPT credentials. Use '--auth auto' or '--auth api-key'.`, + ); + } + if (environmentValue(environment, provider.envKey) === undefined) { + throw new AuthenticationRequiredError( + `${provider.id} model-provider authentication requires ${provider.envKey}. ` + + "Set a valid provider API key or select a provider that uses OpenAI authentication.", + ); + } + return { + method: "api_key", + provider: provider.id, + source: provider.envKey, + verified: false, + }; + } if (auth === "chatgpt") { return { method: "stored_credentials", verified: false }; } @@ -1673,6 +1928,89 @@ export function scanAuthentication( : { method: "api_key", source: key.source, verified: false }; } +type ScanModelProvider = + | { kind: "openai"; id: "openai" } + | { + kind: "environment"; + id: "azure"; + configId: typeof AZURE_OPENAI_PROVIDER_ID; + envKey: "AZURE_OPENAI_API_KEY"; + replayableDefinition: JsonObject; + }; + +function scanModelProvider( + config: Readonly | undefined, + azureConfigured = false, +): ScanModelProvider { + if (!azureConfigured || config === undefined) { + return { kind: "openai", id: "openai" }; + } + const profileName = config["profile"]; + const profiles = config["profiles"]; + const selectedProfile = + typeof profileName === "string" && + isRecord(profiles) && + isRecord(profiles[profileName]) + ? profiles[profileName] + : undefined; + const rootProvider = config["model_provider"] ?? "openai"; + if (rootProvider !== AZURE_OPENAI_PROVIDER_ID) { + return { kind: "openai", id: "openai" }; + } + const profileProvider = selectedProfile?.["model_provider"]; + if ( + profileProvider !== undefined && + profileProvider !== AZURE_OPENAI_PROVIDER_ID + ) { + throw new ConfigurationError( + "The selected Codex profile cannot override the Azure model provider.", + ); + } + if (selectedProfile !== undefined) { + for (const key of ["model", "model_reasoning_effort"]) { + const profileValue = selectedProfile[key]; + if (profileValue !== undefined && profileValue !== config[key]) { + throw new ConfigurationError( + `The selected Codex profile cannot override ${key} for Azure.`, + ); + } + } + } + const providers = config["model_providers"]; + const definition = + isRecord(providers) && isRecord(providers[AZURE_OPENAI_PROVIDER_ID]) + ? providers[AZURE_OPENAI_PROVIDER_ID] + : undefined; + if (definition === undefined) { + throw new ConfigurationError( + "The azure model provider is not defined in model_providers.", + ); + } + if (definition?.["requires_openai_auth"] === true) { + throw new ConfigurationError( + "The azure model provider must use AZURE_OPENAI_API_KEY, not OpenAI authentication.", + ); + } + if (definition["env_key"] !== "AZURE_OPENAI_API_KEY") { + throw new ConfigurationError( + "The azure model provider env_key must be AZURE_OPENAI_API_KEY.", + ); + } + if (definition["wire_api"] !== "responses") { + throw new ConfigurationError( + "The azure model provider wire_api must be responses.", + ); + } + const replayableDefinition = replayableAzureProviderDefinition(definition); + return { + kind: "environment", + id: "azure", + configId: AZURE_OPENAI_PROVIDER_ID, + envKey: "AZURE_OPENAI_API_KEY", + replayableDefinition, + }; +} + function selectedScanEnvironment( environment: ProcessEnvironment, auth: ScanAuthMode = "auto", @@ -1687,6 +2025,40 @@ function selectedScanEnvironment( ); } +function withoutProviderApiKey( + environment: ProcessEnvironment, + authentication: ScanAuthentication, +): ProcessEnvironment { + if (!isProviderApiKeyAuthentication(authentication)) return environment; + return Object.fromEntries( + Object.entries(environment).filter( + ([name]) => name.toUpperCase() !== "AZURE_OPENAI_API_KEY", + ), + ); +} + +function providerApiKeyEnvironment( + environment: ProcessEnvironment, + authentication: ScanAuthentication, +): ProcessEnvironment { + if (!isProviderApiKeyAuthentication(authentication)) return {}; + const value = environmentValue(environment, authentication.source); + if (value === undefined) { + throw new AuthenticationRequiredError( + `${authentication.provider} model-provider authentication requires ${authentication.source}.`, + ); + } + return { [authentication.source]: value.trim() }; +} + +function isProviderApiKeyAuthentication( + authentication: ScanAuthentication, +): authentication is Extract { + return ( + authentication.method === "api_key" && authentication.provider === "azure" + ); +} + function notifyObserver( observerName: ScanObserverName, observer: ((...args: Arguments) => void) | undefined, @@ -1807,9 +2179,26 @@ export function scanRuntimeCodexConfig( config: JsonObject, stateDirectory: string, protectedCredentialHome?: string, + azureConfigured = false, ): JsonObject { const hardened = structuredClone(config); delete hardened["sandbox_mode"]; + const provider = scanModelProvider(hardened, azureConfigured); + if (provider.kind === "environment") { + excludeShellEnvironmentVariable(hardened, provider.envKey); + const profileName = hardened["profile"]; + const profiles = hardened["profiles"]; + if ( + typeof profileName === "string" && + isRecord(profiles) && + isRecord(profiles[profileName]) + ) { + excludeShellEnvironmentVariable( + profiles[profileName] as JsonObject, + provider.envKey, + ); + } + } const configuredPermissions = isRecord(hardened["permissions"]) ? hardened["permissions"] : {}; @@ -1833,6 +2222,39 @@ export function scanRuntimeCodexConfig( }; } +function excludeShellEnvironmentVariable( + config: JsonObject, + environmentVariable: string, +): void { + const policy = isRecord(config["shell_environment_policy"]) + ? ({ ...config["shell_environment_policy"] } as JsonObject) + : {}; + const normalized = environmentVariable.toUpperCase(); + const excludes = Array.isArray(policy["exclude"]) + ? policy["exclude"].filter( + (value): value is string => typeof value === "string", + ) + : []; + if (!excludes.some((value) => value.toUpperCase() === normalized)) { + excludes.push(environmentVariable); + } + policy["exclude"] = excludes; + if (isRecord(policy["set"])) { + policy["set"] = Object.fromEntries( + Object.entries(policy["set"]).filter( + ([name]) => name.toUpperCase() !== normalized, + ), + ) as JsonObject; + } + if (Array.isArray(policy["include_only"])) { + policy["include_only"] = policy["include_only"].filter( + (value): value is string => + typeof value === "string" && value.toUpperCase() !== normalized, + ); + } + config["shell_environment_policy"] = policy; +} + export function scanPreflightCodexConfig(config: JsonObject): JsonObject { const safeString = (value: unknown, maxLength: number): value is string => typeof value === "string" && diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 1abe8302..38228b66 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -46,9 +46,12 @@ import { type BulkScanPrompt, } from "./bulk-scan-discovery.js"; import { + AZURE_OPENAI_PROVIDER_ID, + azureOpenAICodexOverrides, DEFAULT_CODEX_CONFIG, mergedCodexConfig, scanModelConfiguration, + type AzureOpenAIOptions, type CodexSecurityConfig, type JsonObject, type JsonValue, @@ -157,6 +160,7 @@ const VALUE_OPTIONS = new Set([ "--mode", "--model", "--effort", + "--azure-endpoint", "--output-dir", "--plugin-path", "--python", @@ -203,6 +207,7 @@ interface ScanArguments { mode: ScanMode; model?: string; effort?: ModelReasoningEffort; + azureEndpoint?: string; outputDir?: string; archiveExisting: boolean; pluginPath?: string; @@ -953,6 +958,11 @@ export async function main( `OpenAI model to use (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, ), effort: effortOption(), + azureEndpoint: optionValue("--azure-endpoint") + .optional() + .describe( + "Use Azure OpenAI at its HTTPS resource endpoint; --model is the deployment name and AZURE_OPENAI_API_KEY is required.", + ), outputDir: optionValue("--output-dir") .optional() .describe( @@ -1011,6 +1021,21 @@ export async function main( (options) => !options.archiveExisting || options.outputDir !== undefined, { message: "--archive-existing requires --output-dir." }, + ) + .refine( + (options) => + options.azureEndpoint === undefined || options.auth !== "chatgpt", + { + message: "--azure-endpoint cannot be combined with --auth chatgpt.", + }, + ) + .refine( + (options) => + options.azureEndpoint === undefined || options.model !== undefined, + { + message: + "--azure-endpoint requires --model with an Azure deployment name.", + }, ), examples: [ { args: { repository: "." } }, @@ -1052,6 +1077,7 @@ export async function main( mode: options.mode, model: options.model, effort: options.effort, + azureEndpoint: options.azureEndpoint, outputDir: options.outputDir, archiveExisting: options.archiveExisting, pluginPath: options.pluginPath, @@ -1168,49 +1194,67 @@ export async function main( "CSV repository list; omit to discover repositories interactively.", ), }), - options: z.object({ - outputDir: z - .string() - .min(1, "--output-dir must not be empty.") - .optional() - .describe( - "Resumable results directory; required with a repository CSV.", - ), - workers: z - .number() - .int() - .positive() - .default(4) - .describe( - "Concurrent repository scans. Per-scan Codex workers are separate.", - ), - mode: z - .enum(["standard", "deep"]) - .default("standard") - .describe("Default scan mode for repositories without a CSV mode."), - model: optionValue("--model") - .optional() - .describe( - `OpenAI model for each repository (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, - ), - effort: effortOption(), - maxAttempts: z - .number() - .int() - .positive() - .default(1) - .describe("Maximum scan attempts per repository."), - pluginPath: z - .string() - .min(1) - .optional() - .describe(PLUGIN_PATH_DESCRIPTION), - python: z.string().min(1).optional().describe(PYTHON_PATH_DESCRIPTION), - codex: z - .array(z.string().min(1)) - .default([]) - .describe(CODEX_OVERRIDE_DESCRIPTION), - }), + options: z + .object({ + outputDir: z + .string() + .min(1, "--output-dir must not be empty.") + .optional() + .describe( + "Resumable results directory; required with a repository CSV.", + ), + workers: z + .number() + .int() + .positive() + .default(4) + .describe( + "Concurrent repository scans. Per-scan Codex workers are separate.", + ), + mode: z + .enum(["standard", "deep"]) + .default("standard") + .describe("Default scan mode for repositories without a CSV mode."), + model: optionValue("--model") + .optional() + .describe( + `OpenAI model for each repository (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, + ), + effort: effortOption(), + azureEndpoint: optionValue("--azure-endpoint") + .optional() + .describe( + "Use Azure OpenAI at its HTTPS resource endpoint; --model is the deployment name and AZURE_OPENAI_API_KEY is required.", + ), + maxAttempts: z + .number() + .int() + .positive() + .default(1) + .describe("Maximum scan attempts per repository."), + pluginPath: z + .string() + .min(1) + .optional() + .describe(PLUGIN_PATH_DESCRIPTION), + python: z + .string() + .min(1) + .optional() + .describe(PYTHON_PATH_DESCRIPTION), + codex: z + .array(z.string().min(1)) + .default([]) + .describe(CODEX_OVERRIDE_DESCRIPTION), + }) + .refine( + (options) => + options.azureEndpoint === undefined || options.model !== undefined, + { + message: + "--azure-endpoint requires --model with an Azure deployment name.", + }, + ), examples: [ { args: {}, @@ -1247,12 +1291,14 @@ export async function main( if ( argument === "--model" || argument === "--effort" || + argument === "--azure-endpoint" || argument === "--codex" ) { optionIndex += 2; } else if ( argument.startsWith("--model=") || argument.startsWith("--effort=") || + argument.startsWith("--azure-endpoint=") || argument.startsWith("--codex=") ) { optionIndex += 1; @@ -1297,10 +1343,19 @@ export async function main( config: { pluginPath: options.pluginPath, pythonPath: options.python, + azureOpenAI: + options.azureEndpoint === undefined + ? undefined + : { endpoint: options.azureEndpoint }, codexOverrides: parseCodexOverrides( options.codex, options.model, options.effort, + options.azureEndpoint === undefined + ? undefined + : { + endpoint: options.azureEndpoint, + }, ), }, createSecurity: dependencies.createSecurity, @@ -1744,6 +1799,28 @@ function scanArgumentsFromRecipe( "The saved scan recipe contains invalid configuration.", ); } + const azureOpenAI = recipe["azureOpenAI"]; + if ( + azureOpenAI !== undefined && + (!isJsonObject(azureOpenAI) || + typeof azureOpenAI["endpoint"] !== "string" || + azureOpenAI["endpoint"].length === 0) + ) { + throw new CodexSecurityError( + "The saved scan recipe contains invalid Azure OpenAI configuration.", + ); + } + if (azureOpenAI !== undefined) { + try { + azureOpenAICodexOverrides({ + endpoint: azureOpenAI["endpoint"] as string, + }); + } catch { + throw new CodexSecurityError( + "The saved scan recipe contains invalid Azure OpenAI configuration.", + ); + } + } const reference = target["baseRef"] ?? target["base"]; if ( (reference !== undefined && typeof reference !== "string") || @@ -1792,6 +1869,10 @@ function scanArgumentsFromRecipe( archiveExisting: false, codex: [], codexOverrides: config, + azureEndpoint: + azureOpenAI === undefined + ? undefined + : (azureOpenAI["endpoint"] as string), failOnSeverity: threshold as FailureSeverity | undefined, maxCostUsd, dryRun: false, @@ -2436,22 +2517,37 @@ async function runScan( const config: CodexSecurityConfig = { pluginPath: arguments_.pluginPath, pythonPath: arguments_.pythonPath, + azureOpenAI: + arguments_.azureEndpoint === undefined + ? undefined + : { endpoint: arguments_.azureEndpoint }, codexOverrides: arguments_.codexOverrides ?? parseCodexOverrides( arguments_.codex, arguments_.model, arguments_.effort, + arguments_.azureEndpoint === undefined + ? undefined + : { + endpoint: arguments_.azureEndpoint, + }, ), }; let auth = arguments_.auth; - selectedAuthentication = scanAuthentication(dependencies.environment, auth); + selectedAuthentication = scanAuthentication( + dependencies.environment, + auth, + undefined, + config.azureOpenAI !== undefined, + ); if ( (auth === undefined || auth === "auto") && !arguments_.dryRun && interactive && errorOutput.isTTY === true && - selectedAuthentication.method === "api_key" + selectedAuthentication.method === "api_key" && + selectedAuthentication.provider !== "azure" ) { const prompt = dependencies.scanAuthenticationPrompt ?? @@ -2478,6 +2574,8 @@ async function runScan( selectedAuthentication = scanAuthentication( dependencies.environment, auth, + undefined, + config.azureOpenAI !== undefined, ); } } @@ -2528,12 +2626,18 @@ async function runScan( selectedAuthentication = authentication; progress?.stopTimer(); if (authentication.method === "api_key") { - progress?.stage( - `Authentication: API key from ${authentication.source}.`, - ); - progress?.stage( - "To use your ChatGPT sign-in, retry with --auth chatgpt.", - ); + if (authentication.provider === "azure") { + progress?.stage( + `Authentication: API key from ${authentication.source} for ${authentication.provider}.`, + ); + } else { + progress?.stage( + `Authentication: API key from ${authentication.source}.`, + ); + progress?.stage( + "To use your ChatGPT sign-in, retry with --auth chatgpt.", + ); + } } else { progress?.stage("Authentication: stored Codex credentials."); } @@ -2680,18 +2784,37 @@ function scanFailureMessage( ): string { switch (classifyConnectionFailure(error)) { case "unauthorized": - return authentication?.method === "api_key" - ? `Authentication failed using ${authentication.source}. ` + - "Your ChatGPT sign-in was not used. " + - "Retry with '--auth chatgpt' or provide a valid API key." - : "Authentication failed using stored ChatGPT credentials. " + - "Sign in again with 'codex-security login' or provide a valid API key."; + if (authentication?.method === "api_key") { + if (authentication.provider === "azure") { + return ( + `Authentication failed using ${authentication.source} for model provider ${authentication.provider}. ` + + "Provide a valid provider API key." + ); + } + return ( + `Authentication failed using ${authentication.source}. ` + + "Your ChatGPT sign-in was not used. " + + "Retry with '--auth chatgpt' or provide a valid API key." + ); + } + return ( + "Authentication failed using stored ChatGPT credentials. " + + "Sign in again with 'codex-security login' or provide a valid API key." + ); case "forbidden": - return authentication?.method === "api_key" - ? `The API key from ${authentication.source} cannot access the configured model. ` + - "Retry with '--auth chatgpt' or use an API key with model access." - : "The stored ChatGPT credentials cannot access the configured model. " + - "Use an account or API key with model access."; + if (authentication?.method === "api_key") { + if (authentication.provider === "azure") { + return `The API key from ${authentication.source} cannot access the configured model through provider ${authentication.provider}.`; + } + return ( + `The API key from ${authentication.source} cannot access the configured model. ` + + "Retry with '--auth chatgpt' or use an API key with model access." + ); + } + return ( + "The stored ChatGPT credentials cannot access the configured model. " + + "Use an account or API key with model access." + ); case "rate_limited": return "The configured account reached its rate limit. Wait and retry."; case "network_error": @@ -2904,8 +3027,12 @@ export function parseCodexOverrides( values: readonly string[], model?: string, effort?: ModelReasoningEffort, + azure?: AzureOpenAIOptions, ): JsonObject { const result = Object.create(null) as JsonObject; + if (azure !== undefined) { + azureOpenAICodexOverrides(azure); + } if (model !== undefined) result["model"] = model; if (effort !== undefined) result["model_reasoning_effort"] = effort; for (const value of values) { @@ -2934,6 +3061,14 @@ export function parseCodexOverrides( ) { throw new CodexSecurityError("Invalid --codex key"); } + if ( + azure !== undefined && + (key === "model_provider" || + key === `model_providers.${AZURE_OPENAI_PROVIDER_ID}` || + key.startsWith(`model_providers.${AZURE_OPENAI_PROVIDER_ID}.`)) + ) { + throw new CodexSecurityError("Duplicate --codex key"); + } let parsed: JsonValue; try { parsed = parseToml(`value = ${literal}`)["value"] as JsonValue; @@ -2967,6 +3102,15 @@ export function parseCodexOverrides( } cursor[final] = parsed; } + const modelProviders = result["model_providers"]; + if ( + azure !== undefined && + modelProviders !== undefined && + isJsonObject(modelProviders) && + Object.hasOwn(modelProviders, AZURE_OPENAI_PROVIDER_ID) + ) { + throw new CodexSecurityError("Duplicate --codex key"); + } return result; } diff --git a/sdk/typescript/src/config.ts b/sdk/typescript/src/config.ts index 2fa18a6e..a960a1a5 100644 --- a/sdk/typescript/src/config.ts +++ b/sdk/typescript/src/config.ts @@ -14,6 +14,7 @@ export interface CodexSecurityConfig { pluginPath?: string; codexOverrides?: JsonObject; pythonPath?: string; + azureOpenAI?: AzureOpenAIOptions; } export interface ScanModelConfiguration { @@ -21,6 +22,12 @@ export interface ScanModelConfiguration { reasoningEffort: string; } +export interface AzureOpenAIOptions { + endpoint: string; +} + +export const AZURE_OPENAI_PROVIDER_ID = "codex_security_azure_openai"; + export const DEFAULT_CODEX_CONFIG: Readonly = { cli_auth_credentials_store: "auto", model: "gpt-5.6-sol", @@ -41,6 +48,33 @@ export const DEFAULT_CODEX_CONFIG: Readonly = { deepFreezeJson(DEFAULT_CODEX_CONFIG); +export function azureOpenAICodexOverrides( + options: AzureOpenAIOptions, +): JsonObject { + if ( + typeof options !== "object" || + options === null || + typeof options.endpoint !== "string" + ) { + throw new ConfigurationError( + "azureOpenAI.endpoint must be a valid HTTPS URL.", + ); + } + const endpoint = normalizeAzureOpenAIEndpoint(options.endpoint); + const provider: JsonObject = { + name: "Azure OpenAI", + base_url: endpoint, + env_key: "AZURE_OPENAI_API_KEY", + wire_api: "responses", + }; + return { + model_provider: AZURE_OPENAI_PROVIDER_ID, + model_providers: { + [AZURE_OPENAI_PROVIDER_ID]: provider, + }, + }; +} + export function scanModelConfiguration( config: Readonly, ): ScanModelConfiguration { @@ -81,6 +115,25 @@ export async function mergedCodexConfig( } } } + if (config.azureOpenAI !== undefined) { + const modelProvider = overrides["model_provider"]; + if (modelProvider !== undefined) { + throw new ConfigurationError( + "azureOpenAI cannot be combined with a codexOverrides model_provider.", + ); + } + const modelProviders = overrides["model_providers"]; + if ( + modelProviders !== undefined && + (!isObject(modelProviders) || + Object.hasOwn(modelProviders, AZURE_OPENAI_PROVIDER_ID)) + ) { + throw new ConfigurationError( + "azureOpenAI owns its Codex model-provider configuration.", + ); + } + deepMerge(overrides, azureOpenAICodexOverrides(config.azureOpenAI)); + } return deepMerge(cloneJson(DEFAULT_CODEX_CONFIG), overrides); } @@ -290,6 +343,46 @@ function deepFreezeJson(value: JsonValue): void { Object.freeze(value); } +function normalizeAzureOpenAIEndpoint(value: string): string { + const input = value.trim(); + if ( + input.length === 0 || + input.length > 2_048 || + /[\u0000-\u001f\u007f]/u.test(input) + ) { + throw new ConfigurationError( + "The Azure OpenAI endpoint must be a valid HTTPS URL.", + ); + } + let endpoint: URL; + try { + endpoint = new URL(input); + } catch { + throw new ConfigurationError( + "The Azure OpenAI endpoint must be a valid HTTPS URL.", + ); + } + if ( + endpoint.protocol !== "https:" || + endpoint.username !== "" || + endpoint.password !== "" || + endpoint.search !== "" || + endpoint.hash !== "" + ) { + throw new ConfigurationError( + "The Azure OpenAI endpoint must be an HTTPS URL without credentials, query parameters, or a fragment.", + ); + } + const pathname = endpoint.pathname.replace(/\/+$/u, ""); + if (!["", "/openai", "/openai/v1"].includes(pathname.toLowerCase())) { + throw new ConfigurationError( + "The Azure OpenAI endpoint must be a resource URL or end in /openai/v1.", + ); + } + endpoint.pathname = "/openai/v1"; + return endpoint.toString(); +} + function isObject(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { return false; diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 912ccd27..030f7d1e 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -36,7 +36,7 @@ interface SessionUsage { interface ScanCostTrackerOptions { codexHome: string; - model: string; + model?: string; maxCostUsd?: number; onCost?: (cost: Readonly) => void; onError?: (error: unknown) => void; diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index dc1c64df..190003b2 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -33,7 +33,12 @@ export { mergedCodexConfig, writeCodexConfig, } from "./config.js"; -export type { CodexSecurityConfig, JsonObject, JsonValue } from "./config.js"; +export type { + AzureOpenAIOptions, + CodexSecurityConfig, + JsonObject, + JsonValue, +} from "./config.js"; export { loadContract, requireScanFile } from "./contract.js"; export type { LoadedContract, ScanExpectation } from "./contract.js"; export type * from "./models.js"; diff --git a/sdk/typescript/src/result.ts b/sdk/typescript/src/result.ts index 467c520d..af0d1d48 100644 --- a/sdk/typescript/src/result.ts +++ b/sdk/typescript/src/result.ts @@ -107,3 +107,18 @@ export class ScanResult { }; } } + +export function createScanResult( + options: ScanResultOptions, + estimateCost = true, +): ScanResult { + const result = new ScanResult(options); + if (!estimateCost) { + ( + result as unknown as { + cost: Readonly | null; + } + ).cost = null; + } + return result; +} diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index 13e73152..38cbb7c7 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -23,6 +23,7 @@ import { parse as parseToml } from "smol-toml"; import { AuthenticationRequiredError, CodexSecurity, + ConfigurationError, DiffTarget, InvalidTargetError, OutputDirectoryError, @@ -36,10 +37,16 @@ import { classifyConnectionFailure, environmentValue, initialCredentialsAvailable, + scanAuthentication, scanPreflightCodexConfig, + scanReplayCodexConfig, scanRuntimeCodexConfig, } from "../src/api.js"; -import { writeCodexConfig, type JsonObject } from "../src/config.js"; +import { + AZURE_OPENAI_PROVIDER_ID, + writeCodexConfig, + type JsonObject, +} from "../src/config.js"; import { runWorkbench, setCodexSecurityCredentialLogout, @@ -495,6 +502,58 @@ describe("CodexSecurity orchestration", () => { ).resolves.toBeUndefined(); }); + test("stores only replay-safe Azure provider configuration", () => { + const definition = { + name: "Azure OpenAI", + base_url: "https://security-models.openai.azure.com/openai/v1", + env_key: "AZURE_OPENAI_API_KEY", + request_max_retries: 4, + wire_api: "responses", + } satisfies JsonObject; + const config = { + model: "secret-review-deployment", + model_reasoning_effort: "high", + features: { + multi_agent_v2: { + enabled: true, + max_concurrent_threads_per_session: 9, + }, + }, + model_provider: AZURE_OPENAI_PROVIDER_ID, + model_providers: { + [AZURE_OPENAI_PROVIDER_ID]: definition, + }, + } satisfies JsonObject; + const replay = scanReplayCodexConfig(config, true); + + expect(replay).toEqual({ + model: "secret-review-deployment", + model_reasoning_effort: "high", + features: { + multi_agent_v2: { + enabled: true, + max_concurrent_threads_per_session: 9, + }, + }, + }); + expect(() => + scanReplayCodexConfig( + { + ...config, + model_providers: { + [AZURE_OPENAI_PROVIDER_ID]: { + ...definition, + http_headers: { "api-key": "STATIC_HEADER_SECRET" }, + }, + }, + }, + true, + ), + ).toThrow( + "azure model provider contains settings that cannot be stored safely", + ); + }); + test("selects a real-scan target in the active repository layout", async () => { await expect( stat(join(REPOSITORY_ROOT, INTEGRATION_TARGET)), @@ -641,6 +700,36 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("rejects cost limits for Azure before starting the runtime", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + await mkdir(repository); + let runtimeStarted = false; + const client = new TestClient( + { + azureOpenAI: { + endpoint: "https://security-models.openai.azure.com", + }, + codexOverrides: { + model: "gpt-5.6-sol", + }, + }, + { + environment: { AZURE_OPENAI_API_KEY: "synthetic-azure-key" }, + prepareRuntime: async () => { + runtimeStarted = true; + throw new Error("runtime should not initialize"); + }, + }, + ); + + await expect( + client.preflight(repository, { maxCostUsd: 5 }), + ).rejects.toThrow("model provider: azure"); + expect(runtimeStarted).toBe(false); + await client.close(); + }); + test("validates knowledge-base documents before initializing the runtime", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -709,6 +798,10 @@ describe("CodexSecurity orchestration", () => { { openai_api_key: " ", Codex_Api_Key: "synthetic-codex-key" }, { method: "api_key", source: "CODEX_API_KEY", verified: false }, ], + [ + { AZURE_OPENAI_API_KEY: "unrelated-azure-key" }, + { method: "stored_credentials", verified: false }, + ], [{}, { method: "stored_credentials", verified: false }], ] as const) { let runtimeStarted = false; @@ -733,6 +826,168 @@ describe("CodexSecurity orchestration", () => { } }); + test("selects only the configured provider credential", () => { + const config = { + model_provider: AZURE_OPENAI_PROVIDER_ID, + model_providers: { + [AZURE_OPENAI_PROVIDER_ID]: { + base_url: "https://security-models.openai.azure.com/openai/v1", + env_key: "AZURE_OPENAI_API_KEY", + wire_api: "responses", + }, + }, + } satisfies JsonObject; + + for (const auth of ["auto", "api-key"] as const) { + const authentication = scanAuthentication( + { + OPENAI_API_KEY: "unrelated-openai-secret", + AZURE_OPENAI_API_KEY: "synthetic-azure-key", + }, + auth, + config, + true, + ); + expect(authentication).toEqual({ + method: "api_key", + provider: "azure", + source: "AZURE_OPENAI_API_KEY", + verified: false, + }); + expect(JSON.stringify(authentication)).not.toContain( + "synthetic-azure-key", + ); + } + + let missing: unknown; + try { + scanAuthentication( + { OPENAI_API_KEY: "unrelated-openai-secret" }, + "auto", + config, + true, + ); + } catch (error) { + missing = error; + } + expect(missing).toBeInstanceOf(AuthenticationRequiredError); + expect(String(missing)).toContain("AZURE_OPENAI_API_KEY"); + expect(String(missing)).not.toContain("unrelated-openai-secret"); + expect(() => scanAuthentication({}, "chatgpt", config, true)).toThrow( + ConfigurationError, + ); + }); + + test("preserves custom providers even when their ID resembles managed Azure", () => { + for (const providerId of ["azure", AZURE_OPENAI_PROVIDER_ID]) { + const config = { + model: "gpt-5.6-sol", + model_provider: providerId, + model_providers: { + [providerId]: { + name: "Existing custom provider", + base_url: "https://legacy.example.test/v1", + env_key: "LEGACY_PROVIDER_KEY", + requires_openai_auth: true, + wire_api: "responses", + http_headers: { "X-Custom-Header": "existing-value" }, + }, + }, + shell_environment_policy: { exclude: ["EXISTING"] }, + } satisfies JsonObject; + + expect( + scanAuthentication( + { OPENAI_API_KEY: "legacy-openai-key" }, + "auto", + config, + ), + ).toEqual({ + method: "api_key", + source: "OPENAI_API_KEY", + verified: false, + }); + expect(scanReplayCodexConfig(config)).toEqual( + scanPreflightCodexConfig(config), + ); + const hardened = scanRuntimeCodexConfig(config, "/state"); + expect(hardened["model_providers"]).toEqual(config["model_providers"]); + expect(hardened["shell_environment_policy"]).toEqual({ + exclude: ["EXISTING"], + }); + } + }); + + test("rejects Azure providers outside the supported API-key contract", () => { + const baseDefinition: JsonObject = { + base_url: "https://security-models.openai.azure.com/openai/v1", + env_key: "AZURE_OPENAI_API_KEY", + wire_api: "responses", + }; + const definitions: JsonObject[] = [ + { + ...baseDefinition, + env_key: "OTHER_API_KEY", + }, + { + ...baseDefinition, + wire_api: "chat", + }, + { + ...baseDefinition, + requires_openai_auth: true, + }, + { + ...baseDefinition, + base_url: "http://security-models.openai.azure.com/openai/v1", + }, + { + ...baseDefinition, + http_headers: { "api-key": "STATIC_HEADER_SECRET" }, + }, + ]; + for (const definition of definitions) { + expect(() => + scanAuthentication( + { AZURE_OPENAI_API_KEY: "synthetic-azure-key" }, + "auto", + { + model_provider: AZURE_OPENAI_PROVIDER_ID, + model_providers: { [AZURE_OPENAI_PROVIDER_ID]: definition }, + }, + true, + ), + ).toThrow(ConfigurationError); + } + expect(() => + scanAuthentication( + { AZURE_OPENAI_API_KEY: "synthetic-azure-key" }, + "auto", + { + model_provider: AZURE_OPENAI_PROVIDER_ID, + model_providers: { [AZURE_OPENAI_PROVIDER_ID]: baseDefinition }, + profile: "other", + profiles: { other: { model_provider: "openai" } }, + }, + true, + ), + ).toThrow("selected Codex profile cannot override"); + expect(() => + scanAuthentication( + { AZURE_OPENAI_API_KEY: "synthetic-azure-key" }, + "auto", + { + model: "security-deployment", + model_provider: AZURE_OPENAI_PROVIDER_ID, + model_providers: { [AZURE_OPENAI_PROVIDER_ID]: baseDefinition }, + profile: "other", + profiles: { other: { model: "different-deployment" } }, + }, + true, + ), + ).toThrow("selected Codex profile cannot override model for Azure"); + }); + test("honors explicit authentication selection without initializing the runtime", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); @@ -2984,6 +3239,34 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test("reserves the operation before account initialization yields", async () => { + const root = await temporaryDirectory(); + const codexHome = join(root, "codex-home"); + await mkdir(codexHome); + let releaseRuntime!: (runtime: Record) => void; + const pendingRuntime = new Promise>((resolve) => { + releaseRuntime = resolve; + }); + const client = new TestClient( + {}, + { + environment: { OPENAI_API_KEY: "ambient-key" }, + prepareRuntime: async () => await pendingRuntime, + }, + ); + + const account = client.account(); + await expect(client.account()).rejects.toThrow( + "operation is already in progress", + ); + releaseRuntime(preparedRuntime(codexHome)); + await expect(account).resolves.toEqual({ + authenticated: true, + details: "Authenticated with an API key.", + }); + await client.close(); + }); + test("reports effective ambient API-key authentication", async () => { const root = await temporaryDirectory(); const codexHome = join(root, "codex-home"); @@ -3046,6 +3329,7 @@ process.exitCode = 2; openai_api_key: "stale-key", OPENAI_API_KEY: "ambient-key", Codex_Api_Key: "secondary-key", + AZURE_OPENAI_API_KEY: "unused-azure-key", }, prepareRuntime: async () => ({ codexHome, @@ -3061,6 +3345,7 @@ process.exitCode = 2; CODEX_HOME: codexHome, OpenAi_Api_Key: "forwarded-openai-key", codex_api_key: "forwarded-codex-key", + Azure_OpenAI_Api_Key: "forwarded-unused-azure-key", }, credentialsAvailable: false, }), @@ -3113,16 +3398,176 @@ process.exitCode = 2; ["OPENAI_API_KEY", "CODEX_API_KEY"].includes(name.toUpperCase()), ), ).toBe(false); + expect((codexOptions as CodexOptions | null)?.env).toMatchObject({ + Azure_OpenAI_Api_Key: "forwarded-unused-azure-key", + }); expect(existsSync(nativeLoginMarker)).toBe(false); expect(pythonEnvironment).toMatchObject({ openai_api_key: "stale-key", OPENAI_API_KEY: "ambient-key", Codex_Api_Key: "secondary-key", + AZURE_OPENAI_API_KEY: "unused-azure-key", }); expect(pythonProtectedRoot).toBe(await realpath(repository)); await client.close(); }); + test("passes an Azure key only to Codex and keeps it out of local helpers", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + await mkdir(repository); + await mkdir(codexHome); + await mkdir(scanDir, { mode: 0o700 }); + let codexOptions: CodexOptions | null = null; + let selectedAuthentication: ScanAuthentication | undefined; + let pythonEnvironment: Record | undefined; + let savedRecipe: JsonObject | undefined; + const workbenchEnvironments: Array> = []; + const client = new TestClient( + { + azureOpenAI: { + endpoint: "https://security-models.openai.azure.com", + }, + codexOverrides: { + model: "security-deployment", + }, + }, + { + environment: { + Azure_OpenAI_Api_Key: "synthetic-azure-key", + OPENAI_API_KEY: "unrelated-openai-key", + }, + prepareRuntime: async () => ({ + ...preparedRuntime(codexHome), + environment: { + CODEX_HOME: codexHome, + Azure_OpenAI_Api_Key: "runtime-azure-key", + OPENAI_API_KEY: "runtime-openai-key", + }, + }), + resolvePluginPython: async (options: { + environment?: Record; + }) => { + pythonEnvironment = options.environment; + return "/managed/python"; + }, + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + runWorkbench: async ( + options: { environment?: Record }, + args: readonly string[], + ) => { + workbenchEnvironments.push(options.environment ?? {}); + if (args[0] === "register-cli-scan") { + savedRecipe = JSON.parse( + args[args.indexOf("--recipe-json") + 1]!, + ) as JsonObject; + return mockScanRegistration(args); + } + if (args[0] === "get-scan-feedback") { + return { + scanId: "scan_example_001", + targetId: "target_sha256_example", + falsePositives: [], + }; + } + return {}; + }, + createCodex: (options: CodexOptions) => { + codexOptions = options; + return { + startThread: () => ({ + id: null, + async runStreamed() { + await copyCompletedScan(root); + return { events: completedEvents() }; + }, + }), + }; + }, + }, + ); + + const result = await client.run(repository, { + onAuthentication: (authentication) => { + selectedAuthentication = authentication; + }, + }); + expect(selectedAuthentication).toEqual({ + method: "api_key", + provider: "azure", + source: "AZURE_OPENAI_API_KEY", + verified: false, + }); + expect((codexOptions as CodexOptions | null)?.apiKey).toBeUndefined(); + expect((codexOptions as CodexOptions | null)?.env).toMatchObject({ + AZURE_OPENAI_API_KEY: "synthetic-azure-key", + }); + expect( + Object.keys((codexOptions as CodexOptions | null)?.env ?? {}).some( + (name) => name.toUpperCase() === "OPENAI_API_KEY", + ), + ).toBe(false); + expect( + Object.keys(pythonEnvironment ?? {}).some( + (name) => name.toUpperCase() === "AZURE_OPENAI_API_KEY", + ), + ).toBe(false); + expect(workbenchEnvironments.length).toBeGreaterThan(0); + for (const environment of workbenchEnvironments) { + expect( + Object.keys(environment).some( + (name) => name.toUpperCase() === "AZURE_OPENAI_API_KEY", + ), + ).toBe(false); + } + expect(result.turnResult).toMatchObject({ + model: "security-deployment", + modelProvider: "azure", + }); + expect(result.cost).toBeNull(); + expect(savedRecipe).toMatchObject({ + azureOpenAI: { + endpoint: "https://security-models.openai.azure.com/openai/v1", + }, + config: { + model: "security-deployment", + }, + }); + expect((savedRecipe?.["config"] as JsonObject)["model_provider"]).toBe( + undefined, + ); + await expect(client.account()).resolves.toEqual({ + authenticated: true, + details: + "Authenticated with an API key from AZURE_OPENAI_API_KEY for model provider azure.", + }); + await client.close(); + }); + + test("reports a missing Azure key as an unauthenticated account", async () => { + const client = new TestClient( + { + azureOpenAI: { + endpoint: "https://security-models.openai.azure.com", + }, + codexOverrides: { + model: "security-deployment", + }, + }, + { environment: {} }, + ); + + await expect(client.account()).resolves.toEqual({ + authenticated: false, + details: + "Not authenticated for model provider azure; set AZURE_OPENAI_API_KEY.", + }); + await client.close(); + }); + test("accepts native keyring authentication without an auth.json file", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository"); diff --git a/sdk/typescript/tests-ts/cli-authentication.test.ts b/sdk/typescript/tests-ts/cli-authentication.test.ts index 40f12a27..b49073be 100644 --- a/sdk/typescript/tests-ts/cli-authentication.test.ts +++ b/sdk/typescript/tests-ts/cli-authentication.test.ts @@ -324,7 +324,10 @@ describe("CLI authentication", () => { let question = ""; let choices: readonly { label: string; value: string }[] = []; const deps = dependencies({ - environment: { OPENAI_API_KEY: "sk-proj-SYNTHETIC_SECRET_123" }, + environment: { + OPENAI_API_KEY: "sk-proj-SYNTHETIC_SECRET_123", + AZURE_OPENAI_API_KEY: "unrelated-azure-key", + }, onTurn: (_repository, options) => { selected = (options as ScanOptions).auth; }, @@ -477,6 +480,36 @@ describe("CLI authentication", () => { expect(stderr.text()).not.toContain("must not initialize"); }); + test("requires the Azure provider key even when an OpenAI key is available", async () => { + const stderr = capture(); + const deps = dependencies({ + environment: { OPENAI_API_KEY: "openai-SYNTHETIC_SECRET_123" }, + }); + deps.createSecurity = () => { + throw new Error("must not initialize Codex Security"); + }; + + expect( + await main( + [ + "scan", + "--azure-endpoint", + "https://security.openai.azure.com", + "--model", + "security-deployment", + ], + capture().stream, + stderr.stream, + deps, + ), + ).toBe(2); + expect(stderr.text()).toContain( + "azure model-provider authentication requires AZURE_OPENAI_API_KEY", + ); + expect(stderr.text()).not.toContain("must not initialize"); + expect(stderr.text()).not.toContain("SYNTHETIC_SECRET"); + }); + test("keeps stored-login status unchanged when no environment key is set", async () => { const stdout = capture(); const stderr = capture(); @@ -653,6 +686,109 @@ describe("CLI authentication", () => { ); }); + test("reports Azure provider authentication without offering ChatGPT credentials", async () => { + const stdout = capture(); + const stderr = capture(true); + let prompts = 0; + const deps = dependencies({ + environment: { + AZURE_OPENAI_API_KEY: "azure-SYNTHETIC_SECRET_123", + OPENAI_API_KEY: "openai-SYNTHETIC_SECRET_456", + }, + }); + deps.hasStoredChatGPTSignIn = async () => true; + deps.scanAuthenticationPrompt = { + isInteractive: () => true, + select: async ( + _message: string, + options: readonly { label: string; value: Value }[], + ): Promise => { + prompts += 1; + return options[0]!.value; + }, + }; + deps.createSecurity = () => ({ + run: async (_repository, options) => { + options?.onAuthentication?.({ + method: "api_key", + provider: "azure", + source: "AZURE_OPENAI_API_KEY", + verified: false, + }); + return fakeResult(); + }, + preflight: async () => fakePreflight(), + close: async () => {}, + }); + + expect( + await main( + [ + "scan", + "--json", + "--azure-endpoint", + "https://security.openai.azure.com", + "--model", + "security-deployment", + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(0); + expect(prompts).toBe(0); + expect(JSON.parse(stdout.text())).toEqual(fakeResult().toJSON()); + expect(stderr.text()).toContain( + "Authentication: API key from AZURE_OPENAI_API_KEY for azure.", + ); + expect(stderr.text()).not.toContain("--auth chatgpt"); + expect(stderr.text()).not.toContain("SYNTHETIC_SECRET"); + }); + + test("reports Azure authentication failures without leaking credentials or suggesting ChatGPT", async () => { + const stderr = capture(false); + const deps = dependencies({ + environment: { + AZURE_OPENAI_API_KEY: "azure-SYNTHETIC_SECRET_123", + }, + }); + deps.createSecurity = () => ({ + run: async (_repository, options) => { + options?.onAuthentication?.({ + method: "api_key", + provider: "azure", + source: "AZURE_OPENAI_API_KEY", + verified: false, + }); + throw new CodexSecurityError( + "401 invalid API key azure-SYNTHETIC_SECRET_123", + ); + }, + preflight: async () => fakePreflight(), + close: async () => {}, + }); + + expect( + await main( + [ + "scan", + "--azure-endpoint", + "https://security.openai.azure.com", + "--model", + "security-deployment", + ], + capture().stream, + stderr.stream, + deps, + ), + ).toBe(2); + expect(stderr.text()).toContain( + "Authentication failed using AZURE_OPENAI_API_KEY for model provider azure.", + ); + expect(stderr.text()).not.toContain("--auth chatgpt"); + expect(stderr.text()).not.toContain("SYNTHETIC_SECRET"); + }); + test("identifies overriding API keys in noninteractive scan auth failures", async () => { for (const [environment, source] of [ [{ OPENAI_API_KEY: "sk-proj-SYNTHETIC_SECRET_123" }, "OPENAI_API_KEY"], diff --git a/sdk/typescript/tests-ts/cli-fixtures.ts b/sdk/typescript/tests-ts/cli-fixtures.ts index 892783b9..a235f8b8 100644 --- a/sdk/typescript/tests-ts/cli-fixtures.ts +++ b/sdk/typescript/tests-ts/cli-fixtures.ts @@ -31,6 +31,7 @@ export const SYNTHETIC_CREDENTIALS = [ "ghs_SYNTHETIC_GITHUB_TOKEN_123", "OPENAI_API_KEY=SYNTHETIC_OPENAI_VALUE_123", "CODEX_API_KEY=SYNTHETIC_CODEX_VALUE_123", + "AZURE_OPENAI_API_KEY=SYNTHETIC_AZURE_OPENAI_VALUE_123", "CODEX_ACCESS_TOKEN=SYNTHETIC_CODEX_ACCESS_TOKEN_123", "GITHUB_TOKEN=SYNTHETIC_GITHUB_VALUE_123", "GH_TOKEN=SYNTHETIC_GH_VALUE_123", @@ -83,6 +84,7 @@ export const REDACTED_CREDENTIALS = [ "[redacted]", "OPENAI_API_KEY=[redacted]", "CODEX_API_KEY=[redacted]", + "AZURE_OPENAI_API_KEY=[redacted]", "CODEX_ACCESS_TOKEN=[redacted]", "GITHUB_TOKEN=[redacted]", "GH_TOKEN=[redacted]", diff --git a/sdk/typescript/tests-ts/cli-workbench.test.ts b/sdk/typescript/tests-ts/cli-workbench.test.ts index 90156a43..3c2fcb4b 100644 --- a/sdk/typescript/tests-ts/cli-workbench.test.ts +++ b/sdk/typescript/tests-ts/cli-workbench.test.ts @@ -565,6 +565,79 @@ describe("CLI workbench", () => { knowledgeBasePaths: ["/original/security.md"], }); + let azureConfig: CodexSecurityConfig | undefined; + expect( + await main( + ["scans", "rerun", "scan-azure"], + capture().stream, + capture().stream, + dependencies({ + environment: { + AZURE_OPENAI_API_KEY: "synthetic-azure-key", + }, + onConfig: (value) => { + azureConfig = value; + }, + onWorkbench: () => ({ + recipe: { + repository: "/original/repository", + target: { kind: "repository", paths: [] }, + mode: "standard", + config: { + model: "security-deployment", + model_reasoning_effort: "high", + }, + azureOpenAI: { + endpoint: "https://security-models.openai.azure.com/openai/v1", + }, + }, + }), + }), + ), + ).toBe(0); + expect(azureConfig).toEqual({ + pluginPath: undefined, + pythonPath: undefined, + azureOpenAI: { + endpoint: "https://security-models.openai.azure.com/openai/v1", + }, + codexOverrides: { + model: "security-deployment", + model_reasoning_effort: "high", + }, + }); + + let invalidAzureStarted = false; + const invalidAzureStderr = capture(); + expect( + await main( + ["scans", "rerun", "scan-invalid-azure"], + capture().stream, + invalidAzureStderr.stream, + dependencies({ + onRun: () => { + invalidAzureStarted = true; + }, + onWorkbench: () => ({ + recipe: { + repository: "/original/repository", + target: { kind: "repository", paths: [] }, + mode: "standard", + config: { model: "security-deployment" }, + azureOpenAI: { + endpoint: "http://unsafe.example.test?api-key=secret", + }, + }, + }), + }), + ), + ).toBe(2); + expect(invalidAzureStarted).toBe(false); + expect(invalidAzureStderr.text()).toContain( + "invalid Azure OpenAI configuration", + ); + expect(invalidAzureStderr.text()).not.toContain("api-key=secret"); + const references: Array<[JsonObject, ReturnType]> = [ [ diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index 84b7d7c2..66babc33 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -31,7 +31,11 @@ import { VERSION, } from "../src/index.js"; import { main, parseCodexOverrides, Progress } from "../src/cli.js"; -import { DEFAULT_CODEX_CONFIG, scanModelConfiguration } from "../src/config.js"; +import { + AZURE_OPENAI_PROVIDER_ID, + DEFAULT_CODEX_CONFIG, + scanModelConfiguration, +} from "../src/config.js"; import { FakeSignals, REDACTED_CREDENTIALS, @@ -674,6 +678,9 @@ describe("CLI", () => { stderr.stream, dependencies({ currentDirectory: root, + environment: { + AZURE_OPENAI_API_KEY: "unrelated-azure-key", + }, onConfig: (value) => (config = value), onTurn: (_repository, options) => (scanOptions = options), }), @@ -701,6 +708,50 @@ describe("CLI", () => { } }); + test("configures Azure OpenAI for a bulk scan", async () => { + const root = await mkdtemp( + join(tmpdir(), "codex-security-cli-azure-bulk-"), + ); + try { + await multiscanInventory(root); + let config: CodexSecurityConfig | undefined; + expect( + await main( + [ + "bulk-scan", + "repositories.csv", + "--output-dir", + "results", + "--azure-endpoint", + "https://security.openai.azure.com", + "--model", + "security-deployment", + "--json", + ], + capture().stream, + capture().stream, + dependencies({ + currentDirectory: root, + environment: { + AZURE_OPENAI_API_KEY: "synthetic-azure-key", + }, + onConfig: (value) => (config = value), + }), + ), + ).toBe(0); + expect(config).toMatchObject({ + azureOpenAI: { + endpoint: "https://security.openai.azure.com", + }, + codexOverrides: { + model: "security-deployment", + }, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test("preserves the bulk-scan failure summary and redacts progress errors", async () => { const root = await mkdtemp(join(tmpdir(), "codex-security-cli-multiscan-")); try { @@ -1462,6 +1513,8 @@ describe("CLI", () => { expect(help.text()).toContain( `OpenAI model to use (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, ); + expect(help.text()).toContain("--azure-endpoint "); + expect(help.text()).toContain("--model is the deployment name"); expect(help.text()).toContain("--effort "); expect(help.text()).toContain( `Model reasoning effort (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.reasoningEffort}).`, @@ -1499,6 +1552,8 @@ describe("CLI", () => { expect(help.text()).toContain( `OpenAI model for each repository (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, ); + expect(help.text()).toContain("--azure-endpoint "); + expect(help.text()).toContain("--model is the deployment name"); expect(help.text()).toContain("--effort "); expect(help.text()).toContain( `Model reasoning effort (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.reasoningEffort}).`, @@ -1571,6 +1626,36 @@ describe("CLI", () => { } }); + test("configures Azure OpenAI from dedicated scan options", async () => { + let config: CodexSecurityConfig | undefined; + expect( + await main( + [ + "scan", + ".", + "--azure-endpoint", + "https://security.openai.azure.com", + "--model", + "security-deployment", + ], + capture().stream, + capture().stream, + dependencies({ + environment: { AZURE_OPENAI_API_KEY: "synthetic-azure-key" }, + onConfig: (value) => (config = value), + }), + ), + ).toBe(0); + expect(config).toMatchObject({ + azureOpenAI: { + endpoint: "https://security.openai.azure.com", + }, + codexOverrides: { + model: "security-deployment", + }, + }); + }); + test("parses repeatable options and every scan target through Incur", async () => { const pathOutput = capture(); let pathOptions: unknown; @@ -1667,6 +1752,41 @@ describe("CLI", () => { model: "gpt-5.6-terra", model_reasoning_effort: "high", }); + expect( + parseCodexOverrides([], "security-deployment", undefined, { + endpoint: "https://security.openai.azure.com", + }), + ).toEqual({ + model: "security-deployment", + }); + expect(() => + parseCodexOverrides( + ['model_provider="openai"'], + "security-deployment", + undefined, + { endpoint: "https://security.openai.azure.com" }, + ), + ).toThrow("Duplicate --codex key"); + expect(() => + parseCodexOverrides( + [ + `model_providers={${AZURE_OPENAI_PROVIDER_ID}={base_url="https://attacker.example/v1"}}`, + ], + "security-deployment", + undefined, + { endpoint: "https://security.openai.azure.com" }, + ), + ).toThrow("Duplicate --codex key"); + expect(() => + parseCodexOverrides( + [ + `model_providers.${AZURE_OPENAI_PROVIDER_ID}.base_url="https://attacker.example/v1"`, + ], + "security-deployment", + undefined, + { endpoint: "https://security.openai.azure.com" }, + ), + ).toThrow("Duplicate --codex key"); expect(() => parseCodexOverrides( ['model_reasoning_effort="medium"'], @@ -1723,6 +1843,34 @@ describe("CLI", () => { [["scan", ".", "--max-cost=0"], "expected number to be >0"], [["scan", ".", "--path="], "--path must not be empty"], [["scan", ".", "--model="], "--model must not be empty"], + [ + ["scan", ".", "--azure-endpoint", "https://example.openai.azure.com"], + "--azure-endpoint requires --model", + ], + [ + [ + "scan", + ".", + "--azure-endpoint", + "http://example.openai.azure.com", + "--model", + "security-deployment", + ], + "HTTPS", + ], + [ + [ + "scan", + ".", + "--azure-endpoint", + "https://example.openai.azure.com", + "--model", + "security-deployment", + "--auth", + "chatgpt", + ], + "--azure-endpoint cannot be combined with --auth chatgpt", + ], [ ["scan", ".", "--effort", "ultra"], "--effort must be minimal, low, medium, high, or xhigh", @@ -1732,6 +1880,10 @@ describe("CLI", () => { [["scan", ".", "--path", "--dry-run"], "Missing value for flag"], [["scan", ".", "--model", "--dry-run"], "Missing value for flag"], [["scan", ".", "--effort", "--dry-run"], "Missing value for flag"], + [ + ["scan", ".", "--azure-endpoint", "--dry-run"], + "Missing value for flag", + ], [["scan", ".", "--output-dir", "--dry-run"], "Missing value for flag"], [["scan", ".", "--max-cost", "--dry-run"], "Missing value for flag"], [["scan", "repo-a", "repo-b", "--dry-run"], "Unexpected positional"], diff --git a/sdk/typescript/tests-ts/config.test.ts b/sdk/typescript/tests-ts/config.test.ts index a03839f3..49367049 100644 --- a/sdk/typescript/tests-ts/config.test.ts +++ b/sdk/typescript/tests-ts/config.test.ts @@ -11,6 +11,7 @@ import { mergedCodexConfig, writeCodexConfig, } from "../src/index.js"; +import { AZURE_OPENAI_PROVIDER_ID } from "../src/config.js"; const temporaryDirectories: string[] = []; @@ -69,6 +70,83 @@ describe("Codex configuration", () => { ); }); + test("builds validated Azure OpenAI v1 provider overrides", async () => { + const azureConfig = await mergedCodexConfig({ + azureOpenAI: { + endpoint: " https://security-models.openai.azure.com/ ", + }, + }); + expect(azureConfig).toMatchObject({ + model_provider: AZURE_OPENAI_PROVIDER_ID, + model_providers: { + [AZURE_OPENAI_PROVIDER_ID]: { + name: "Azure OpenAI", + base_url: "https://security-models.openai.azure.com/openai/v1", + env_key: "AZURE_OPENAI_API_KEY", + wire_api: "responses", + }, + }, + }); + expect((azureConfig["features"] as JsonObject)["multi_agent_v2"]).toEqual({ + enabled: true, + max_concurrent_threads_per_session: 9, + }); + for (const endpoint of [ + "https://security-models.openai.azure.com/openai", + "https://security-models.openai.azure.com/openai/v1/", + ]) { + const merged = await mergedCodexConfig({ + azureOpenAI: { endpoint }, + }); + expect( + ( + (merged["model_providers"] as JsonObject)[ + AZURE_OPENAI_PROVIDER_ID + ] as JsonObject + )["base_url"], + ).toBe("https://security-models.openai.azure.com/openai/v1"); + } + for (const endpoint of [ + "not-a-url", + "http://security-models.openai.azure.com", + "https://user:secret@security-models.openai.azure.com", + "https://security-models.openai.azure.com?api-key=secret", + "https://security-models.openai.azure.com#fragment", + "https://security-models.openai.azure.com/openai/deployments/legacy", + "https://security-models.openai.azure.com/unexpected", + ]) { + await expect( + mergedCodexConfig({ azureOpenAI: { endpoint } }), + ).rejects.toBeInstanceOf(ConfigurationError); + } + await expect( + mergedCodexConfig({ + azureOpenAI: { + endpoint: "https://security-models.openai.azure.com", + }, + codexOverrides: { model_provider: "existing-provider" }, + }), + ).rejects.toThrow( + "azureOpenAI cannot be combined with a codexOverrides model_provider", + ); + await expect( + mergedCodexConfig({ + azureOpenAI: { + endpoint: "https://security-models.openai.azure.com", + }, + codexOverrides: { + model_providers: { + [AZURE_OPENAI_PROVIDER_ID]: { + base_url: "https://attacker.example/v1", + }, + }, + }, + }), + ).rejects.toThrow( + "azureOpenAI owns its Codex model-provider configuration", + ); + }); + test("deep-merges native multi-agent v2 defaults", async () => { const merged = await mergedCodexConfig({ codexOverrides: { @@ -207,6 +285,53 @@ describe("Codex configuration", () => { }); }); + test("keeps the active Azure key out of model-run shell commands", async () => { + const stateDirectory = join(tmpdir(), "codex-security-azure-state"); + const merged = await mergedCodexConfig({ + azureOpenAI: { + endpoint: "https://security-models.openai.azure.com", + }, + codexOverrides: { + shell_environment_policy: { + exclude: ["EXISTING_SECRET"], + ignore_default_excludes: true, + }, + profile: "azure-scan", + profiles: { + "azure-scan": { + model_provider: AZURE_OPENAI_PROVIDER_ID, + shell_environment_policy: { + exclude: ["PROFILE_SECRET"], + include_only: ["AZURE_OPENAI_API_KEY", "PATH"], + set: { + Azure_OpenAI_Api_Key: "STATIC_SECRET", + SAFE_VALUE: "kept", + }, + }, + }, + }, + }, + }); + + expect( + scanRuntimeCodexConfig(merged, stateDirectory, undefined, true), + ).toMatchObject({ + shell_environment_policy: { + exclude: ["EXISTING_SECRET", "AZURE_OPENAI_API_KEY"], + ignore_default_excludes: true, + }, + profiles: { + "azure-scan": { + shell_environment_policy: { + exclude: ["PROFILE_SECRET", "AZURE_OPENAI_API_KEY"], + include_only: ["PATH"], + set: { SAFE_VALUE: "kept" }, + }, + }, + }, + }); + }); + test("writes Windows sandbox settings accepted by the pinned Codex CLI", async () => { const root = await temporaryDirectory(); const path = join(root, "config.toml"); diff --git a/sdk/typescript/tests-ts/result.test.ts b/sdk/typescript/tests-ts/result.test.ts index 6566e522..24a36093 100644 --- a/sdk/typescript/tests-ts/result.test.ts +++ b/sdk/typescript/tests-ts/result.test.ts @@ -8,6 +8,7 @@ import type { FindingsDocument, ScanManifest, } from "../src/index.js"; +import { createScanResult } from "../src/result.js"; const manifest = { documentType: "codex-security.scan-manifest", @@ -89,6 +90,55 @@ describe("ScanResult", () => { expect(result.toJSON()["cost"]).toEqual(result.cost); }); + test("does not apply OpenAI pricing to an Azure deployment", () => { + const result = createScanResult( + { + manifest, + findings, + coverage, + scanDir: "/scan", + threadId: "thread", + turnResult: { + model: "gpt-5.6-sol", + modelProvider: "azure", + usage: { + input_tokens: 1_250, + cached_input_tokens: 200, + output_tokens: 30, + }, + }, + }, + false, + ); + + expect(result.cost).toBeNull(); + expect(result.toJSON()).toMatchObject({ + cost: null, + turn: { model: "gpt-5.6-sol", modelProvider: "azure" }, + }); + }); + + test("preserves cost estimation for pre-existing provider metadata", () => { + const result = new ScanResult({ + manifest, + findings, + coverage, + scanDir: "/scan", + threadId: "thread", + turnResult: { + model: "gpt-5.6-sol", + modelProvider: "azure", + usage: { + input_tokens: 1_250, + cached_input_tokens: 200, + output_tokens: 30, + }, + }, + }); + + expect(result.cost?.estimatedUsd).toBe(0.00625); + }); + test("discovers SARIF at its canonical scan path", async () => { const scanDir = await mkdtemp(join(tmpdir(), "codex-security-result-")); try {