diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index b655e8a65..3f5500040 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -794,6 +794,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: @@ -852,6 +872,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. + ### Classify finding severity Classify findings after a scan or dedupe without repeating discovery or changing diff --git a/sdk/typescript/scripts/fixtures/package-consumer.ts b/sdk/typescript/scripts/fixtures/package-consumer.ts index 708e67119..5e7c93671 100644 --- a/sdk/typescript/scripts/fixtures/package-consumer.ts +++ b/sdk/typescript/scripts/fixtures/package-consumer.ts @@ -6,9 +6,11 @@ import { classifyScanDirectorySeverity, deduplicateScan, estimateScanCost, + publishScan, + type PublishScanOptions, + type PublishScanResult, planComponents, publishScanToCustom, - publishScan, runComponentScans, type ComponentScanOptions, type DeduplicateScanResult, @@ -119,6 +121,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 de4ffed33..92124e426 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -532,6 +532,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, @@ -575,6 +577,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 8ae7515df..e9cc3f8d0 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -309,6 +309,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"]) @@ -2127,6 +2129,15 @@ export async function main( .describe( "External completed scan directory; Linear and custom accept one scan.", ), + 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() @@ -2272,6 +2283,8 @@ export async function main( if ( options.to !== "linear" && (options.skipExisting || + options.finding !== undefined || + options["expect-digest"] !== undefined || [ options.linearTeam, options.linearApiKey, @@ -2643,6 +2656,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 1b2741afc..7ad1d6c1c 100644 --- a/sdk/typescript/src/publication.ts +++ b/sdk/typescript/src/publication.ts @@ -100,7 +100,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; let classification: SeverityClassification | undefined; if (options.classification !== undefined) { @@ -174,12 +173,7 @@ export async function prepareScanPublication( findingId: finding.findingId, occurrenceId: finding.occurrenceId, title: `[Codex Security][${level.toUpperCase()}] ${finding.title}`, - description: renderFindingDescription( - contract, - finding, - uploadedAt, - assessment, - ), + description: renderFindingDescription(contract, finding, assessment), ...(priority === undefined ? {} : { priority }), }; }), @@ -189,7 +183,6 @@ export async function prepareScanPublication( function renderFindingDescription( contract: LoadedContract, finding: Finding, - uploadedAt: string, assessment?: SeverityAssessment, ): string { const { coverage } = contract; @@ -282,7 +275,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 c304af03a..0f0fc991f 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, @@ -73,6 +73,7 @@ export interface PublishScanOptions { projectId?: string; linearApiKey?: string; assigneeId?: string; + expectedDigest?: string; dryRun?: boolean; skipExisting?: boolean; signal?: AbortSignal; @@ -127,6 +128,7 @@ export interface PublishScanResult { issues?: PreparedPublicationIssue[]; indeterminate?: boolean; warnings?: string[]; + payloadDigest: string; } export type CheckScanPublicationOptions = Pick< @@ -274,39 +276,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, environment }, ); - 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 }; @@ -574,6 +596,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/cli-classify-severity.test.ts b/sdk/typescript/tests-ts/cli-classify-severity.test.ts index 06f1d5e54..9996b916d 100644 --- a/sdk/typescript/tests-ts/cli-classify-severity.test.ts +++ b/sdk/typescript/tests-ts/cli-classify-severity.test.ts @@ -152,6 +152,7 @@ test("publication forwards selected finding IDs only to Linear", async () => { return { scanId: "scan-example", uploadId: "scan-example", + payloadDigest: "a".repeat(64), destination: { type: "linear", teamId: "team-example" }, created: [], failed: [], diff --git a/sdk/typescript/tests-ts/cli-cloud-publish.test.ts b/sdk/typescript/tests-ts/cli-cloud-publish.test.ts index 60b7f0b2d..7934022ad 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 3b7fdbcc3..ac09571cb 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", @@ -434,6 +435,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(); @@ -2308,6 +2367,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 401112d6b..1fb0c233d 100644 --- a/sdk/typescript/tests-ts/publication-integration.test.ts +++ b/sdk/typescript/tests-ts/publication-integration.test.ts @@ -401,6 +401,93 @@ describe("database-backed Linear publication integration", () => { expect(storedPublications(completed)).toHaveLength(2); }); + 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 6e321eb3e..0c097c79f 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( @@ -260,7 +270,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 7720aeb40..8c643d1b4 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 },