From 6557bbfdefd15d7ac2bf49f31c7e4178d230f43b Mon Sep 17 00:00:00 2001 From: Will Holley Date: Fri, 21 Aug 2026 16:50:25 -0700 Subject: [PATCH] feat: upload to Codex Security Cloud --- sdk/typescript/scripts/check-package.mjs | 1 + sdk/typescript/src/cli.ts | 63 +++- sdk/typescript/src/cloud-publish.ts | 177 ++++++++++ .../tests-ts/cli-cloud-publish.test.ts | 259 ++++++++++++++ sdk/typescript/tests-ts/cloud-publish.test.ts | 320 ++++++++++++++++++ 5 files changed, 810 insertions(+), 10 deletions(-) create mode 100644 sdk/typescript/src/cloud-publish.ts create mode 100644 sdk/typescript/tests-ts/cli-cloud-publish.test.ts create mode 100644 sdk/typescript/tests-ts/cloud-publish.test.ts diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 72603d9f..8b366d3b 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -164,6 +164,7 @@ const distFiles = new Set( "auth", "bulk-scan-discovery", "cli", + "cloud-publish", "codex-prompt", "config", "contract", diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index c7549bb7..88386798 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -55,6 +55,7 @@ import { type ScanPreflight, } from "./api.js"; import { accountStatus } from "./auth.js"; +import { publishScanToCloud } from "./cloud-publish.js"; import { createBulkScanDiscoveryDependencies, runBulkScanWizard, @@ -944,6 +945,7 @@ interface CliDependencies { scanAuthenticationPrompt?: Pick; publishPrompt?: Pick; publishScan?: typeof publishScan; + publishScanToCloud?: typeof publishScanToCloud; confirmPatchReview?: (question: string) => Promise; patchEditor?: ( repository: string, @@ -1843,7 +1845,13 @@ export async function main( .describe("Completed scan directory; omit to select a saved scan."), }), options: z.object({ - to: z.literal("linear").describe("Publication destination."), + // Cloud remains an internal destination, omitted from public discovery. + to: z + .string() + .refine((value) => value === "linear" || value === "cloud", { + message: "Unsupported publication destination. Use --to linear.", + }) + .describe("Publication destination (linear)."), linearTeam: optionValue("--linear-team") .optional() .describe("Linear team ID; defaults to CODEX_SECURITY_LINEAR_TEAM."), @@ -1879,10 +1887,27 @@ export async function main( const onTerminate = (): void => cancel("SIGTERM"); let observingSignals = false; try { - const linearApiKey = resolveLinearApiKey( - dependencies.environment, - options.linearApiKey, - ); + if ( + options.to === "cloud" && + [ + options.linearTeam, + options.linearApiKey, + options.linearProject, + options.project, + options.linearAssignee, + ].some((value) => value !== undefined) + ) { + throw new CodexSecurityError( + "Cloud publication cannot be combined with Linear options.", + ); + } + const linearApiKey = + options.to === "linear" + ? resolveLinearApiKey( + dependencies.environment, + options.linearApiKey, + ) + : undefined; const assigneeId = options.linearAssignee?.trim(); if (options.linearAssignee !== undefined && !assigneeId) { throw new CodexSecurityError("--linear-assignee must not be empty."); @@ -1894,8 +1919,9 @@ export async function main( } const teamId = options.linearTeam?.trim() || - dependencies.environment["CODEX_SECURITY_LINEAR_TEAM"]?.trim(); - if (!teamId) { + dependencies.environment["CODEX_SECURITY_LINEAR_TEAM"]?.trim() || + ""; + if (options.to === "linear" && !teamId) { throw new CodexSecurityError( "--linear-team or CODEX_SECURITY_LINEAR_TEAM is required.", ); @@ -1934,7 +1960,7 @@ export async function main( }).prompt; if (!prompt.isInteractive()) { throw new CodexSecurityError( - "Interactive scan selection requires a terminal. Provide a completed scan directory: codex-security publish scan /path/to/sealed-scan --to linear --linear-team TEAM_ID.", + `Interactive scan selection requires a terminal. Provide a completed scan directory: codex-security publish scan /path/to/sealed-scan --to ${options.to}${options.to === "linear" ? " --linear-team TEAM_ID" : ""}.`, ); } const saved = await dependencies.runWorkbench([ @@ -2081,6 +2107,21 @@ export async function main( repositories.get(scanDir) ?? basename(scanDir); } + if (options.to === "cloud") { + dependencies.addSignalListener("SIGINT", onInterrupt); + dependencies.addSignalListener("SIGTERM", onTerminate); + observingSignals = true; + const result = await ( + dependencies.publishScanToCloud ?? publishScanToCloud + )(resolve(dependencies.currentDirectory(), scanDir), { + environment: dependencies.environment, + dryRun: options.dryRun, + signal: controller.signal, + }); + controller.signal.throwIfAborted(); + return { ...result }; + } + const progress = new PublicationProgressPresenter( errorOutput, dependencies, @@ -2098,7 +2139,7 @@ export async function main( result = await (dependencies.publishScan ?? publishScan)( resolve(dependencies.currentDirectory(), scanDir), { - destination: options.to, + destination: "linear", teamId, ...(projectId === undefined ? {} : { projectId }), dryRun: options.dryRun, @@ -2154,7 +2195,9 @@ export async function main( errorOutput.write(`codex-security: ${reason}${recovery}\n`); exitCode = signal === "SIGINT" ? 130 : 143; } else { - errorOutput.write(`codex-security: ${errorMessage(error)}\n`); + errorOutput.write( + `codex-security: ${options.to === "cloud" ? safeErrorMessage(error) : errorMessage(error)}\n`, + ); exitCode = 2; } return undefined; diff --git a/sdk/typescript/src/cloud-publish.ts b/sdk/typescript/src/cloud-publish.ts new file mode 100644 index 00000000..d4e63c83 --- /dev/null +++ b/sdk/typescript/src/cloud-publish.ts @@ -0,0 +1,177 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "incur"; +import { parse as parseToml } from "smol-toml"; +import { loadContract } from "./contract.js"; +import { AuthenticationRequiredError, CodexSecurityError } from "./errors.js"; +import type { Finding } from "./models.js"; +import { + bundledPluginRoot, + codexSecurityCredentialAllowsAmbientImport, + codexSecurityCredentialHome, + codexSecurityHasStoredFileCredentials, + expandHome, +} from "./runtime.js"; + +const CLOUD_PUBLISH_URL = + "https://chatgpt.com/backend-api/aardvark/cli/findings"; +const CHATGPT_LOGIN_REQUIRED = + "Cloud publication requires a file-backed ChatGPT login. Sign in with ChatGPT using Codex file credential storage, then retry."; + +const credentialsSchema = z.object({ + auth_mode: z.literal("chatgpt").optional(), + OPENAI_API_KEY: z.null().optional(), + tokens: z.object({ + access_token: z.string().trim().min(1), + account_id: z.string().trim().min(1), + }), +}); + +const receiptSchema = z.object({ + status: z.literal("accepted"), + finding_ids: z.array(z.string().min(1)), + finding_count: z.number().int().positive(), +}); + +export interface CloudPublicationResult { + scanId: string; + findingIds: string[]; + findingCount: number; + dryRun?: true; + findings?: Finding[]; +} + +export async function publishScanToCloud( + scanDirectory: string, + dependencies: { + environment?: NodeJS.ProcessEnv; + fetch?: (url: string, options: RequestInit) => Promise; + signal?: AbortSignal; + dryRun?: boolean; + } = {}, +): Promise { + const { manifest, findings } = await loadContract(scanDirectory, { + pluginRoot: await bundledPluginRoot(), + signal: dependencies.signal, + }); + if (findings.findings.length === 0) { + throw new CodexSecurityError( + "The completed scan has no findings to publish.", + ); + } + dependencies.signal?.throwIfAborted(); + if (dependencies.dryRun) { + return { + scanId: manifest.scan.id, + findingIds: [], + findingCount: findings.findings.length, + dryRun: true, + findings: findings.findings, + }; + } + const credentials = await readCloudCredentials( + dependencies.environment ?? process.env, + ); + const timeout = AbortSignal.timeout(30_000); + const signal = dependencies.signal + ? AbortSignal.any([dependencies.signal, timeout]) + : timeout; + let response: Response; + try { + response = await (dependencies.fetch ?? globalThis.fetch)( + CLOUD_PUBLISH_URL, + { + method: "POST", + headers: { + Authorization: `Bearer ${credentials.access_token}`, + "ChatGPT-Account-ID": credentials.account_id, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + schemaVersion: "1.0", + scan: manifest.scan, + findings: findings.findings, + }), + redirect: "error", + signal, + }, + ); + } catch { + // A lost response does not establish whether the server accepted the POST. + throw new CodexSecurityError( + "Cloud publication was not confirmed. The request was not retried; check whether it was accepted before submitting again.", + ); + } + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + const detail = + response.status === 401 + ? "Sign in with ChatGPT again before retrying." + : response.status === 403 + ? "The signed-in account is not authorized to publish to Cloud." + : response.status === 404 + ? "Cloud publication is not available for this account or deployment." + : "The request was not retried."; + throw new CodexSecurityError( + `Cloud publication failed (HTTP ${response.status}). ${detail}`, + ); + } + const receipt = receiptSchema.safeParse( + await response.json().catch(() => undefined), + ); + if ( + (response.status !== 200 && response.status !== 201) || + !receipt.success || + receipt.data.finding_count !== findings.findings.length || + receipt.data.finding_ids.length !== findings.findings.length + ) { + throw new CodexSecurityError( + "Cloud publication returned an invalid acceptance receipt. Check whether the request was accepted before submitting again.", + ); + } + return { + scanId: manifest.scan.id, + findingIds: receipt.data.finding_ids, + findingCount: receipt.data.finding_count, + }; +} + +async function readCloudCredentials(environment: NodeJS.ProcessEnv) { + let home = expandHome( + environment["CODEX_HOME"]?.trim() || "~/.codex", + environment, + ); + const dedicatedHome = codexSecurityCredentialHome(environment); + if (existsSync(dedicatedHome)) { + if (!(await codexSecurityCredentialAllowsAmbientImport(dedicatedHome))) { + throw new AuthenticationRequiredError(CHATGPT_LOGIN_REQUIRED); + } + if (await codexSecurityHasStoredFileCredentials(dedicatedHome)) { + home = dedicatedHome; + } else if (existsSync(join(dedicatedHome, "config.toml"))) { + // Do not silently switch accounts when the dedicated login may be in a keyring. + throw new AuthenticationRequiredError(CHATGPT_LOGIN_REQUIRED); + } + } + try { + const configPath = join(home, "config.toml"); + if (existsSync(configPath)) { + const config = parseToml(await readFile(configPath, "utf8")); + if ( + config["cli_auth_credentials_store"] === "keyring" || + config["cli_auth_credentials_store"] === "auto" + ) { + throw new AuthenticationRequiredError(CHATGPT_LOGIN_REQUIRED); + } + } + const credentials = credentialsSchema.safeParse( + JSON.parse(await readFile(join(home, "auth.json"), "utf8")), + ); + if (credentials.success) return credentials.data.tokens; + } catch { + // Parsing and filesystem diagnostics must not reflect credential contents. + } + throw new AuthenticationRequiredError(CHATGPT_LOGIN_REQUIRED); +} diff --git a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts new file mode 100644 index 00000000..567b1d76 --- /dev/null +++ b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts @@ -0,0 +1,259 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { main } from "../src/cli.js"; +import { + capture, + dependencies, + FakeSignals, + SYNTHETIC_CREDENTIALS, +} from "./cli-fixtures.js"; + +const receipt = { + scanId: "scan-1", + findingIds: ["finding-1"], + findingCount: 1, +}; + +describe("publish scan to Cloud", () => { + test("routes explicit scans to Cloud without initializing Codex or Linear", async () => { + for (const destination of [["--to=cloud"], ["--to", "cloud"]]) { + for (const dryRun of [false, true]) { + const currentDirectory = join(tmpdir(), "cloud-publish-current"); + const deps = dependencies({ + currentDirectory, + environment: { CODEX_SECURITY_LINEAR_API_KEY: " " }, + onWorkbench: () => { + throw new Error("must not inspect scan history"); + }, + }); + deps.createSecurity = () => { + throw new Error("must not initialize Codex"); + }; + deps.publishScan = async () => { + throw new Error("must not publish to Linear"); + }; + let calls = 0; + const result = dryRun + ? { ...receipt, findingIds: [], dryRun: true as const, findings: [] } + : receipt; + deps.publishScanToCloud = async (directory, options) => { + calls++; + expect(directory).toBe(join(currentDirectory, "completed-scan")); + expect(options).toEqual({ + environment: deps.environment, + dryRun, + signal: expect.any(AbortSignal), + }); + return result; + }; + const stdout = capture(); + const stderr = capture(); + expect( + await main( + [ + "publish", + "scan", + "completed-scan", + ...destination, + "--json", + ...(dryRun ? ["--dry-run"] : []), + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(0); + expect(calls).toBe(1); + expect(JSON.parse(stdout.text())).toEqual(result); + expect(stderr.text()).toBe(""); + } + } + }); + + test("reuses the completed-scan picker when no directory is supplied", async () => { + const root = await mkdtemp(join(tmpdir(), "cloud-publish-picker-")); + const scanDir = join(root, "completed-scan"); + try { + await mkdir(scanDir); + const deps = dependencies({ + onWorkbench: (args) => { + expect(args).toEqual(["list-scans", "--status", "complete"]); + return { + scans: [ + { + scanId: "scan-1", + scanDir, + progress: { status: "complete" }, + findingCount: 1, + }, + ], + }; + }, + }); + let selections = 0; + deps.publishPrompt = { + isInteractive: () => true, + select: async (_question, choices) => { + selections++; + const directories: string[] = choices.map(({ value }) => value); + expect(directories).toEqual([scanDir]); + return choices[0]!.value; + }, + }; + deps.publishScanToCloud = async (directory) => { + expect(directory).toBe(scanDir); + return receipt; + }; + const stdout = capture(); + expect( + await main( + ["publish", "scan", "--to", "cloud", "--json"], + stdout.stream, + capture().stream, + deps, + ), + ).toBe(0); + expect(selections).toBe(1); + expect(JSON.parse(stdout.text())).toEqual(receipt); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("requests an explicit scan outside a terminal without suggesting Linear options", async () => { + const deps = dependencies(); + deps.publishPrompt = { + isInteractive: () => false, + select: async () => { + throw new Error("unexpected picker"); + }, + }; + deps.publishScanToCloud = async () => { + throw new Error("unexpected publication"); + }; + const stderr = capture(); + expect( + await main( + ["publish", "scan", "--to", "cloud"], + capture().stream, + stderr.stream, + deps, + ), + ).toBe(2); + expect(stderr.text()).toContain("/path/to/sealed-scan --to cloud"); + expect(stderr.text()).not.toContain("--linear-team"); + }); + + test("rejects Linear-specific options before uploading to Cloud", async () => { + for (const flag of [ + "--linear-team", + "--linear-project", + "--project", + "--linear-assignee", + "--linear-api-key", + ]) { + const deps = dependencies(); + let calls = 0; + deps.publishScanToCloud = async () => { + calls++; + return receipt; + }; + const stdout = capture(); + const stderr = capture(); + expect( + await main( + [ + "publish", + "scan", + "completed-scan", + "--to", + "cloud", + flag, + "synthetic-value", + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(2); + expect(calls).toBe(0); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain("cannot be combined with Linear options"); + } + }); + + test("keeps the internal Cloud destination out of help and discovery", async () => { + for (const flag of ["--help", "--schema", "--llms", "--llms-full"]) { + const stdout = capture(); + const deps = dependencies(); + deps.publishScanToCloud = async () => { + throw new Error("unexpected publication"); + }; + expect( + await main( + ["publish", "scan", flag], + stdout.stream, + capture().stream, + deps, + ), + ).toBe(0); + expect(stdout.text().toLowerCase()).not.toContain("cloud"); + } + }); + + test("reports publication failures without leaking credentials or claiming success", async () => { + const deps = dependencies(); + deps.publishScanToCloud = async () => { + throw new Error(`Cloud failed: ${SYNTHETIC_CREDENTIALS}`); + }; + const stdout = capture(); + const stderr = capture(); + expect( + await main( + ["publish", "scan", "completed-scan", "--to", "cloud"], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(2); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toBe("codex-security: [redacted]\n"); + }); + + test("aborts Cloud publication and removes signal listeners", async () => { + for (const [signal, code] of [ + ["SIGINT", 130], + ["SIGTERM", 143], + ] as const) { + const signals = new FakeSignals(); + const deps = dependencies({ signals }); + deps.publishScanToCloud = async (_directory, options) => { + signals.emit(signal); + expect(options?.signal?.aborted).toBe(true); + options?.signal?.throwIfAborted(); + return receipt; + }; + const stdout = capture(); + const stderr = capture(); + expect( + await main( + ["publish", "scan", "completed-scan", "--to", "cloud"], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(code); + expect(stdout.text()).toBe(""); + expect(stderr.text()).toContain( + signal === "SIGINT" ? "canceled" : "terminated", + ); + expect( + [...signals.listeners.values()].every( + (listeners) => listeners.size === 0, + ), + ).toBe(true); + } + }); +}); diff --git a/sdk/typescript/tests-ts/cloud-publish.test.ts b/sdk/typescript/tests-ts/cloud-publish.test.ts new file mode 100644 index 00000000..9b2d1e1b --- /dev/null +++ b/sdk/typescript/tests-ts/cloud-publish.test.ts @@ -0,0 +1,320 @@ +import { + chmod, + cp, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { publishScanToCloud } from "../src/cloud-publish.js"; +import { + codexSecurityCredentialHome, + setCodexSecurityCredentialLogout, +} from "../src/runtime.js"; +import { PLUGIN_ROOT } from "./plugin-root.js"; + +const directories: string[] = []; +const login = { + auth_mode: "chatgpt", + tokens: { + access_token: "synthetic-access-token", + account_id: "synthetic-account", + refresh_token: "synthetic-refresh-token", + id_token: "synthetic-id-token", + }, +}; +const receipt = { + status: "accepted", + finding_ids: ["finding-1"], + finding_count: 1, +}; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "codex-security-cloud-")); + directories.push(root); + const scan = join(root, "scan"); + const home = join(root, "home"); + await cp(join(PLUGIN_ROOT, "examples", "completed-scan"), scan, { + recursive: true, + }); + if (process.platform !== "win32") await chmod(scan, 0o700); + await mkdir(home, { mode: 0o700 }); + await writeFile(join(home, "auth.json"), JSON.stringify(login), { + mode: 0o600, + }); + return { + scan, + home, + environment: { + CODEX_HOME: home, + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }, + }; +} + +describe("Cloud publication", () => { + test("previews validated findings without credentials or network access", async () => { + const { scan, home, environment } = await fixture(); + await rm(join(home, "auth.json")); + const manifest = JSON.parse( + await readFile(join(scan, "scan-manifest.json"), "utf8"), + ); + const findings = JSON.parse( + await readFile(join(scan, "findings.json"), "utf8"), + ); + let requests = 0; + expect( + await publishScanToCloud(scan, { + environment, + dryRun: true, + fetch: async () => { + requests++; + throw new Error("unexpected request"); + }, + }), + ).toEqual({ + scanId: manifest.scan.id, + findingIds: [], + findingCount: findings.findings.length, + dryRun: true, + findings: findings.findings, + }); + expect(requests).toBe(0); + await writeFile(join(scan, "findings.json"), "{}"); + await expect( + publishScanToCloud(scan, { environment, dryRun: true }), + ).rejects.toThrow(); + }); + + test("posts validated findings and scan provenance with only ChatGPT access credentials", async () => { + const { scan, environment } = await fixture(); + const manifest = JSON.parse( + await readFile(join(scan, "scan-manifest.json"), "utf8"), + ); + const findings = JSON.parse( + await readFile(join(scan, "findings.json"), "utf8"), + ); + let requests = 0; + const result = await publishScanToCloud(scan, { + environment: { ...environment, OPENAI_API_KEY: "synthetic-api-key" }, + fetch: async (url, options) => { + requests++; + expect(new URL(String(url)).origin).toBe("https://chatgpt.com"); + expect(options).toMatchObject({ + method: "POST", + redirect: "error", + headers: { + Authorization: "Bearer synthetic-access-token", + "ChatGPT-Account-ID": "synthetic-account", + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + expect(JSON.parse(String(options!.body))).toEqual({ + schemaVersion: "1.0", + scan: manifest.scan, + findings: findings.findings, + }); + expect(JSON.stringify(options)).not.toContain( + "synthetic-refresh-token", + ); + expect(JSON.stringify(options)).not.toContain("synthetic-id-token"); + expect(options!.signal).toBeInstanceOf(AbortSignal); + return Response.json(receipt, { status: 201 }); + }, + }); + expect(result).toEqual({ + scanId: manifest.scan.id, + findingIds: ["finding-1"], + findingCount: 1, + }); + expect(requests).toBe(1); + }); + + test("prefers the dedicated file login and honors an explicit logout", async () => { + const { scan, environment } = await fixture(); + const home = codexSecurityCredentialHome(environment); + await mkdir(home, { recursive: true, mode: 0o700 }); + await writeFile( + join(home, "auth.json"), + JSON.stringify({ + ...login, + tokens: { ...login.tokens, account_id: "dedicated-account" }, + }), + { mode: 0o600 }, + ); + let requests = 0; + const send = async (_url: string, options: RequestInit) => { + requests++; + expect(new Headers(options!.headers).get("ChatGPT-Account-ID")).toBe( + "dedicated-account", + ); + return Response.json(receipt); + }; + await publishScanToCloud(scan, { environment, fetch: send }); + await setCodexSecurityCredentialLogout(home, true); + await expect( + publishScanToCloud(scan, { environment, fetch: send }), + ).rejects.toThrow("file-backed ChatGPT login"); + expect(requests).toBe(1); + }); + + test("resolves missing or empty CODEX_HOME through the existing user-home helper", async () => { + const { scan, home, environment } = await fixture(); + await mkdir(join(home, ".codex"), { mode: 0o700 }); + await writeFile(join(home, ".codex", "auth.json"), JSON.stringify(login), { + mode: 0o600, + }); + for (const codexHome of [undefined, "", " "]) { + const result = await publishScanToCloud(scan, { + environment: { + ...environment, + CODEX_HOME: codexHome, + HOME: home, + USERPROFILE: home, + }, + fetch: async () => Response.json(receipt), + }); + expect(result.findingIds).toEqual(["finding-1"]); + } + }); + + test("rejects unsupported, missing, or malformed credentials without leaking their contents", async () => { + const { scan, home, environment } = await fixture(); + let requests = 0; + const send = async () => { + requests++; + return Response.json(receipt); + }; + for (const credentials of [ + { auth_mode: "apikey", OPENAI_API_KEY: "synthetic-api-secret" }, + { ...login, auth_mode: "personal_access_token" }, + { ...login, tokens: { access_token: "synthetic-access-token" } }, + {}, + ]) { + await writeFile(join(home, "auth.json"), JSON.stringify(credentials), { + mode: 0o600, + }); + await expect( + publishScanToCloud(scan, { environment, fetch: send }), + ).rejects.toThrow("file-backed ChatGPT login"); + } + await writeFile(join(home, "auth.json"), "malformed-synthetic-secret"); + await expect( + publishScanToCloud(scan, { environment, fetch: send }), + ).rejects.toThrow("file-backed ChatGPT login"); + await rm(join(home, "auth.json")); + await expect( + publishScanToCloud(scan, { environment, fetch: send }), + ).rejects.toThrow("file-backed ChatGPT login"); + expect(requests).toBe(0); + }); + + test("does not use a stale file when keyring or automatic credential storage is selected", async () => { + const { scan, home, environment } = await fixture(); + let requests = 0; + for (const mode of ["keyring", "auto"]) { + await writeFile( + join(home, "config.toml"), + `cli_auth_credentials_store = "${mode}"\n`, + ); + await expect( + publishScanToCloud(scan, { + environment, + fetch: async () => { + requests++; + return Response.json(receipt); + }, + }), + ).rejects.toThrow("file-backed ChatGPT login"); + } + expect(requests).toBe(0); + }); + + test("rejects tampered scan artifacts before reading credentials or uploading", async () => { + const { scan, environment } = await fixture(); + await writeFile(join(scan, "findings.json"), "{}"); + let requests = 0; + await expect( + publishScanToCloud(scan, { + environment, + fetch: async () => { + requests++; + return Response.json(receipt); + }, + }), + ).rejects.toThrow(); + expect(requests).toBe(0); + }); + + test("does not retry HTTP errors or expose server response bodies", async () => { + const { scan, environment } = await fixture(); + for (const status of [401, 403, 404, 413, 422, 429, 503]) { + let requests = 0; + try { + await publishScanToCloud(scan, { + environment, + fetch: async () => { + requests++; + return new Response("synthetic-access-token", { status }); + }, + }); + throw new Error("expected publication to fail"); + } catch (error) { + expect(String(error)).toContain(`HTTP ${status}`); + expect(String(error)).not.toContain("synthetic-access-token"); + } + expect(requests).toBe(1); + } + }); + + test("does not retry an ambiguous transport failure", async () => { + const { scan, environment } = await fixture(); + let requests = 0; + await expect( + publishScanToCloud(scan, { + environment, + fetch: async () => { + requests++; + throw new Error("synthetic-access-token"); + }, + }), + ).rejects.toThrow("Cloud publication was not confirmed"); + expect(requests).toBe(1); + }); + + test("requires a complete acceptance receipt instead of treating any 2xx as success", async () => { + const { scan, environment } = await fixture(); + for (const body of [ + {}, + { ...receipt, status: "queued" }, + { ...receipt, finding_count: 2 }, + { ...receipt, finding_ids: [] }, + ]) { + await expect( + publishScanToCloud(scan, { + environment, + fetch: async () => Response.json(body), + }), + ).rejects.toThrow("invalid acceptance receipt"); + } + await expect( + publishScanToCloud(scan, { + environment, + fetch: async () => Response.json(receipt, { status: 202 }), + }), + ).rejects.toThrow("invalid acceptance receipt"); + }); +});