diff --git a/README.md b/README.md index 15f44770f..c80e07451 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ export AWS_BEARER_TOKEN_BEDROCK="" export AWS_REGION="us-east-2" codex-security scan . --provider amazon-bedrock --model openai.gpt-5.6-luna +export AZURE_OPENAI_ENDPOINT="https://.openai.azure.com" +export AZURE_OPENAI_API_KEY="" +codex-security scan . --provider azure --model + export OPENROUTER_API_KEY="" codex-security scan . --provider openrouter --model anthropic/claude-sonnet-4.5 @@ -86,6 +90,13 @@ export FIREWORKS_API_KEY="" codex-security scan . --provider fireworks --model accounts/fireworks/models/qwen3-235b-a22b ``` +For Azure, `--model` is the **deployment** name, not the underlying model name, +and the deployment must serve a reasoning model because scans always request a +reasoning effort. `AZURE_OPENAI_API_KEY` accepts either a resource key or a +Microsoft Entra access token, so +`az account get-access-token --resource https://ai.azure.com` works in place of +a static key. + ## Documentation **👉👉 See the [Codex Security documentation](https://learn.chatgpt.com/docs/security/cli)** for full documentation. diff --git a/plugins/codex-security/.mcp.json b/plugins/codex-security/.mcp.json index 1abf1d4e0..0b5280fed 100644 --- a/plugins/codex-security/.mcp.json +++ b/plugins/codex-security/.mcp.json @@ -17,6 +17,9 @@ "OPENAI_API_KEY", "OPENROUTER_API_KEY", "FIREWORKS_API_KEY", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_BASE_URL", + "AZURE_OPENAI_ENDPOINT", "AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", diff --git a/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.mjs b/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.mjs index e776ebd5b..9c8a35fe4 100644 --- a/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.mjs +++ b/plugins/codex-security/mcp-app/tests/test_mcp_app_smoke.mjs @@ -69,6 +69,9 @@ assert.deepEqual( "OPENAI_API_KEY", "OPENROUTER_API_KEY", "FIREWORKS_API_KEY", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_BASE_URL", + "AZURE_OPENAI_ENDPOINT", "AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index caaae2e60..548a4df25 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -189,6 +189,10 @@ Access-token environment variables are not scan API keys. For other inference providers: ```bash +export AZURE_OPENAI_ENDPOINT="https://.openai.azure.com" +export AZURE_OPENAI_API_KEY="" +npx @openai/codex-security scan . --provider azure --model + export OPENROUTER_API_KEY="" npx @openai/codex-security scan . --provider openrouter --model anthropic/claude-sonnet-4.5 @@ -200,6 +204,21 @@ export AWS_REGION="us-east-2" npx @openai/codex-security scan . --provider amazon-bedrock --model openai.gpt-5.6-luna ``` +Azure uses the resource's v1 endpoint, so no `api-version` is needed. Set +`AZURE_OPENAI_ENDPOINT` (or `AZURE_OPENAI_BASE_URL`) to the resource address +such as `https://.openai.azure.com`; `/openai/v1` is appended when +absent. `--model` is the deployment name. `AZURE_OPENAI_API_KEY` accepts a +resource key or a Microsoft Entra access token from +`az account get-access-token --resource https://ai.azure.com`. Saved scan +recipes keep the resolved endpoint so a replay targets the same resource. + +Deploy a reasoning model: scans always send a reasoning effort, so a +non-reasoning deployment fails with `Unsupported parameter: 'reasoning.effort'`. +Give the deployment enough tokens-per-minute for scan-sized prompts; a small +quota surfaces as repeated `Rate limit reached` retries. Deploying a model the +cost table already prices, such as `gpt-5.6-sol`, also keeps `--max-cost` and +cost reporting working; other deployments report no cost estimate. + Bedrock also accepts AWS access keys, profiles, web identity, container credentials, and the default AWS credential chain. Set `AWS_REGION` and choose a Bedrock model with `--model`; OpenAI models such as `openai.gpt-5.6-luna` diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index ca308aadf..0bbe9d74f 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -46,6 +46,7 @@ import { } from "./codex-prompt.js"; import { EXTERNAL_CODEX_PROVIDERS, + hasResolvedBaseUrl, isExternalModelProvider, mergedCodexConfig, scanApprovalPolicy, @@ -300,7 +301,8 @@ export type ScanAuthentication = | "OPENAI_API_KEY" | "CODEX_API_KEY" | "OPENROUTER_API_KEY" - | "FIREWORKS_API_KEY"; + | "FIREWORKS_API_KEY" + | "AZURE_OPENAI_API_KEY"; verified: false; } | { @@ -3308,6 +3310,10 @@ async function runtimeScanAuthentication( return authentication; } +const EXTERNAL_PROVIDER_KEYS: ReadonlySet = new Set( + Object.values(EXTERNAL_CODEX_PROVIDERS).map((provider) => provider.env_key), +); + /** @internal */ export function selectedScanEnvironment( environment: ProcessEnvironment, @@ -3325,7 +3331,7 @@ export function selectedScanEnvironment( Object.entries(environment).filter(([name]) => { const key = name.toUpperCase(); if (key === "OPENAI_API_KEY" || key === "CODEX_API_KEY") return false; - if (key === "OPENROUTER_API_KEY" || key === "FIREWORKS_API_KEY") { + if (EXTERNAL_PROVIDER_KEYS.has(key)) { return ( !bedrockProvider && (selectedProviderKey === null || key === selectedProviderKey) @@ -3365,7 +3371,8 @@ function environmentApiKeyEntry( | "OPENAI_API_KEY" | "CODEX_API_KEY" | "OPENROUTER_API_KEY" - | "FIREWORKS_API_KEY"; + | "FIREWORKS_API_KEY" + | "AZURE_OPENAI_API_KEY"; value: string; } | null { const keys = isExternalModelProvider(modelProvider) @@ -3624,8 +3631,19 @@ export function scanPreflightCodexConfig(config: JsonObject): JsonObject { } const modelProvider = scanModelProvider(result); if (isExternalModelProvider(modelProvider)) { + // Per-account endpoints must survive into saved recipes, or replaying a + // recipe would lose the resource the scan actually ran against. + const configured = isRecord(config["model_providers"]) + ? config["model_providers"][modelProvider] + : undefined; + const baseUrl = isRecord(configured) ? configured["base_url"] : undefined; result["model_providers"] = { - [modelProvider]: { ...EXTERNAL_CODEX_PROVIDERS[modelProvider] }, + [modelProvider]: { + ...EXTERNAL_CODEX_PROVIDERS[modelProvider], + ...(hasResolvedBaseUrl(modelProvider) && safeString(baseUrl) + ? { base_url: baseUrl } + : {}), + }, }; } else if (modelProvider === "amazon-bedrock") { const providers = config["model_providers"]; diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 33bf8aca9..a05cae79f 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -83,6 +83,7 @@ import { import { DEFAULT_CODEX_CONFIG, EXTERNAL_CODEX_PROVIDERS, + externalProviderTable, isExternalModelProvider, mergedCodexConfig, scanModelConfiguration, @@ -299,7 +300,7 @@ const VALUE_OPTIONS = new Set([ "--linear-assignee", ]); const PROVIDER_OPTION = z - .enum(["openai", "openrouter", "fireworks", "amazon-bedrock"]) + .enum(["openai", "openrouter", "fireworks", "azure", "amazon-bedrock"]) .default("openai") .describe("Inference provider for scans."); const CREATE_PR_OPTION = z @@ -6645,9 +6646,12 @@ async function executeScan( progress?.stage( `Authentication: API key from ${authentication.source}.`, ); - progress?.stage( - "To use your ChatGPT sign-in, retry with --auth chatgpt.", - ); + // A ChatGPT sign-in cannot reach a third-party provider endpoint. + if (externalProviderSource(authentication) === null) { + progress?.stage( + "To use your ChatGPT sign-in, retry with --auth chatgpt.", + ); + } } else if (authentication.method === "aws_credentials") { progress?.stage( `Authentication: AWS credentials from ${authentication.source}.`, @@ -7142,6 +7146,13 @@ function scanFailureMessage( "Check your Amazon Bedrock bearer token or AWS credential chain." ); } + const unauthorizedProvider = externalProviderSource(authentication); + if (unauthorizedProvider !== null) { + return ( + `Authentication failed using ${unauthorizedProvider}. ` + + "Check the credential and endpoint for the configured provider." + ); + } return authentication?.method === "api_key" ? `Authentication failed using ${authentication.source}. ` + "Retry with '--auth chatgpt' or provide a valid API key." @@ -7154,6 +7165,13 @@ function scanFailureMessage( "Check your AWS identity and Bedrock model permissions." ); } + const forbiddenProvider = externalProviderSource(authentication); + if (forbiddenProvider !== null) { + return ( + `The credential from ${forbiddenProvider} cannot access the configured model. ` + + "Confirm the deployment or model name and the credential's permissions." + ); + } 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." @@ -7168,6 +7186,21 @@ function scanFailureMessage( } } +const EXTERNAL_PROVIDER_SOURCES: ReadonlySet = new Set( + Object.values(EXTERNAL_CODEX_PROVIDERS).map((provider) => provider.env_key), +); + +// ChatGPT sign-in is not an alternative when the scan runs through a +// third-party provider, so those failures need different guidance. +function externalProviderSource( + authentication: ScanAuthentication | null | undefined, +): string | null { + return authentication?.method === "api_key" && + EXTERNAL_PROVIDER_SOURCES.has(authentication.source) + ? authentication.source + : null; +} + function scanScope(arguments_: ScanArguments): string | null { if (arguments_.paths.length > 0) { const displayed = arguments_.paths.slice(0, 3).map((path) => { @@ -7447,6 +7480,7 @@ export function parseCodexOverrides( model?: string, effort?: ScanReasoningEffort, provider?: "openai" | "amazon-bedrock" | ExternalModelProvider, + environment: Readonly> = process.env, ): JsonObject { const result = Object.create(null) as JsonObject; if (model !== undefined) result["model"] = model; @@ -7454,7 +7488,7 @@ export function parseCodexOverrides( if (isExternalModelProvider(provider)) { result["model_provider"] = provider; result["model_providers"] = { - [provider]: { ...EXTERNAL_CODEX_PROVIDERS[provider] }, + [provider]: externalProviderTable(provider, environment), }; } else if (provider === "amazon-bedrock") { result["model_provider"] = provider; diff --git a/sdk/typescript/src/config.ts b/sdk/typescript/src/config.ts index 27cbddd1b..4f7a84c6d 100644 --- a/sdk/typescript/src/config.ts +++ b/sdk/typescript/src/config.ts @@ -35,9 +35,27 @@ export const FIREWORKS_CODEX_PROVIDER = { wire_api: "responses", } as const satisfies JsonObject; +// Azure resources each have their own endpoint, so base_url is resolved from +// the environment instead of being a fixed address. The v1 surface accepts the +// resource key or a Microsoft Entra token as a bearer token, so env_key covers +// both without extra header configuration. +export const AZURE_CODEX_PROVIDER = { + name: "Azure OpenAI", + env_key: "AZURE_OPENAI_API_KEY", + wire_api: "responses", +} as const satisfies JsonObject; + +export const AZURE_ENDPOINT_ENVIRONMENT_VARIABLES = [ + "AZURE_OPENAI_BASE_URL", + "AZURE_OPENAI_ENDPOINT", +] as const; + +const AZURE_V1_PATH = "/openai/v1"; + export const EXTERNAL_CODEX_PROVIDERS = { openrouter: OPENROUTER_CODEX_PROVIDER, fireworks: FIREWORKS_CODEX_PROVIDER, + azure: AZURE_CODEX_PROVIDER, } as const; export type ExternalModelProvider = keyof typeof EXTERNAL_CODEX_PROVIDERS; @@ -51,6 +69,51 @@ export function isExternalModelProvider( ); } +/** Providers whose endpoint is per-account rather than a fixed address. */ +export function hasResolvedBaseUrl(provider: ExternalModelProvider): boolean { + return provider === "azure"; +} + +export function azureBaseUrl( + environment: Readonly>, +): string { + const configured = AZURE_ENDPOINT_ENVIRONMENT_VARIABLES.map((name) => + environment[name]?.trim(), + ).find((value) => value !== undefined && value.length > 0); + if (configured === undefined) { + throw new ConfigurationError( + "Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_ENDPOINT to the Azure OpenAI " + + "resource endpoint, such as https://.openai.azure.com.", + ); + } + let endpoint: URL; + try { + endpoint = new URL(configured); + } catch { + throw new ConfigurationError( + `The Azure OpenAI endpoint must be an absolute URL: ${configured}`, + ); + } + if (endpoint.protocol !== "https:" && endpoint.protocol !== "http:") { + throw new ConfigurationError( + `The Azure OpenAI endpoint must use http or https: ${configured}`, + ); + } + // Accept the resource endpoint Azure prints as well as a full v1 base URL. + const path = endpoint.pathname.replace(/\/+$/u, ""); + return `${endpoint.origin}${path.endsWith(AZURE_V1_PATH) ? path : `${path}${AZURE_V1_PATH}`}`; +} + +/** The complete `model_providers.` table Codex needs for a scan. */ +export function externalProviderTable( + provider: ExternalModelProvider, + environment: Readonly>, +): JsonObject { + const table: JsonObject = { ...EXTERNAL_CODEX_PROVIDERS[provider] }; + if (provider === "azure") table["base_url"] = azureBaseUrl(environment); + return table; +} + export const DEFAULT_CODEX_CONFIG: Readonly = { approval_policy: "on-request", approvals_reviewer: "auto_review", diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 59898f0a9..3e12a2fbb 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -86,6 +86,7 @@ const PLUGIN_HELPER_SECRET_ENVIRONMENT_VARIABLES = new Set([ "CODEX_API_KEY", "OPENROUTER_API_KEY", "FIREWORKS_API_KEY", + "AZURE_OPENAI_API_KEY", ]); const PREPARE_SCAN_ARTIFACT_RESTORER_PROGRAM = ` from pathlib import Path diff --git a/sdk/typescript/tests-ts/azure-provider.test.ts b/sdk/typescript/tests-ts/azure-provider.test.ts new file mode 100644 index 000000000..2ef6d2939 --- /dev/null +++ b/sdk/typescript/tests-ts/azure-provider.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "bun:test"; +import { + scanAuthentication, + scanPreflightCodexConfig, + selectedScanEnvironment, +} from "../src/api.js"; +import { + AZURE_CODEX_PROVIDER, + azureBaseUrl, + externalProviderTable, +} from "../src/config.js"; +import { parseCodexOverrides } from "../src/cli.js"; + +const RESOURCE = "https://synthetic-resource.openai.azure.com"; +const azureEnvironment = (overrides: Record = {}) => ({ + AZURE_OPENAI_BASE_URL: RESOURCE, + AZURE_OPENAI_API_KEY: "synthetic-azure-key", + ...overrides, +}); + +describe("Azure endpoint resolution", () => { + test.each([ + ["bare resource endpoint", RESOURCE], + ["trailing slash", `${RESOURCE}/`], + ["explicit v1 path", `${RESOURCE}/openai/v1`], + ["explicit v1 path with slash", `${RESOURCE}/openai/v1/`], + ])("normalizes the %s", (_name, configured) => { + expect(azureBaseUrl({ AZURE_OPENAI_BASE_URL: configured })).toBe( + `${RESOURCE}/openai/v1`, + ); + }); + + // Azure hands out at least three endpoint domains for the same v1 surface. + test.each([ + ["openai.azure.com", "https://synthetic.openai.azure.com"], + ["services.ai.azure.com", "https://synthetic.services.ai.azure.com"], + [ + "cognitiveservices.azure.com", + "https://synthetic.cognitiveservices.azure.com", + ], + ])("accepts a %s endpoint", (_name, endpoint) => { + expect(azureBaseUrl({ AZURE_OPENAI_BASE_URL: endpoint })).toBe( + `${endpoint}/openai/v1`, + ); + }); + + test("falls back to AZURE_OPENAI_ENDPOINT", () => { + expect(azureBaseUrl({ AZURE_OPENAI_ENDPOINT: RESOURCE })).toBe( + `${RESOURCE}/openai/v1`, + ); + }); + + test("prefers AZURE_OPENAI_BASE_URL over AZURE_OPENAI_ENDPOINT", () => { + expect( + azureBaseUrl({ + AZURE_OPENAI_BASE_URL: RESOURCE, + AZURE_OPENAI_ENDPOINT: "https://ignored.openai.azure.com", + }), + ).toBe(`${RESOURCE}/openai/v1`); + }); + + test.each([ + ["unset", {}], + ["blank", { AZURE_OPENAI_BASE_URL: " " }], + ["not a URL", { AZURE_OPENAI_BASE_URL: "synthetic-resource" }], + ["wrong protocol", { AZURE_OPENAI_BASE_URL: "ftp://synthetic/openai" }], + ])("rejects an endpoint that is %s", (_name, environment) => { + expect(() => azureBaseUrl(environment)).toThrow(); + }); +}); + +describe("Azure provider table", () => { + test("resolves a complete Codex provider table", () => { + expect(externalProviderTable("azure", azureEnvironment())).toEqual({ + ...AZURE_CODEX_PROVIDER, + base_url: `${RESOURCE}/openai/v1`, + }); + }); + + test("uses the Responses wire API", () => { + expect(AZURE_CODEX_PROVIDER.wire_api).toBe("responses"); + expect(AZURE_CODEX_PROVIDER.env_key).toBe("AZURE_OPENAI_API_KEY"); + }); + + test("--provider azure builds the Codex overrides", () => { + expect( + parseCodexOverrides( + [], + "gpt-4.1-mini", + undefined, + "azure", + azureEnvironment(), + ), + ).toEqual({ + model: "gpt-4.1-mini", + model_provider: "azure", + model_providers: { + azure: { ...AZURE_CODEX_PROVIDER, base_url: `${RESOURCE}/openai/v1` }, + }, + }); + }); + + test("requires a deployment name because Azure models are deployments", () => { + expect(() => + parseCodexOverrides( + [], + undefined, + undefined, + "azure", + azureEnvironment(), + ), + ).toThrow("--model is required when using --provider azure"); + }); + + test("reports a missing endpoint rather than building a broken provider", () => { + expect(() => + parseCodexOverrides([], "gpt-4.1-mini", undefined, "azure", { + AZURE_OPENAI_API_KEY: "synthetic-azure-key", + }), + ).toThrow(/AZURE_OPENAI_BASE_URL or AZURE_OPENAI_ENDPOINT/u); + }); +}); + +describe("Azure authentication", () => { + test("selects the Azure key, which carries a resource key or an Entra token", () => { + expect(scanAuthentication(azureEnvironment(), "auto", "azure")).toEqual({ + method: "api_key", + source: "AZURE_OPENAI_API_KEY", + verified: false, + }); + }); + + test("keeps only the selected provider's credential in the scan environment", () => { + const environment = selectedScanEnvironment( + azureEnvironment({ + OPENAI_API_KEY: "synthetic-openai-key", + CODEX_API_KEY: "synthetic-codex-key", + OPENROUTER_API_KEY: "synthetic-openrouter-key", + FIREWORKS_API_KEY: "synthetic-fireworks-key", + PATH: "/usr/bin", + }), + "auto", + "azure", + ); + expect(environment["AZURE_OPENAI_API_KEY"]).toBe("synthetic-azure-key"); + expect(environment["AZURE_OPENAI_BASE_URL"]).toBe(RESOURCE); + expect(environment["PATH"]).toBe("/usr/bin"); + for (const leaked of [ + "OPENAI_API_KEY", + "CODEX_API_KEY", + "OPENROUTER_API_KEY", + "FIREWORKS_API_KEY", + ]) { + expect(environment).not.toHaveProperty(leaked); + } + }); + + test("drops the Azure key when another provider is selected", () => { + const environment = selectedScanEnvironment( + azureEnvironment(), + "auto", + "openrouter", + ); + expect(environment).not.toHaveProperty("AZURE_OPENAI_API_KEY"); + }); +}); + +describe("Azure scan recipes", () => { + test("preserves the resource endpoint and strips provider secrets", () => { + const config = scanPreflightCodexConfig({ + model: "gpt-4.1-mini", + model_provider: "azure", + model_providers: { + azure: { + ...AZURE_CODEX_PROVIDER, + base_url: `${RESOURCE}/openai/v1`, + api_key: "synthetic-azure-secret", + }, + private: { bearer_token: "synthetic-unrelated-secret" }, + }, + }); + expect(config).toEqual({ + model: "gpt-4.1-mini", + model_provider: "azure", + model_providers: { + azure: { ...AZURE_CODEX_PROVIDER, base_url: `${RESOURCE}/openai/v1` }, + }, + }); + expect(JSON.stringify(config)).not.toContain("synthetic-azure-secret"); + expect(JSON.stringify(config)).not.toContain("synthetic-unrelated-secret"); + }); + + test("does not let a recipe override a fixed provider endpoint", () => { + const config = scanPreflightCodexConfig({ + model: "anthropic/claude-sonnet-4.5", + model_provider: "openrouter", + model_providers: { + openrouter: { base_url: "https://synthetic-attacker.example/v1" }, + }, + }); + expect(config["model_providers"]).toMatchObject({ + openrouter: { base_url: "https://openrouter.ai/api/v1" }, + }); + }); +}); diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index dcb5d2545..915894991 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -2353,7 +2353,7 @@ describe("CLI", () => { ); expect(help.text()).toContain("--model "); expect(help.text()).toContain( - "--provider ", + "--provider ", ); expect(help.text()).toContain( `OpenAI model to use (default: ${DEFAULT_SCAN_MODEL_CONFIGURATION.model}).`, @@ -2425,7 +2425,7 @@ describe("CLI", () => { expect(help.text()).not.toContain("--outputDir"); expect(help.text()).not.toContain("--maxAttempts"); expect(help.text()).toContain( - "--provider ", + "--provider ", ); expect(stderr.text()).toBe(""); });