diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index fb5e5968e..81122a319 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -689,6 +689,26 @@ a project. Destination flags override `CODEX_SECURITY_LINEAR_TEAM` and `CODEX_SECURITY_LINEAR_PROJECT`. `--dry-run` previews issue titles without contacting Linear; `--json` returns structured results. +Repeat `--finding FINDING_ID` to select findings; omitting it publishes all +findings. Review `--dry-run --json`, then pass its `payloadDigest` as +`--expect-digest` to require the same pending issue payload: + +```bash +npx @openai/codex-security publish scan /path/to/completed-scan \ + --to linear --linear-team TEAM_ID --finding csf_example --dry-run --json + +npx @openai/codex-security publish scan /path/to/completed-scan \ + --to linear --linear-team TEAM_ID --finding csf_example \ + --expect-digest DIGEST_FROM_PREVIEW +``` + +The digest binds the scan, destination, pending issue content and requested +assignee. A mismatch stops before publication writes. Assigned approvals require +the same assignee and API credential; unassigned digests are credential-independent. +The digest is not a permission check or remote readback. Keep previews private: +they contain finding descriptions and source snippets. Descriptions omit the +wall-clock upload timestamp so unchanged inputs produce stable previews. + Sign in to Codex and connect Linear to publish with your existing Codex configuration; publication doesn't use the isolated scan home. To use the Linear API directly, set a personal API key: @@ -747,6 +767,10 @@ Options include `projectId`, `skipExisting`, `linearApiKey` for direct API publication, and `assigneeId` (user ID or email). `checkScanPublication` accepts the same destination options for a read-only check. +Use `findingIds: ["csf_example"]` to select findings. With `dryRun: true`, the +result includes selected `issues` and `payloadDigest`; pass that digest as +`expectedDigest` when publishing. + ### Scan history and reruns Commands default to the current repository. Select scans by full ID or a diff --git a/sdk/typescript/scripts/fixtures/package-consumer.ts b/sdk/typescript/scripts/fixtures/package-consumer.ts index ebe014ef8..0a97a5bcc 100644 --- a/sdk/typescript/scripts/fixtures/package-consumer.ts +++ b/sdk/typescript/scripts/fixtures/package-consumer.ts @@ -2,6 +2,9 @@ import { CodexSecurity, DiffTarget, estimateScanCost, + publishScan, + type PublishScanOptions, + type PublishScanResult, planComponents, runComponentScans, type ComponentScanOptions, @@ -35,6 +38,22 @@ export const cost: ScanCost | null = estimateScanCost("gpt-5.6-sol", { output_tokens: 2, }); +const publicationOptions: PublishScanOptions = { + destination: "linear", + teamId: "team-example", + findingIds: ["finding-example"], + expectedDigest: "0".repeat(64), + dryRun: true, +}; + +export async function previewPublication( + scanDirectory: string, +): Promise { + const result = await publishScan(scanDirectory, publicationOptions); + result.payloadDigest satisfies string; + return result; +} + interface ImportedFinding { id: string; title: string; diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index 80a442b47..f6e37ab86 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -515,6 +515,8 @@ try { assert.equal(publication.counts.findings, 1); assert.equal(publication.counts.created, 0); assert.match(publication.issues[0].title, /^\[Codex Security\]\[HIGH\] /u); + assert.match(publication.payloadDigest, /^[a-f0-9]{64}$/u); + assert.doesNotMatch(publication.issues[0].description, /\*\*Uploaded:\*\*/u); assert.match( run(process.execPath, [launcher, "publish", "scan", "--help"], { cwd: consumer, @@ -558,6 +560,42 @@ try { networkGuard, 'globalThis.fetch = async () => { throw new Error("Publication dry runs must not make network requests."); };\n', ); + assert.deepEqual( + JSON.parse( + run( + process.execPath, + [ + "--require", + networkGuard, + launcher, + "publish", + "scan", + publicationScan, + "--to", + "linear", + "--linear-team", + "team-example", + "--finding", + publication.issues[0].findingId, + "--expect-digest", + publication.payloadDigest, + "--dry-run", + "--json", + ], + { + cwd: consumer, + capture: true, + env: { + ...process.env, + CODEX_SECURITY_LINEAR_PROJECT: "", + CODEX_SECURITY_LINEAR_API_KEY: "", + CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"), + }, + }, + ), + ), + publication, + ); const directPublicationText = run( process.execPath, [ diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 5656c5862..07e7f0a0f 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -287,6 +287,8 @@ const VALUE_OPTIONS = new Set([ "--linear-api-key", "--project", "--linear-assignee", + "--finding", + "--expect-digest", ]); const PROVIDER_OPTION = z .enum(["openai", "openrouter", "fireworks", "amazon-bedrock"]) @@ -2083,6 +2085,15 @@ export async function main( .describe( "External completed scan directory; repeat for multiple scans (Linear accepts one).", ), + finding: z + .array(optionValue("--finding")) + .optional() + .describe( + "Finding ID to publish; repeat to select several. Defaults to all findings.", + ), + "expect-digest": optionValue("--expect-digest") + .optional() + .describe("Require the payload digest from a reviewed dry run."), // Cloud remains an internal destination, omitted from public discovery. to: z .string() @@ -2212,6 +2223,8 @@ export async function main( if ( options.to === "cloud" && (options.skipExisting || + options.finding !== undefined || + options["expect-digest"] !== undefined || [ options.linearTeam, options.linearApiKey, @@ -2542,6 +2555,12 @@ export async function main( ? {} : { expectedScanId: selectedScans[0].scanId }), dryRun: options.dryRun, + ...(options.finding === undefined + ? {} + : { findingIds: options.finding }), + ...(options["expect-digest"] === undefined + ? {} + : { expectedDigest: options["expect-digest"] }), signal: controller.signal, ...(options.skipExisting ? { skipExisting: true } : {}), ...(options.dryRun diff --git a/sdk/typescript/src/publication.ts b/sdk/typescript/src/publication.ts index dfa3bef36..aebaa8bf9 100644 --- a/sdk/typescript/src/publication.ts +++ b/sdk/typescript/src/publication.ts @@ -78,7 +78,6 @@ export async function prepareScanPublication( ...(options.signal === undefined ? {} : { signal: options.signal }), expectedScanId: options.expectedScanId, }); - const uploadedAt = options.uploadedAt ?? new Date().toISOString(); const scanId = contract.manifest.scan.id; return { @@ -98,7 +97,7 @@ export async function prepareScanPublication( findingId: finding.findingId, occurrenceId: finding.occurrenceId, title: `[Codex Security][${finding.severity.level.toUpperCase()}] ${finding.title}`, - description: renderFindingDescription(contract, finding, uploadedAt), + description: renderFindingDescription(contract, finding), ...(priority === undefined ? {} : { priority }), }; }), @@ -108,7 +107,6 @@ export async function prepareScanPublication( function renderFindingDescription( contract: LoadedContract, finding: Finding, - uploadedAt: string, ): string { const { coverage } = contract; const { scan } = contract.manifest; @@ -141,7 +139,6 @@ function renderFindingDescription( `**Scan mode:** ${scanMode(coverage.mode)}`, `**Started:** ${scan.startedAt}`, `**Completed:** ${scan.completedAt}`, - `**Uploaded:** ${uploadedAt}`, "", "### Affected locations", "", diff --git a/sdk/typescript/src/publish.ts b/sdk/typescript/src/publish.ts index eb8321ce9..0a34ce09c 100644 --- a/sdk/typescript/src/publish.ts +++ b/sdk/typescript/src/publish.ts @@ -3,7 +3,7 @@ import { spawnSync, type ChildProcessWithoutNullStreams, } from "node:child_process"; -import { createHash, randomUUID } from "node:crypto"; +import { createHash, createHmac, randomUUID } from "node:crypto"; import { appendFile, mkdir, @@ -69,6 +69,8 @@ export interface PublishScanOptions { projectId?: string; linearApiKey?: string; assigneeId?: string; + findingIds?: readonly string[]; + expectedDigest?: string; dryRun?: boolean; skipExisting?: boolean; signal?: AbortSignal; @@ -123,6 +125,7 @@ export interface PublishScanResult { issues?: PreparedPublicationIssue[]; indeterminate?: boolean; warnings?: string[]; + payloadDigest: string; } export type CheckScanPublicationOptions = Pick< @@ -268,39 +271,59 @@ export async function publishScanInternal( options.signal?.throwIfAborted(); const environment = dependencies.environment ?? process.env; const linearApiKey = publicationApiKey(options, environment); + const approvedAssignee = + options.assigneeId === undefined + ? undefined + : { id: options.assigneeId, key: linearApiKey! }; const preparedScan = await (dependencies.prepare ?? prepareScanPublication)( scanDirectory, options, ); - let prepared = preparedScan; options.signal?.throwIfAborted(); + let prepared = selectPublicationFindings(preparedScan, options.findingIds); + const findingCount = prepared.issues.length; + let skipped: PublishedScanIssue[] | undefined; + if (options.skipExisting) { + const selected = new Set(prepared.issues.map((issue) => issue.findingId)); + skipped = ( + await (dependencies.inspectPublicationStore ?? inspectPublicationStore)( + preparedScan, + environment, + options.signal, + ) + ).filter((issue) => selected.has(issue.findingId)); + const recorded = new Set(skipped.map((issue) => issue.findingId)); + prepared = { + ...prepared, + issues: prepared.issues.filter((issue) => !recorded.has(issue.findingId)), + }; + options.signal?.throwIfAborted(); + } + const payloadDigest = publicationPayloadDigest(prepared, approvedAssignee); + if ( + options.expectedDigest !== undefined && + options.expectedDigest !== payloadDigest + ) { + throw new ConfigurationError( + "The prepared Linear publication does not match the expected digest. Review a new dry run before publishing.", + ); + } const result: PublishScanResult = { scanId: prepared.scanId, uploadId: prepared.scanId, destination: prepared.destination, + payloadDigest, created: [], failed: [], + ...(skipped === undefined ? {} : { skipped }), counts: { - findings: prepared.issues.length, + findings: findingCount, created: 0, failed: 0, + ...(skipped === undefined ? {} : { skipped: skipped.length }), }, }; - if (options.skipExisting) { - result.skipped = await ( - dependencies.inspectPublicationStore ?? inspectPublicationStore - )(preparedScan, environment, options.signal); - result.counts.skipped = result.skipped.length; - const recorded = new Set(result.skipped.map((issue) => issue.findingId)); - prepared = { - ...preparedScan, - issues: preparedScan.issues.filter( - (issue) => !recorded.has(issue.findingId), - ), - }; - options.signal?.throwIfAborted(); - } const saveReceipt = dependencies.writeReceipt ?? writePublicationReceipt; if (options.dryRun) { return { ...result, dryRun: true, issues: prepared.issues }; @@ -568,6 +591,66 @@ export async function publishScanInternal( return result; } +function selectPublicationFindings( + publication: PreparedScanPublication, + findingIds: readonly string[] | undefined, +): PreparedScanPublication { + if (findingIds === undefined) return publication; + if ( + !Array.isArray(findingIds) || + findingIds.some((id) => typeof id !== "string" || !id.trim()) + ) { + throw new ConfigurationError( + "Publication finding IDs must be nonempty strings.", + ); + } + const selected = new Set(findingIds); + const known = new Set(publication.issues.map((issue) => issue.findingId)); + for (const findingId of selected) { + if (!known.has(findingId)) { + throw new ConfigurationError( + `Unknown publication finding ID: ${JSON.stringify(findingId)}.`, + ); + } + } + return { + ...publication, + issues: publication.issues.filter((issue) => selected.has(issue.findingId)), + }; +} + +function publicationPayloadDigest( + publication: PreparedScanPublication, + assignee: { id: string; key: string } | undefined, +): string { + const { destination } = publication; + const digest = + assignee === undefined + ? createHash("sha256") + : createHmac("sha256", assignee.key); + return digest + .update( + JSON.stringify({ + version: assignee === undefined ? 1 : 2, + scanId: publication.scanId, + destination: { + type: destination.type, + teamId: destination.teamId, + projectId: destination.projectId ?? null, + }, + assigneeId: assignee?.id ?? null, + issues: publication.issues.map((issue) => ({ + findingId: issue.findingId, + occurrenceId: issue.occurrenceId, + title: issue.title, + description: issue.description, + priority: issue.priority ?? null, + })), + }), + ) + .digest("hex"); +} + export async function checkScanPublication( scanDirectory: string, options: CheckScanPublicationOptions, diff --git a/sdk/typescript/tests-ts/attack-path-tool-name.test.ts b/sdk/typescript/tests-ts/attack-path-tool-name.test.ts index 03a007f4d..3dbb46f93 100644 --- a/sdk/typescript/tests-ts/attack-path-tool-name.test.ts +++ b/sdk/typescript/tests-ts/attack-path-tool-name.test.ts @@ -1,30 +1,40 @@ -import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "bun:test"; import { PLUGIN_ROOT } from "./plugin-root.js"; -test("keeps model-visible attack-path tool names within the Codex limit", () => { +test("keeps model-visible attack-path tool names within the Codex limit", async () => { const node = Bun.which("node"); expect(node).not.toBeNull(); const state = mkdtempSync(join(tmpdir(), "codex-security-mcp-tools-")); try { - const server = spawnSync(node!, [join(PLUGIN_ROOT, "mcp", "server.mjs")], { - encoding: "utf8", + const server = Bun.spawn({ + cmd: [node!, join(PLUGIN_ROOT, "mcp", "server.mjs")], + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", env: { ...process.env, CODEX_SECURITY_STATE_DIR: state }, - input: [ + timeout: 30_000, + }); + server.stdin.write( + [ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"codex-security-test","version":"1.0.0"}}}', '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}', '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}', "", ].join("\n"), - timeout: 30_000, - }); - expect(server.status, server.stderr).toBe(0); + ); + server.stdin.end(); + const [status, stdout, stderr] = await Promise.all([ + server.exited, + new Response(server.stdout).text(), + new Response(server.stderr).text(), + ]); + expect(status, stderr).toBe(0); - const tools = server.stdout + const tools = stdout .trim() .split("\n") .map((line) => JSON.parse(line)) diff --git a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts index 803c5ad93..0a1f2a5c9 100644 --- a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts @@ -940,6 +940,8 @@ describe("publish scan to Cloud", () => { ["--linear-assignee", "synthetic-value"], ["--linear-api-key", "synthetic-value"], ["--skip-existing"], + ["--finding", "finding-example"], + ["--expect-digest", "0".repeat(64)], ]) { const deps = dependencies(); let calls = 0; diff --git a/sdk/typescript/tests-ts/cli-publish.test.ts b/sdk/typescript/tests-ts/cli-publish.test.ts index c1cc4fb6e..fd8b10966 100644 --- a/sdk/typescript/tests-ts/cli-publish.test.ts +++ b/sdk/typescript/tests-ts/cli-publish.test.ts @@ -59,6 +59,7 @@ function publicationResult( return { scanId: "scan-123", uploadId: "scan-123", + payloadDigest: "a".repeat(64), destination: { type: "linear" as const, teamId: "team-from-flags", @@ -251,6 +252,64 @@ describe("publish check", () => { }); describe("publish scan", () => { + test("advertises executable finding selection and digest flags", async () => { + const stdout = capture(); + const stderr = capture(); + + expect( + await main( + ["publish", "scan", "--llms-full"], + stdout.stream, + stderr.stream, + dependencies(), + ), + ).toBe(0); + expect(stdout.text()).toContain("`--finding`"); + expect(stdout.text()).toContain("`--expect-digest`"); + expect(stdout.text()).not.toContain("`--expectDigest`"); + expect(stderr.text()).toBe(""); + }); + + test("forwards repeated finding selections and the reviewed payload digest", async () => { + const stdout = capture(); + const stderr = capture(); + const deps = dependencies(); + let selected: Record | undefined; + const digest = "a".repeat(64); + deps.publishScan = async (_directory, options) => { + selected = { ...options }; + return { ...publicationResult(), payloadDigest: digest }; + }; + + expect( + await main( + [ + "publish", + "scan", + "completed-scan", + ...DESTINATION_OPTIONS, + "--finding", + "finding-3", + "--finding", + "finding-1", + "--expect-digest", + digest, + "--dry-run", + "--json", + ], + stdout.stream, + stderr.stream, + deps, + ), + ).toBe(0); + expect(selected).toMatchObject({ + findingIds: ["finding-3", "finding-1"], + expectedDigest: digest, + dryRun: true, + }); + expect(JSON.parse(stdout.text()).payloadDigest).toBe(digest); + expect(stderr.text()).toBe(""); + }); test("forwards opt-in retry and reports skipped issues separately", async () => { for (const dryRun of [false, true]) { const stdout = capture(); @@ -2125,6 +2184,26 @@ describe("publish scan", () => { test("requires an explicit supported destination and team with valid optional flags", async () => { const cases: ReadonlyArray<[readonly string[], string]> = [ [["publish", "scan", "completed-scan"], "to"], + [ + [ + "publish", + "scan", + "completed-scan", + ...DESTINATION_OPTIONS, + "--finding", + ], + "Missing value for flag: --finding", + ], + [ + [ + "publish", + "scan", + "completed-scan", + ...DESTINATION_OPTIONS, + "--expect-digest", + ], + "Missing value for flag: --expect-digest", + ], [["publish", "scan", "completed-scan", "--to", "azure"], "linear"], [ ["publish", "scan", "completed-scan", "--to", "linear"], diff --git a/sdk/typescript/tests-ts/publication-integration.test.ts b/sdk/typescript/tests-ts/publication-integration.test.ts index 8f8f7f415..339985a7a 100644 --- a/sdk/typescript/tests-ts/publication-integration.test.ts +++ b/sdk/typescript/tests-ts/publication-integration.test.ts @@ -325,6 +325,93 @@ function receiptPath(fixture: PublicationFixture): string { } describe("database-backed Linear publication integration", () => { + test("publishes a reviewed subset without weakening full-scan history checks", async () => { + const completed = await fixture(3); + const sealed = await artifactDigests(completed.scanDirectory); + const selected = completed.findings[1]!; + const environment = { + ...completed.environment, + CODEX_SECURITY_LINEAR_API_KEY: "synthetic-key", + }; + const preview = await publishScanInternal( + completed.scanDirectory, + { + ...OPTIONS, + findingIds: [selected.findingId], + dryRun: true, + }, + { environment }, + ); + expect(preview.issues?.map((issue) => issue.findingId)).toEqual([ + selected.findingId, + ]); + expect(storedPublications(completed)).toEqual([]); + const stdout = capture(); + const stderr = capture(); + const cli = dependencies({ environment }); + type LinearClient = ReturnType< + NonNullable + >; + type IssueInput = Parameters[0]; + let mutations = 0; + cli.publishScan = async (directory, options) => + publishScanInternal(directory, options, { + environment, + linearClient: () => + ({ + createIssue: async (input: IssueInput) => { + mutations += 1; + expect(input.description).toBe(preview.issues![0]!.description); + return { + success: true, + issue: Promise.resolve({ + identifier: "SEC-SELECTED", + url: "https://linear.app/example/issue/SEC-SELECTED", + }), + }; + }, + }) as unknown as LinearClient, + }); + + expect( + await main( + [ + "publish", + "scan", + completed.scanDirectory, + "--to", + "linear", + "--linear-team", + OPTIONS.teamId, + "--project", + OPTIONS.projectId, + "--finding", + selected.findingId, + "--expect-digest", + preview.payloadDigest, + "--json", + ], + stdout.stream, + stderr.stream, + cli, + ), + ).toBe(0); + const result = JSON.parse(stdout.text()) as PublishScanResult; + expect(result.payloadDigest).toBe(preview.payloadDigest); + expect(result.counts).toEqual({ findings: 1, created: 1, failed: 0 }); + expect(mutations).toBe(1); + expect( + storedPublications(completed).map((record) => [ + record.finding_id, + record.external_id, + ]), + ).toEqual([[selected.findingId, "SEC-SELECTED"]]); + expect(JSON.parse(await readFile(receiptPath(completed), "utf8"))).toEqual( + result, + ); + expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); + }); + test("checks and retries a partial publication without duplicating recorded successes", async () => { const completed = await fixture(2); const sealed = await artifactDigests(completed.scanDirectory); diff --git a/sdk/typescript/tests-ts/publication.test.ts b/sdk/typescript/tests-ts/publication.test.ts index 5581d118e..b095bb7ff 100644 --- a/sdk/typescript/tests-ts/publication.test.ts +++ b/sdk/typescript/tests-ts/publication.test.ts @@ -28,7 +28,6 @@ const DESTINATION = { destination: "linear", teamId: "team_example", projectId: "project_example", - uploadedAt: "2026-06-01T10:30:00Z", } as const; afterEach(async () => { @@ -82,6 +81,17 @@ describe("scan publication preparation", () => { ).rejects.toBe(reason); }); + test("prepares stable descriptions without a wall-clock upload timestamp", async () => { + const scanDirectory = await copyExample(); + const options = { destination: "linear", teamId: "team_example" } as const; + const first = await prepareScanPublication(scanDirectory, options); + const second = await prepareScanPublication(scanDirectory, options); + + expect(second).toEqual(first); + expect(first.issues[0]!.description).toContain("**Completed:**"); + expect(first.issues[0]!.description).not.toContain("**Uploaded:**"); + }); + test("requires artifacts to match the selected saved scan", async () => { const scanDirectory = await copyExample(); await expect( @@ -147,7 +157,7 @@ describe("scan publication preparation", () => { expect(issue.description).toContain("**Scan mode:** standard"); expect(issue.description).toContain("**CWE:** CWE-22"); expect(issue.description).toContain("**Sink:** `src/extract.py:41-44`"); - expect(issue.description).toContain("**Uploaded:** 2026-06-01T10:30:00Z"); + expect(issue.description).not.toContain("**Uploaded:**"); expect(issue.description).toContain("without containment validation"); expect(issue.description).toContain("Normalize destinations"); expect(issue.description).not.toContain("/blob/deadbeef/"); @@ -211,7 +221,6 @@ describe("scan publication preparation", () => { const publication = await prepareScanPublication(scanDirectory, { destination: "linear", teamId: "team_example", - uploadedAt: "2026-06-01T10:30:00Z", }); expect(publication.destination).toEqual({ diff --git a/sdk/typescript/tests-ts/publish.test.ts b/sdk/typescript/tests-ts/publish.test.ts index 62e653ab1..98a7eaf34 100644 --- a/sdk/typescript/tests-ts/publish.test.ts +++ b/sdk/typescript/tests-ts/publish.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { createHash, randomUUID } from "node:crypto"; +import { createHash, createHmac, randomUUID } from "node:crypto"; import { appendFile, mkdtemp, @@ -328,6 +328,109 @@ async function processHasExited(pid: number): Promise { } describe("skip-recorded publication", () => { + test("keeps selected retries and reviewed digests bound to the pending payload", async () => { + const publication = preparedPublication(3); + const recorded: PublishedScanIssue[] = [1, 2].map((index) => ({ + findingId: `finding-${index}`, + occurrenceId: `occurrence-${index}`, + issueIdentifier: `SEC-${index}`, + })); + const options = { + ...OPTIONS, + findingIds: ["finding-1", "finding-3"], + skipExisting: true, + }; + let historyWrites = 0; + let mutations = 0; + const injected = dependencies( + publication, + {}, + { + inspectPublicationStore: async (fullScan) => { + expect(fullScan).toBe(publication); + return recorded; + }, + preparePublicationStore: async (fullScan) => { + expect(fullScan).toBe(publication); + historyWrites++; + }, + runCodex: async () => { + mutations++; + return { + exitCode: 0, + stdout: issueEvent(publication.issues[2]!), + stderr: "", + }; + }, + recordPublishedIssues: async (fullScan, issues) => { + expect(fullScan).toBe(publication); + expect(issues.map((issue) => issue.findingId)).toEqual(["finding-3"]); + return [...issues]; + }, + }, + ); + const preview = await publishScanInternal( + "scan", + { + ...options, + dryRun: true, + }, + injected, + ); + const pendingOnly = await publishScanInternal( + "scan", + { + ...OPTIONS, + findingIds: ["finding-3"], + dryRun: true, + }, + injected, + ); + expect(preview.counts).toEqual({ + findings: 2, + created: 0, + failed: 0, + skipped: 1, + }); + expect(preview.skipped).toEqual([recorded[0]!]); + expect(preview.issues).toEqual([publication.issues[2]!]); + expect(preview.payloadDigest).toBe(pendingOnly.payloadDigest); + expect(historyWrites).toBe(0); + expect(mutations).toBe(0); + + const published = await publishScanInternal( + "scan", + { + ...options, + expectedDigest: preview.payloadDigest, + }, + injected, + ); + expect(published.counts).toEqual({ + findings: 2, + created: 1, + failed: 0, + skipped: 1, + }); + expect(published.payloadDigest).toBe(preview.payloadDigest); + expect(historyWrites).toBe(1); + expect(mutations).toBe(1); + + recorded.push(...published.created); + await expect( + publishScanInternal( + "scan", + { + ...options, + expectedDigest: preview.payloadDigest, + }, + injected, + ), + ).rejects.toThrow("does not match the expected digest"); + expect(historyWrites).toBe(1); + expect(mutations).toBe(1); + }); + test("forwards cancellation into read-only history inspection", async () => { const publication = preparedPublication(); const controller = new AbortController(); @@ -1077,6 +1180,281 @@ describe("direct Linear API publication", () => { }); describe("connected Linear publication", () => { + test("publishes only selected findings while verifying the full scan history", async () => { + const publication = preparedPublication(3); + const selected = [publication.issues[0]!, publication.issues[2]!]; + const preview = await publishScanInternal( + publication.scanDirectory, + { + ...OPTIONS, + findingIds: ["finding-3", "finding-1", "finding-1"], + dryRun: true, + }, + dependencies(publication), + ); + expect(preview.issues).toEqual(selected); + expect(preview.payloadDigest).toMatch(/^[a-f0-9]{64}$/u); + let verified = false; + let persisted = false; + + const result = await publishScanInternal( + publication.scanDirectory, + { + ...OPTIONS, + findingIds: ["finding-1", "finding-3"], + expectedDigest: preview.payloadDigest, + }, + dependencies( + publication, + {}, + { + preparePublicationStore: async (full) => { + expect(full).toBe(publication); + verified = true; + }, + runCodex: async (_command, _args, input) => { + expect(verified).toBe(true); + const payload = JSON.parse( + await readFile(publicationData(input).publicationFile, "utf8"), + ); + expect( + payload.batches + .flat() + .map((issue: PreparedPublicationIssue) => issue.findingId), + ).toEqual(["finding-1", "finding-3"]); + expect(JSON.stringify(payload)).not.toContain("finding-2"); + return { + exitCode: 0, + stdout: selected.map((issue) => issueEvent(issue)).join("\n"), + stderr: "", + }; + }, + recordPublishedIssues: async (full, issues) => { + expect(full).toBe(publication); + expect(issues.map((issue) => issue.findingId)).toEqual([ + "finding-1", + "finding-3", + ]); + persisted = true; + return [...issues]; + }, + }, + ), + ); + expect(persisted).toBe(true); + expect(result.payloadDigest).toBe(preview.payloadDigest); + expect(result.counts).toEqual({ findings: 2, created: 2, failed: 0 }); + }); + + test("rejects changed approved payloads before local or remote publication work", async () => { + const original = preparedPublication(2); + const preview = await publishScanInternal( + original.scanDirectory, + { ...OPTIONS, dryRun: true }, + dependencies(original), + ); + const changes: Array< + ( + publication: PreparedScanPublication, + options: PublishScanOptions, + ) => void + > = [ + (publication) => { + publication.scanId = "different-scan"; + }, + (publication) => { + publication.destination.teamId = "different-team"; + }, + (publication) => { + delete publication.destination.projectId; + }, + (publication) => { + publication.issues[0]!.occurrenceId = "different-occurrence"; + }, + (publication) => { + publication.issues[0]!.title = "Changed title"; + }, + (publication) => { + publication.issues[0]!.description += "\nChanged content"; + }, + (publication) => { + publication.issues[0]!.priority = 4; + }, + (_publication, options) => { + options.findingIds = ["finding-1"]; + }, + (_publication, options) => { + options.linearApiKey = "synthetic-key"; + options.assigneeId = "another-user"; + }, + ]; + for (const change of changes) { + const publication = structuredClone(original); + const options: PublishScanOptions = { + ...OPTIONS, + expectedDigest: preview.payloadDigest, + }; + change(publication, options); + let started = false; + await expect( + publishScanInternal( + publication.scanDirectory, + options, + dependencies( + publication, + {}, + { + preparePublicationStore: async () => { + started = true; + }, + resolveCodex: () => { + started = true; + return { command: "must-not-run" }; + }, + linearClient: linearApiClient(publication, { + configured: () => { + started = true; + }, + }), + }, + ), + ), + ).rejects.toThrow("does not match the expected digest"); + expect(started).toBe(false); + } + }); + + test("rejects unknown selected findings and keeps an empty selection inert", async () => { + const publication = preparedPublication(); + let started = false; + const injected = dependencies( + publication, + {}, + { + preparePublicationStore: async () => { + started = true; + }, + }, + ); + for (const findingIds of [["missing-finding"], [""]]) { + await expect( + publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, findingIds }, + injected, + ), + ).rejects.toThrow(/finding ID/u); + } + const empty = await publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, findingIds: [] }, + injected, + ); + expect(empty.counts).toEqual({ findings: 0, created: 0, failed: 0 }); + expect(started).toBe(false); + }); + + test("keys assigned approvals without exposing the assignee or credential", async () => { + const publication = preparedPublication(); + const preview = ( + linearApiKey: string, + assigneeId = "reviewer@example.test", + ) => + publishScanInternal( + publication.scanDirectory, + { + ...OPTIONS, + linearApiKey, + assigneeId, + dryRun: true, + }, + dependencies(publication), + ); + const first = await preview("synthetic-first-key"); + const repeated = await preview("synthetic-first-key"); + const rotated = await preview("synthetic-rotated-key"); + const reassigned = await preview( + "synthetic-first-key", + "another@example.test", + ); + expect(repeated.payloadDigest).toBe(first.payloadDigest); + expect(rotated.payloadDigest).not.toBe(first.payloadDigest); + expect(reassigned.payloadDigest).not.toBe(first.payloadDigest); + expect(first.payloadDigest).toBe( + createHmac("sha256", "synthetic-first-key") + .update( + JSON.stringify({ + version: 2, + scanId: publication.scanId, + destination: { + type: publication.destination.type, + teamId: publication.destination.teamId, + projectId: publication.destination.projectId ?? null, + }, + assigneeId: "reviewer@example.test", + issues: publication.issues.map((issue) => ({ + findingId: issue.findingId, + occurrenceId: issue.occurrenceId, + title: issue.title, + description: issue.description, + priority: issue.priority ?? null, + })), + }), + ) + .digest("hex"), + ); + expect(JSON.stringify(first)).not.toContain("synthetic-first-key"); + expect(JSON.stringify(first)).not.toContain("reviewer@example.test"); + + const unassigned = (linearApiKey: string) => + publishScanInternal( + publication.scanDirectory, + { ...OPTIONS, linearApiKey, dryRun: true }, + dependencies(publication), + ); + expect((await unassigned("synthetic-first-key")).payloadDigest).toBe( + (await unassigned("synthetic-rotated-key")).payloadDigest, + ); + + const approved = { + ...OPTIONS, + linearApiKey: "synthetic-first-key", + assigneeId: "reviewer@example.test", + expectedDigest: first.payloadDigest, + }; + expect( + ( + await publishScanInternal( + publication.scanDirectory, + { ...approved, dryRun: true }, + dependencies(publication), + ) + ).payloadDigest, + ).toBe(first.payloadDigest); + let started = false; + await expect( + publishScanInternal( + publication.scanDirectory, + { ...approved, linearApiKey: "synthetic-rotated-key" }, + dependencies( + publication, + {}, + { + preparePublicationStore: async () => { + started = true; + }, + linearClient: linearApiClient(publication, { + configured: () => { + started = true; + }, + }), + }, + ), + ), + ).rejects.toThrow("does not match the expected digest"); + expect(started).toBe(false); + }); + test("rejects pre-aborted publication before preparing scans or touching local state", async () => { const publication = preparedPublication(); const controller = new AbortController(); @@ -1146,7 +1524,8 @@ describe("connected Linear publication", () => { publication, {}, { - prepare: async () => { + prepare: async (_scanDirectory, prepareOptions) => { + expect(prepareOptions.signal).toBe(controller.signal); controller.abort(new Error("Publication preparation stopped.")); return publication; }, @@ -1390,6 +1769,7 @@ describe("connected Linear publication", () => { scanId: "scan-example", uploadId: "scan-example", destination: publication.destination, + payloadDigest: expect.stringMatching(/^[a-f0-9]{64}$/u), created: [ { findingId: "finding-1", @@ -3026,6 +3406,7 @@ describe("connected Linear publication", () => { scanId: "scan-example", uploadId: "scan-example", destination: publication.destination, + payloadDigest: expect.stringMatching(/^[a-f0-9]{64}$/u), created: [], failed: [], counts: { findings: 2, created: 0, failed: 0 },