diff --git a/docs/plugin-connectors.md b/docs/plugin-connectors.md index 69558b844e..3157e3feeb 100644 --- a/docs/plugin-connectors.md +++ b/docs/plugin-connectors.md @@ -8,6 +8,8 @@ Codex Security uses optional connected apps for ticket intake and finding tracki | GitHub | Finding intake, source lookup, and issue tracking | | Atlassian | Jira Cloud ticket intake and finding tracking | +Finding tracking and CLI publication share issue associations when they use the same local state directory, including reuse across scan occurrences. The tracking skill reads them with `get_codex_security_finding_issues` and records accepted writes or verified reuse with `record_codex_security_finding_issues`. Provider reads still confirm saved candidates and search for unrecorded duplicates. Accepted writes remain recorded even when follow-up reads fail. The tools accept sealed scan bundles without requiring their original local scan history and keep receipts outside those bundles. + ## Jira setup Connect the **Atlassian** app in the app directory. Existing Atlassian Rovo (Legacy) connections do not connect the new app; authorize it separately with the intended account and site. diff --git a/plugins/codex-security/mcp-app/server.ts b/plugins/codex-security/mcp-app/server.ts index cd80f8b563..026440cbe5 100644 --- a/plugins/codex-security/mcp-app/server.ts +++ b/plugins/codex-security/mcp-app/server.ts @@ -18,6 +18,7 @@ import { type HandoffWorkspaceState as WorkspaceState, } from "./src/server/handoff-tools.js"; import { registerCompactArtifactTools } from "./src/server/compact-artifact-tools.js"; +import { registerFindingIssueTools } from "./src/server/finding-issue-tools.js"; import { DeepScanCoordinatorRegistry, AsyncLock, @@ -1811,6 +1812,7 @@ export function createCodexSecurityServer(): McpServer { ), ); + registerFindingIssueTools(server, runWorkbench); registerCompactArtifactTools(server, { runWorkbench, pluginRoot: PLUGIN_ROOT, diff --git a/plugins/codex-security/mcp-app/src/server/finding-issue-tools.ts b/plugins/codex-security/mcp-app/src/server/finding-issue-tools.ts new file mode 100644 index 0000000000..b1ad13e039 --- /dev/null +++ b/plugins/codex-security/mcp-app/src/server/finding-issue-tools.ts @@ -0,0 +1,96 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod/v4"; +import type { JsonObject } from "../types.js"; +import { readingAnnotations, writingAnnotations } from "./tool-annotations.js"; + +const identifier = z.string().min(1); +const destinationSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("linear"), + teamId: identifier, + projectId: identifier.optional(), + }) + .strict(), + z + .object({ + type: z.literal("jira"), + cloudId: identifier, + projectId: identifier, + }) + .strict(), + z + .object({ + type: z.enum(["github-issue", "github-advisory"]), + hostname: identifier, + repository: identifier, + }) + .strict(), +]); +const sourceSchema = z + .object({ + scanDirectory: identifier, + destination: destinationSchema, + findingIds: z.array(identifier).optional(), + }) + .strict(); +const receiptSchema = z + .object({ + findingId: identifier, + occurrenceId: identifier, + issueIdentifier: identifier, + url: identifier.optional(), + operation: z.enum(["create", "update", "reuse"]), + readback: z + .object({ + status: z.enum(["verified", "failed"]), + error: z.string().optional(), + }) + .strict() + .optional(), + }) + .strict(); + +export function registerFindingIssueTools( + server: McpServer, + runWorkbench: ( + args: string[], + input?: string | Buffer, + ) => Promise, +): void { + const meta = { ui: { visibility: ["model"] } }; + const execute = async (action: "inspect" | "record", input: object) => { + const result = await runWorkbench( + ["finding-issues"], + JSON.stringify({ action, ...input }), + ); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result, + }; + }; + server.registerTool( + "get_codex_security_finding_issues", + { + title: "Get Codex Security Finding Issues", + description: + "Validate a sealed scan and read its findings' saved issue associations for the exact destination, including earlier scan occurrences. Saved receipts are duplicate candidates; verify them through the provider before reusing an issue. Does not contact providers or require local scan history.", + inputSchema: sourceSchema, + annotations: readingAnnotations, + _meta: meta, + }, + (input) => execute("inspect", input), + ); + server.registerTool( + "record_codex_security_finding_issues", + { + title: "Record Codex Security Finding Issues", + description: + "Validate a sealed scan and save accepted provider creates or updates, or verified issue reuse, in the shared local issue history. Record the returned identity immediately; repeat the same receipt with readback status after an optional provider read. A failed read or local save does not undo an accepted write or authorize another create. Does not contact providers or modify the scan bundle.", + inputSchema: sourceSchema.extend({ receipts: z.array(receiptSchema) }), + annotations: writingAnnotations, + _meta: meta, + }, + (input) => execute("record", input), + ); +} diff --git a/plugins/codex-security/mcp-app/src/workbench-client.ts b/plugins/codex-security/mcp-app/src/workbench-client.ts index 70392d5851..980634cc27 100644 --- a/plugins/codex-security/mcp-app/src/workbench-client.ts +++ b/plugins/codex-security/mcp-app/src/workbench-client.ts @@ -66,6 +66,7 @@ async function executeWorkbench( "complete-scan", "export-findings", "finish-deep-scan", + "finding-issues", "get-scan", "get-deep-scan", "get-workspace", diff --git a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts index 06ce49fa80..4f76bfd674 100644 --- a/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts +++ b/plugins/codex-security/mcp-app/tests/test_compact_artifact_server.ts @@ -1199,6 +1199,8 @@ async function testParentToolList(bundle: string) { "record_candidate_attack_paths", "record_codex_security_scan_draft", "get_codex_security_completed_scan", + "get_codex_security_finding_issues", + "record_codex_security_finding_issues", "complete_codex_security_scan", ]) { assert.equal(names.has(name), true, `Missing parent MCP tool ${name}.`); diff --git a/plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts b/plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts new file mode 100644 index 0000000000..bee0865e2b --- /dev/null +++ b/plugins/codex-security/mcp-app/tests/test_finding_issue_tools.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { cp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import type { TextContent } from "@modelcontextprotocol/sdk/types.js"; +import { applicationRoot, buildServer } from "./build-server.ts"; +import { temporaryDirectory } from "./support/temporary-directories.ts"; + +const pluginRoot = path.resolve(applicationRoot, ".."); +const runtimePluginRoot = process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT + ? path.resolve(process.env.CODEX_SECURITY_TEST_PLUGIN_ROOT) + : path.resolve(applicationRoot, "../../../sdk/typescript/_bundled_plugin"); +const root = await temporaryDirectory("codex-security-finding-issues-mcp-"); +const scanDirectory = path.join(root, "scan"); +const stateDirectory = path.join(root, "state"); +const bundle = path.join(root, "server.cjs"); +const client = new Client({ name: "finding-issues-test", version: "1.0.0" }); + +try { + await cp(path.join(pluginRoot, "examples", "completed-scan"), scanDirectory, { + recursive: true, + }); + await buildServer(bundle, { target: "node20" }); + await client.connect( + new StdioClientTransport({ + command: process.execPath, + args: [bundle, "--stdio"], + cwd: applicationRoot, + env: { + ...process.env, + CODEX_SECURITY_PLUGIN_ROOT: runtimePluginRoot, + CODEX_SECURITY_STATE_DIR: stateDirectory, + } as Record, + }), + ); + const findingsPath = path.join(scanDirectory, "findings.json"); + const findingsBytes = await readFile(findingsPath, "utf8"); + const { findingId, occurrenceId } = JSON.parse(findingsBytes).findings[0]; + const manifestBytes = await readFile( + path.join(scanDirectory, "scan-manifest.json"), + ); + const source = { scanDirectory, findingIds: [findingId] }; + const jira = { type: "jira", cloudId: "example-site", projectId: "10001" }; + const linear = { type: "linear", teamId: "example-team" }; + const receipt = { + findingId, + occurrenceId, + issueIdentifier: "APP-1", + operation: "create", + }; + + const tools = (await client.listTools()).tools; + for (const name of [ + "get_codex_security_finding_issues", + "record_codex_security_finding_issues", + ]) { + const tool = tools.find((entry) => entry.name === name); + assert.ok(tool, `Missing model-visible tool ${name}`); + assert.deepEqual(tool._meta?.ui, { visibility: ["model"] }); + assert.equal( + Object.hasOwn(tool.inputSchema.properties ?? {}, "requireHistory"), + false, + ); + } + const empty = await call("get_codex_security_finding_issues", { + ...source, + destination: jira, + }); + assert.deepEqual(empty.receipts, []); + await assert.rejects(stat(path.join(stateDirectory, "workbench.sqlite3")), { + code: "ENOENT", + }); + + const accepted = await call("record_codex_security_finding_issues", { + ...source, + destination: jira, + receipts: [receipt], + }); + assert.equal(accepted.receipts.length, 1); + assert.equal(accepted.receipts[0].issueIdentifier, "APP-1"); + assert.equal(accepted.receipts[0].readback, undefined); + const readback = { + status: "failed", + error: `Issue read access denied. ${"x".repeat(4 * 1024 * 1024)}`, + }; + await call("record_codex_security_finding_issues", { + ...source, + destination: jira, + receipts: [{ ...receipt, readback }], + }); + const saved = await call("get_codex_security_finding_issues", { + ...source, + destination: jira, + }); + assert.equal( + saved.receipts.length, + 1, + "Readback must update the accepted receipt.", + ); + assert.equal(saved.receipts[0].operation, "create"); + assert.deepEqual(saved.receipts[0].readback, readback); + assert.deepEqual( + ( + await call("get_codex_security_finding_issues", { + ...source, + destination: { ...jira, cloudId: "another-site" }, + }) + ).receipts, + [], + ); + + await call("record_codex_security_finding_issues", { + ...source, + destination: linear, + receipts: [ + { ...receipt, operation: "reuse", readback: { status: "verified" } }, + ], + }); + const reused = await call("get_codex_security_finding_issues", { + ...source, + destination: linear, + }); + assert.equal(reused.receipts.length, 1); + assert.equal(reused.receipts[0].operation, "reuse"); + assert.deepEqual(reused.receipts[0].readback, { status: "verified" }); + assert.deepEqual( + await readFile(path.join(scanDirectory, "scan-manifest.json")), + manifestBytes, + ); + assert.equal(await readFile(findingsPath, "utf8"), findingsBytes); + + await writeFile(findingsPath, findingsBytes + "\n"); + for (const [name, input] of [ + ["get_codex_security_finding_issues", { ...source, destination: jira }], + [ + "record_codex_security_finding_issues", + { ...source, destination: jira, receipts: [receipt] }, + ], + ] as const) { + const result = await client.callTool({ name, arguments: input }); + assert.equal( + result.isError, + true, + "Changed sealed artifacts must fail before association access.", + ); + assert.match( + (result.content as TextContent[])[0].text, + /sealed artifact changed/, + ); + } +} finally { + await client.close(); + await rm(root, { recursive: true, force: true }); +} + +async function call(name: string, input: Record) { + const result = await client.callTool({ name, arguments: input }); + assert.notEqual( + result.isError, + true, + (result.content as TextContent[])[0]?.text, + ); + return result.structuredContent as { + receipts: { + issueIdentifier: string; + operation: string; + readback?: { status: string; error?: string }; + }[]; + }; +} diff --git a/plugins/codex-security/mcp-app/tests/test_workbench_process.ts b/plugins/codex-security/mcp-app/tests/test_workbench_process.ts index 8dbdba2bc9..d5c309ff9d 100644 --- a/plugins/codex-security/mcp-app/tests/test_workbench_process.ts +++ b/plugins/codex-security/mcp-app/tests/test_workbench_process.ts @@ -56,6 +56,7 @@ try { "fail-scan", "preserve-scan-results", "complete-scan", + "finding-issues", "start-prompt-only-scan", ]) { await executeWorkbench("fixture-python", [command, "--scan-id", "fixture"]); diff --git a/plugins/codex-security/scripts/workbench_cli.py b/plugins/codex-security/scripts/workbench_cli.py index c3928f886a..cc54fcc21f 100644 --- a/plugins/codex-security/scripts/workbench_cli.py +++ b/plugins/codex-security/scripts/workbench_cli.py @@ -299,12 +299,7 @@ def add_command(name: str, *required: str) -> argparse.ArgumentParser: export_findings.add_argument("--format", choices=(*EXPORT_FORMATS, "md")) export_findings.add_argument("--validate-only", action="store_true") - for command in ( - "inspect-linear-publication", - "prepare-linear-publication", - "record-linear-publications", - ): - add_command(command, "--input-file") + subparsers.add_parser("finding-issues") subparsers.add_parser("database-info") subparsers.add_parser("finding-workflow") diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 3bd233040e..9569cdbe89 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -3359,10 +3359,8 @@ def main(*, before_archive: Callable[[], None] | None = None) -> None: if args.command in {"save-artifact", "read-artifact"}: print(json.dumps(saved_results.read_or_save_artifact(args))) return - if args.command == "inspect-linear-publication": - result = publication.inspect_linear_publication( - _WORKBENCH_PUBLICATION_CONTEXT, read_json_object(Path(args.input_file)) - ) + if args.command == "finding-issues": + result = publication.finding_issues(_WORKBENCH_PUBLICATION_CONTEXT, json.load(sys.stdin)) print(json.dumps(result, allow_nan=False, sort_keys=True)) return with closing( @@ -3415,21 +3413,10 @@ def main(*, before_archive: Callable[[], None] | None = None) -> None: "attach-scan-continuation-thread": partial( handoff.attach_scan_continuation_thread, command_context ), - "prepare-linear-publication": partial( - publication.prepare_linear_publication, _WORKBENCH_PUBLICATION_CONTEXT - ), - "record-linear-publications": partial( - publication.record_linear_publications, _WORKBENCH_PUBLICATION_CONTEXT - ), "export-findings": partial(publication.export_findings, _WORKBENCH_PUBLICATION_CONTEXT), } if handler := handlers.get(args.command): - payload = ( - read_json_object(Path(args.input_file)) - if args.command in {"prepare-linear-publication", "record-linear-publications"} - else args - ) - result = handler(connection, payload) + result = handler(connection, args) elif args.command == "get-workspace": result = workspace_state( connection, diff --git a/plugins/codex-security/scripts/workbench_publication.py b/plugins/codex-security/scripts/workbench_publication.py index b82034b45d..7abccf36bb 100644 --- a/plugins/codex-security/scripts/workbench_publication.py +++ b/plugins/codex-security/scripts/workbench_publication.py @@ -1,10 +1,11 @@ -"""Validate, record, and export findings from completed scans.""" +"""Validate, record, and export findings from sealed scans.""" from __future__ import annotations import argparse import csv import io +import json import os import sqlite3 import sys @@ -26,88 +27,50 @@ write_export_output, write_sarif_projection, ) +from validate_scan_contract import validate_contract -def linear_publication_input( - payload: dict[str, Any], - *, - recording: bool, -) -> tuple[dict[str, Any], dict[str, str], list[dict[str, str]]]: - required = {"scanId", "scanDirectory", "destination", "findings"} - if recording: - required.add("publications") - if set(payload) != required: - raise SystemExit("Linear publication input contains unexpected or missing fields.") - - scan_id = payload["scanId"] - scan_directory = payload["scanDirectory"] - destination = payload["destination"] - findings = payload["findings"] - if not isinstance(scan_id, str) or not isinstance(scan_directory, str): - raise SystemExit("Linear publication input must identify the exact completed scan.") - if ( - not isinstance(destination, dict) - or not {"type", "teamId"}.issubset(destination) - or not set(destination).issubset({"type", "teamId", "projectId"}) - or destination.get("type") != "linear" - or not isinstance(destination.get("teamId"), str) - or not destination["teamId"].strip() - or ( - "projectId" in destination - and ( - not isinstance(destination["projectId"], str) - or not destination["projectId"].strip() - ) - ) - ): - raise SystemExit( - "Linear publication input must identify the exact team and optional project." - ) - if not isinstance(findings, list): - raise SystemExit("Linear publication input must include the planned scan findings.") - - seen_finding_ids: set[str] = set() - seen_occurrence_ids: set[str] = set() - for finding in findings: - if ( - not isinstance(finding, dict) - or set(finding) != {"findingId", "occurrenceId"} - or not isinstance(finding.get("findingId"), str) - or not finding["findingId"].strip() - or not isinstance(finding.get("occurrenceId"), str) - or not finding["occurrenceId"].strip() - ): - raise SystemExit("Linear publication input contains an invalid finding identity.") - if ( - finding["findingId"] in seen_finding_ids - or finding["occurrenceId"] in seen_occurrence_ids - ): - raise SystemExit("Linear publication input repeats a finding or occurrence.") - seen_finding_ids.add(finding["findingId"]) - seen_occurrence_ids.add(finding["occurrenceId"]) - - return payload, destination, findings +def destination_key(destination: Any) -> str: + if not isinstance(destination, dict): + raise SystemExit("Finding issues require an exact tracker destination.") + kind = destination.get("type") + if kind == "linear": + required, optional = {"type", "teamId"}, {"projectId"} + parts = [kind, destination.get("teamId"), destination.get("projectId")] + elif kind == "jira": + required, optional = {"type", "cloudId", "projectId"}, set() + parts = [kind, destination.get("cloudId"), destination.get("projectId")] + elif kind in {"github-issue", "github-advisory"}: + required, optional = {"type", "hostname", "repository"}, set() + parts = [kind, destination.get("hostname"), destination.get("repository")] + else: + raise SystemExit("Unsupported finding issue destination.") + if not required.issubset(destination) or not set(destination).issubset(required | optional): + raise SystemExit("Finding issues require the exact provider destination fields.") + if any(not isinstance(value, str) or not value.strip() for value in destination.values()): + raise SystemExit("Finding issue destination fields must be nonempty strings.") + return json.dumps(parts, ensure_ascii=False, separators=(",", ":")) -def verify_linear_publication_scan( +def verify_publication_history( db: Any, connection: sqlite3.Connection, - payload: dict[str, Any], - findings: list[dict[str, str]], -) -> sqlite3.Row: + scan_id: str, + scan_directory: str, + findings: list[dict[str, Any]], +) -> None: try: - scan = db.require_scan(connection, payload["scanId"]) + scan = db.require_scan(connection, scan_id) except SystemExit as exc: raise SystemExit( "The completed scan is not present in the local Codex Security scan-history database. " "Use the state directory where the scan was completed." ) from exc - if scan["id"] != payload["scanId"]: - raise SystemExit("Linear publication must use the exact completed scan identifier.") + if scan["id"] != scan_id: + raise SystemExit("Publication must use the exact completed scan identifier.") if scan["status"] != "complete": raise SystemExit("Only completed scans can publish findings to Linear.") - - requested_directory = db.require_canonical_scan_directory(Path(payload["scanDirectory"])) + requested_directory = db.require_canonical_scan_directory(Path(scan_directory)) recorded_directory = db.require_canonical_scan_directory(Path(scan["scan_dir"])) if os.path.normcase(requested_directory) != os.path.normcase(recorded_directory): raise SystemExit( @@ -115,212 +78,218 @@ def verify_linear_publication_scan( ) if "seal_manifest_digest" in scan.keys(): db.require_recorded_manifest_digest(scan, recorded_directory) - stored_findings = { row["id"]: row["finding_id"] for row in connection.execute( - "SELECT id, finding_id FROM finding_occurrences WHERE scan_id = ?", - (scan["id"],), + "SELECT id, finding_id FROM finding_occurrences WHERE scan_id = ?", (scan_id,) ) } - for finding in findings: - if stored_findings.get(finding["occurrenceId"]) != finding["findingId"]: - raise SystemExit( - "A selected finding or occurrence does not belong to the completed scan " - "in local Codex Security scan history." - ) - if len(stored_findings) != len(findings): + expected = {finding["occurrenceId"]: finding["findingId"] for finding in findings} + if stored_findings != expected: raise SystemExit( "The completed scan findings do not exactly match local Codex Security scan history." ) - return scan -def _linear_publication_record(row: sqlite3.Row) -> dict[str, str]: +def issue_receipt(row: sqlite3.Row) -> dict[str, Any]: return { + "scanId": row["scan_id"], "findingId": row["finding_id"], "occurrenceId": row["occurrence_id"], "issueIdentifier": row["external_id"], + "operation": row["operation"], **({"url": row["external_url"]} if row["external_url"] is not None else {}), + **({"readback": json.loads(row["readback_json"])} if row["readback_json"] else {}), } -def inspect_linear_publication( - db: Any, - payload: dict[str, Any], -) -> dict[str, Any]: - payload, destination, findings = linear_publication_input(payload, recording=False) - database_uri = f"file:{quote(str(db.database_path()), safe='')}?mode=ro" - with closing(sqlite3.connect(database_uri, uri=True, timeout=5)) as connection: - connection.row_factory = sqlite3.Row - connection.execute("BEGIN") - scan = verify_linear_publication_scan(db, connection, payload, findings) - recorded: dict[str, dict[str, str]] = {} - if connection.execute( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'finding_publications'" - ).fetchone(): - for row in connection.execute( - """ - SELECT finding_id, occurrence_id, external_id, external_url - FROM finding_publications - WHERE scan_id = ? AND destination_type = ? AND team_id = ? AND project_id IS ? - ORDER BY created_at, external_id - """, - ( - scan["id"], - destination["type"], - destination["teamId"], - destination.get("projectId"), - ), - ): - recorded.setdefault(row["occurrence_id"], _linear_publication_record(row)) - return { - "scanId": scan["id"], - "destination": destination, - "findingCount": len(findings), - "recorded": [ - recorded[finding["occurrenceId"]] - for finding in findings - if finding["occurrenceId"] in recorded - ], - } - - -def prepare_linear_publication( - db: Any, +def inspect_issue_receipts( connection: sqlite3.Connection, - payload: dict[str, Any], -) -> dict[str, Any]: - payload, destination, findings = linear_publication_input(payload, recording=False) - connection.execute("BEGIN IMMEDIATE") - with connection: - scan = verify_linear_publication_scan(db, connection, payload, findings) - return { - "scanId": scan["id"], - "destination": destination, - "findingCount": len(findings), - } + destination: dict[str, str], + key: str, + finding_ids: list[str], +) -> list[dict[str, Any]]: + tables = { + row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'table'") + } + selection = json.dumps(finding_ids) + if "finding_issue_receipts" in tables: + rows = connection.execute( + "SELECT * FROM finding_issue_receipts WHERE destination_key = ? " + "AND finding_id IN (SELECT value FROM json_each(?)) ORDER BY id", + (key, selection), + ) + elif "finding_publications" in tables and destination["type"] == "linear": + rows = connection.execute( + "SELECT *, 'create' AS operation, NULL AS readback_json FROM finding_publications " + "WHERE destination_type = 'linear' AND team_id = ? AND project_id IS ? " + "AND finding_id IN (SELECT value FROM json_each(?)) ORDER BY id", + (destination["teamId"], destination.get("projectId"), selection), + ) + else: + return [] + return [issue_receipt(row) for row in rows] -def record_linear_publications( - db: Any, - connection: sqlite3.Connection, - payload: dict[str, Any], -) -> dict[str, Any]: - payload, destination, findings = linear_publication_input(payload, recording=True) - publications = payload["publications"] - if not isinstance(publications, list): - raise SystemExit("Linear publication results must be an array.") - planned = {finding["findingId"]: finding["occurrenceId"] for finding in findings} - current: dict[str, dict[str, str]] = {} - external_ids: set[str] = set() - for publication in publications: - if ( - not isinstance(publication, dict) - or not {"findingId", "occurrenceId", "issueIdentifier"}.issubset(publication) - or not set(publication).issubset( - {"findingId", "occurrenceId", "issueIdentifier", "url"} - ) - or not isinstance(publication.get("findingId"), str) - or not isinstance(publication.get("occurrenceId"), str) - or not isinstance(publication.get("issueIdentifier"), str) - or not publication["issueIdentifier"].strip() - or ( - "url" in publication - and (not isinstance(publication["url"], str) or not publication["url"].strip()) - ) +def validate_issue_receipts(receipts: Any, selected: dict[str, str]) -> list[dict[str, Any]]: + if not isinstance(receipts, list): + raise SystemExit("Finding issue receipts must be an array.") + for receipt in receipts: + if not isinstance(receipt, dict): + raise SystemExit("Each finding issue receipt must be an object.") + if any( + not isinstance(receipt.get(field), str) or not receipt[field].strip() + for field in ("findingId", "occurrenceId", "issueIdentifier") ): - raise SystemExit("Linear publication results contain an invalid issue association.") - finding_id = publication["findingId"] - issue_identifier = publication["issueIdentifier"] - if planned.get(finding_id) != publication["occurrenceId"]: + raise SystemExit("A finding issue receipt is missing its source or issue identity.") + if selected.get(receipt["findingId"]) != receipt["occurrenceId"]: raise SystemExit( - "A created Linear issue does not match its planned finding and occurrence." + "A finding issue receipt does not belong to the selected sealed findings." ) - if finding_id in current or issue_identifier in external_ids: - raise SystemExit("Linear publication results repeat a finding or issue identifier.") - current[finding_id] = publication - external_ids.add(issue_identifier) - - connection.execute("BEGIN IMMEDIATE") - with connection: - scan = verify_linear_publication_scan(db, connection, payload, findings) - timestamp = db.now() - for publication in publications: - conflicting = connection.execute( - """ - SELECT occurrence_id, external_url - FROM finding_publications - WHERE destination_type = ? AND team_id = ? AND project_id IS ? - AND external_id = ? - """, - ( - destination["type"], - destination["teamId"], - destination.get("projectId"), - publication["issueIdentifier"], - ), - ).fetchone() - if ( - conflicting is not None - and conflicting["occurrence_id"] != publication["occurrenceId"] - ): - raise SystemExit( - "This Linear issue is already associated with a different finding." - ) + if receipt.get("operation") not in {"create", "update", "reuse"}: + raise SystemExit("A finding issue receipt requires create, update, or reuse.") + if "url" in receipt and (not isinstance(receipt["url"], str) or not receipt["url"].strip()): + raise SystemExit("A finding issue URL must be a nonempty string when supplied.") + if "readback" in receipt: + readback = receipt["readback"] if ( - conflicting is not None - and "url" in publication - and conflicting["external_url"] != publication["url"] + not isinstance(readback, dict) + or readback.get("status") not in {"verified", "failed"} + or ("error" in readback and not isinstance(readback["error"], str)) ): - raise SystemExit("This Linear issue is already associated with a different URL.") + raise SystemExit("A finding issue readback requires verified or failed status.") + return receipts - connection.execute( - """ - INSERT INTO finding_publications ( - scan_id, finding_id, occurrence_id, destination_type, - team_id, project_id, external_id, external_url, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT DO NOTHING - """, - ( - scan["id"], - publication["findingId"], - publication["occurrenceId"], - destination["type"], - destination["teamId"], - destination.get("projectId"), - publication["issueIdentifier"], - publication.get("url"), - timestamp, - ), - ) - created = [] - for finding in findings: - publication = current.get(finding["findingId"]) - if publication is None: - continue - row = connection.execute( - """ - SELECT finding_id, occurrence_id, external_id, external_url - FROM finding_publications - WHERE scan_id = ? AND occurrence_id = ? AND destination_type = ? - AND team_id = ? AND project_id IS ? AND external_id = ? - """, - ( - scan["id"], - publication["occurrenceId"], - destination["type"], - destination["teamId"], - destination.get("projectId"), - publication["issueIdentifier"], - ), - ).fetchone() - if row is None: - raise SystemExit("A created Linear issue could not be read from scan history.") - created.append(_linear_publication_record(row)) - return {"scanId": scan["id"], "destination": destination, "created": created} +def finding_issues(db: Any, payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict) or payload.get("action") not in { + "inspect", + "prepare", + "record", + }: + raise SystemExit("Finding issues require inspect, prepare, or record action.") + if not isinstance(payload.get("scanDirectory"), str): + raise SystemExit("Finding issues require a sealed scan directory.") + require_history = payload.get("requireHistory", False) + if not isinstance(require_history, bool): + raise SystemExit("requireHistory must be a boolean.") + key = destination_key(payload.get("destination")) + try: + validated = validate_contract(Path(payload["scanDirectory"])) + except (OSError, ValueError, RecursionError) as exc: + raise SystemExit(f"Finding issue source validation failed: {exc}") from exc + scan_id = validated["manifest"]["scan"]["id"] + if "expectedScanId" in payload and payload["expectedScanId"] != scan_id: + raise SystemExit("Scan artifacts do not match the selected scan identifier.") + findings = validated["findings"]["findings"] + available = {finding["findingId"]: finding["occurrenceId"] for finding in findings} + finding_ids = payload.get("findingIds", list(available)) + if not isinstance(finding_ids, list) or any( + not isinstance(finding_id, str) or finding_id not in available for finding_id in finding_ids + ): + raise SystemExit("Selected finding IDs must belong to the sealed scan.") + selected = {finding_id: available[finding_id] for finding_id in finding_ids} + receipts = ( + validate_issue_receipts(payload.get("receipts"), selected) + if payload["action"] == "record" + else [] + ) + result = { + "scanId": scan_id, + "destination": payload["destination"], + "findingCount": len(selected), + "storeExists": True, + "receipts": [], + } + path = db.database_path() + if payload["action"] == "inspect" or require_history: + try: + path.stat() + except FileNotFoundError: + if require_history: + raise SystemExit( + "Cannot publish findings because the local Codex Security scan-history database " + "does not exist. Use the state directory where this scan was completed." + ) from None + result["storeExists"] = False + return result + except PermissionError: + pass # Let SQLite report the open failure used by the host's state fallback. + if payload["action"] == "inspect": + database_uri = f"file:{quote(str(path), safe='')}?mode=ro" + try: + connection = sqlite3.connect(database_uri, uri=True, timeout=5) + except sqlite3.OperationalError as exc: + if str(exc) == "unable to open database file": + exc._codex_security_state_unavailable = True + raise + connection.row_factory = sqlite3.Row + else: + connection = db.connect() + with closing(connection): + connection.execute("BEGIN" if payload["action"] == "inspect" else "BEGIN IMMEDIATE") + with connection: + if require_history: + verify_publication_history(db, connection, scan_id, validated["scanDir"], findings) + if payload["action"] == "inspect": + result["receipts"] = inspect_issue_receipts( + connection, payload["destination"], key, list(selected) + ) + elif payload["action"] == "record": + timestamp = db.now() + for receipt in receipts: + conflicting = connection.execute( + "SELECT 1 FROM finding_issue_receipts " + "WHERE destination_key = ? AND external_id = ? AND finding_id != ? LIMIT 1", + (key, receipt["issueIdentifier"], receipt["findingId"]), + ).fetchone() + if conflicting is not None: + raise SystemExit( + "The issue identifier is already associated with another finding " + "in this destination." + ) + readback = ( + json.dumps(receipt["readback"], ensure_ascii=False) + if "readback" in receipt + else None + ) + connection.execute( + """ + INSERT INTO finding_issue_receipts ( + scan_id, finding_id, occurrence_id, destination_key, + external_id, external_url, operation, readback_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (occurrence_id, destination_key, external_id, operation) + DO UPDATE SET + external_url = COALESCE(external_url, excluded.external_url), + readback_json = COALESCE(excluded.readback_json, readback_json), + updated_at = excluded.updated_at + """, + ( + scan_id, + receipt["findingId"], + receipt["occurrenceId"], + key, + receipt["issueIdentifier"], + receipt.get("url"), + receipt["operation"], + readback, + timestamp, + timestamp, + ), + ) + row = connection.execute( + "SELECT * FROM finding_issue_receipts WHERE occurrence_id = ? " + "AND destination_key = ? AND external_id = ? AND operation = ?", + ( + receipt["occurrenceId"], + key, + receipt["issueIdentifier"], + receipt["operation"], + ), + ).fetchone() + result["receipts"].append(issue_receipt(row)) + return result def export_findings( diff --git a/plugins/codex-security/shared/workbench-migrations.json b/plugins/codex-security/shared/workbench-migrations.json index e737315d42..11e3682864 100644 --- a/plugins/codex-security/shared/workbench-migrations.json +++ b/plugins/codex-security/shared/workbench-migrations.json @@ -393,5 +393,22 @@ "statements": [ "ALTER TABLE deep_scan_runs ADD COLUMN discovery_user_context_json TEXT;" ] + }, + { + "version": 48, + "name": "share finding issue receipts across trackers", + "statements": [ + "CREATE TABLE finding_issue_receipts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scan_id TEXT NOT NULL,\n finding_id TEXT NOT NULL,\n occurrence_id TEXT NOT NULL,\n destination_key TEXT NOT NULL,\n external_id TEXT NOT NULL,\n external_url TEXT,\n operation TEXT NOT NULL CHECK (operation IN ('create', 'update', 'reuse')),\n readback_json TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n UNIQUE (occurrence_id, destination_key, external_id, operation)\n);", + "INSERT INTO finding_issue_receipts (\n scan_id, finding_id, occurrence_id, destination_key, external_id, external_url,\n operation, readback_json, created_at, updated_at\n) SELECT scan_id, finding_id, occurrence_id, json_array(destination_type, team_id, project_id),\n external_id, external_url, 'create', NULL, created_at, created_at\nFROM finding_publications ORDER BY id;", + "DROP TABLE finding_publications;", + "CREATE INDEX finding_issue_receipts_by_finding\nON finding_issue_receipts(finding_id, destination_key, id);" + ] + }, + { + "version": 49, + "name": "index finding issue receipt ownership", + "statements": [ + "CREATE INDEX finding_issue_receipts_by_destination_issue\nON finding_issue_receipts(destination_key, external_id, finding_id);" + ] } ] diff --git a/plugins/codex-security/skills/track-findings/SKILL.md b/plugins/codex-security/skills/track-findings/SKILL.md index d51663a5fe..1cd134dca5 100644 --- a/plugins/codex-security/skills/track-findings/SKILL.md +++ b/plugins/codex-security/skills/track-findings/SKILL.md @@ -52,12 +52,14 @@ Report source status as `verified`, `unverified` (a candidate cannot be tied to Read the provider state needed to choose supported fields. Use exact ids for teams, projects, labels, milestones, and assignees when available. Do not invent metadata or infer it from names. +Call `get_codex_security_finding_issues` with `scanDirectory`, the selected `findingIds`, and the confirmed `destination`: Linear `{type: "linear", teamId, projectId?}`, Jira `{type: "jira", cloudId, projectId}`, or GitHub `{type: "github-issue" | "github-advisory", hostname, repository: "owner/repo"}`. Use canonical provider ids. Saved associations include earlier scan occurrences and CLI publications; read them through the selected provider and confirm the match before reuse. They do not replace remote duplicate searches. + Search the finding id and fingerprint before proposing a create. Search all statuses in the exact destination; GitHub issue searches exclude pull requests. Complete identifier searches and read plausible matches. Add narrow semantic searches when safe for the confirmed audience. Compare bindings when present and assess whether the source context, affected code, root cause, control, and sink match. Similar wording or missing bindings alone do not settle the comparison. Choose one outcome: - `create`: complete searches found no issue tracking the same finding. -- `reuse`: one issue tracks the finding, established by a current read or this run's successful create receipt. Reuse is read-only and does not require adding bindings or rewriting the issue. +- `reuse`: one issue tracks the finding, established by a current read or this run's successful create receipt. Reuse does not require adding bindings or rewriting the issue. - `update`: one verified matching issue should receive specific reviewed changes. - `blocked`: routing, access, disclosure, or duplicate ambiguity prevents a decision. @@ -73,7 +75,7 @@ Every create or update body includes labeled canonical finding id and primary fi Do not include credentials, signed URLs, local file URLs, or unreviewed links. A public GitHub issue requires approval of its complete public title and body, including a prominent visibility warning. Exclude internal evidence, attack paths, exploit detail, and private source links from public issues. -For batches, show every item in execution order and obtain approval for that list. A general request to track findings does not approve an unseen payload. Changes to source, identity, transport, destination, audience, duplicate decision, payload, or membership require a new preview and approval. Read-only reuse requires neither mutation approval nor write access. +For batches, show every item in execution order and obtain approval for that list. A general request to track findings does not approve an unseen payload. Changes to source, identity, transport, destination, audience, duplicate decision, payload, or membership require a new preview and approval. Reuse requires neither provider mutation approval nor write access. ## 5. Apply and verify @@ -81,9 +83,11 @@ After an approval pause or interruption, rerun source validation, reverify sourc Before creating, refresh duplicate results to catch an issue created while awaiting approval. After a pause, reread each selected reuse candidate and reconfirm the match; stop if it is unreadable or ambiguous, and return to preview if the duplicate decision changes. Before updating after a pause, reread the fields being changed and return to preview if they differ from the reviewed values. Without an intervening pause, a successful create receipt can supply the values for an already approved follow-up edit. Preserve unowned fields and existing rich content. -Process findings serially in the approved order. Immediately before each create, update, or reuse, run the source validator with that exact finding id and stop if it fails. Use the exact approved payload and record each provider receipt outside the sealed bundle. Stop the batch on a failed or uncertain mutation or unresolved duplicate. +Process findings serially in the approved order. Immediately before each create, update, or reuse, run the source validator with that exact finding id and stop if it fails. Use the exact approved payload. Stop the batch on a failed or uncertain mutation or unresolved duplicate. + +A successful provider response establishes an accepted write. Immediately call `record_codex_security_finding_issues` with the same source and destination and a receipt containing the canonical `findingId` and `occurrenceId`, returned `issueIdentifier`, optional returned `url`, and `operation: "create" | "update" | "reuse"`. Record verified reuse the same way, using the current scan occurrence. Preserve returned identities and stop the batch if local recording fails; a storage failure never authorizes another create. -A successful provider response establishes an accepted write. Read the returned object through the same transport when possible to check identity and changed fields, allowing provider formatting that preserves meaning and links. Report a failed follow-up read separately, retaining the returned identity. An unreadable issue does not authorize another create. +After recording an accepted write, read the returned object through the same transport when possible to check identity and changed fields, allowing provider formatting that preserves meaning and links. Record the same receipt again with `readback: {status: "verified"}` or `{status: "failed", error}`; omit readback when no read was attempted. A current read confirming reuse can be recorded as verified immediately. Keep accepted writes and read failures distinct. An unreadable issue does not authorize another create. For CLI issue writes, put the approved body in a mode-`0600` temporary file outside the repository and scan bundle, arrange cleanup on every exit, and pass it to one `gh issue create` or `gh issue edit`. Never print the file. Capture the issue identity and read it with `gh issue view --json`. Use the advisory reference for JSON file creation and required readback. diff --git a/plugins/codex-security/skills/track-findings/references/github-security-advisories.md b/plugins/codex-security/skills/track-findings/references/github-security-advisories.md index 49bb74f4d0..f5b117520d 100644 --- a/plugins/codex-security/skills/track-findings/references/github-security-advisories.md +++ b/plugins/codex-security/skills/track-findings/references/github-security-advisories.md @@ -43,4 +43,4 @@ Use the main skill's preview and approval flow, including package metadata, the Write the approved JSON to a mode-`0600` temporary file outside the repository and scan directory. Review it for sensitive content and send it once with `gh api --hostname github.com --input`; remove it on every exit. Reconcile uncertain creates by exact bindings before considering another mutation. -Read the returned `ghsa_id` through the allowed endpoint. Compare normalized structured readback with the approved payload and require `state: draft` before reporting success. +Record the accepted create with the returned `ghsa_id` using the main skill's shared issue history, then read it through the allowed endpoint. Compare normalized structured readback with the approved payload and require `state: draft` before reporting success. Record that readback separately from the accepted create. diff --git a/plugins/codex-security/tests/test_finding_issue_receipts.py b/plugins/codex-security/tests/test_finding_issue_receipts.py new file mode 100644 index 0000000000..e835cf884c --- /dev/null +++ b/plugins/codex-security/tests/test_finding_issue_receipts.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import json +import os +import shutil +import sqlite3 +import uuid +from contextlib import closing +from pathlib import Path + +import pytest +from workbench_test_support import load_script, run_workbench, write_completed_contract + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +VALIDATOR = load_script("validate_scan_contract") +SCHEMA = load_script("workbench_schema") +RECEIPT_MIGRATION = next( + version + for version, name, _ in SCHEMA.MIGRATIONS + if name == "share finding issue receipts across trackers" +) +LINEAR = {"type": "linear", "teamId": "team-example"} +JIRA = {"type": "jira", "cloudId": "site-example", "projectId": "10001"} + + +def source(tmp_path: Path) -> tuple[Path, dict]: + scan_dir = tmp_path / "scan" + shutil.copytree(PLUGIN_ROOT / "examples" / "completed-scan", scan_dir) + return scan_dir, VALIDATOR.validate_contract(scan_dir) + + +def receipt_for(validated: dict, **fields) -> dict: + finding = validated["findings"]["findings"][0] + return { + "findingId": finding["findingId"], + "occurrenceId": finding["occurrenceId"], + "issueIdentifier": "SEC-101", + "operation": "create", + **fields, + } + + +def issues(state: Path, scan_dir: Path, action: str, *, destination=None, **fields) -> dict: + return run_workbench( + state, + "finding-issues", + input_text=json.dumps( + { + "action": action, + "scanDirectory": str(scan_dir), + "destination": destination or LINEAR, + **fields, + } + ), + ) + + +def legacy_database(path: Path, validated: dict, destination: dict) -> None: + path.parent.mkdir() + with closing(sqlite3.connect(path)) as connection: + connection.row_factory = sqlite3.Row + SCHEMA.apply_migrations( + connection, + tuple(migration for migration in SCHEMA.MIGRATIONS if migration[0] < RECEIPT_MIGRATION), + lambda: "2026-01-01T00:00:00Z", + lambda _: None, + ) + receipt = receipt_for(validated) + connection.execute( + "INSERT INTO finding_publications (scan_id, finding_id, occurrence_id, destination_type, " + "team_id, project_id, external_id, external_url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + validated["manifest"]["scan"]["id"], + receipt["findingId"], + receipt["occurrenceId"], + "linear", + destination["teamId"], + destination.get("projectId"), + "SEC-101", + "https://linear.app/example/issue/SEC-101", + "2026-01-01T00:00:00Z", + ), + ) + connection.commit() + + +def test_inspection_of_portable_source_does_not_create_state(tmp_path: Path) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + result = issues(state, scan_dir, "inspect") + assert result["scanId"] == validated["manifest"]["scan"]["id"] + assert result["receipts"] == [] + assert result["storeExists"] is False + assert not state.exists() + + +@pytest.mark.skipif( + os.name == "nt" or getattr(os, "geteuid", lambda: 0)() == 0, + reason="requires Unix file permissions", +) +@pytest.mark.parametrize("action", ["inspect", "record"]) +def test_inaccessible_state_preserves_sqlite_open_error(tmp_path: Path, action: str) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + state.mkdir(mode=0o000) + try: + with pytest.raises(PermissionError): + (state / "workbench.sqlite3").stat() + result = run_workbench( + state, + "finding-issues", + input_text=json.dumps( + { + "action": action, + "scanDirectory": str(scan_dir), + "destination": LINEAR, + **({"receipts": [receipt_for(validated)]} if action == "record" else {}), + } + ), + check=False, + ) + assert result["returncode"] != 0 + assert "sqlite3.OperationalError: unable to open database file" in result["stderr"] + finally: + state.chmod(0o700) + + +@pytest.mark.parametrize("project", [None, "project-example"]) +def test_legacy_receipts_are_read_without_migration_and_preserved_on_upgrade( + tmp_path: Path, project: str | None +) -> None: + scan_dir, validated = source(tmp_path) + destination = {"type": "linear", "teamId": "équipe-example"} + if project is not None: + destination["projectId"] = project + state = tmp_path / "state" + path = state / "workbench.sqlite3" + legacy_database(path, validated, destination) + original = path.read_bytes() + inspected = issues(state, scan_dir, "inspect", destination=destination) + assert inspected["storeExists"] is True + assert inspected["receipts"] == [ + { + **receipt_for(validated), + "scanId": validated["manifest"]["scan"]["id"], + "url": "https://linear.app/example/issue/SEC-101", + } + ] + assert path.read_bytes() == original + prepared = issues(state, scan_dir, "prepare", destination=destination) + assert prepared["receipts"] == [] + assert prepared["storeExists"] is True + assert issues(state, scan_dir, "inspect", destination=destination) == inspected + with closing(sqlite3.connect(path)) as connection: + assert ( + connection.execute("SELECT MAX(version) FROM schema_migrations").fetchone()[0] + == SCHEMA.MIGRATIONS[-1][0] + ) + assert ( + connection.execute( + "SELECT 1 FROM sqlite_master WHERE name = 'finding_publications'" + ).fetchone() + is None + ) + key = connection.execute("SELECT destination_key FROM finding_issue_receipts").fetchone()[0] + assert key == json.dumps( + ["linear", "équipe-example", project], ensure_ascii=False, separators=(",", ":") + ) + + +def test_records_id_only_acceptance_and_failed_readback_without_losing_create( + tmp_path: Path, +) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + accepted = receipt_for(validated) + recorded = issues(state, scan_dir, "record", destination=JIRA, receipts=[accepted]) + assert recorded["storeExists"] is True + assert recorded["receipts"] == [{**accepted, "scanId": validated["manifest"]["scan"]["id"]}] + failed_read = {"status": "failed", "error": "Read denied; diagnostic sk-synthetic-value"} + issues( + state, + scan_dir, + "record", + destination=JIRA, + receipts=[ + { + **accepted, + "url": "https://issues.example.test/browse/SEC-101", + "readback": failed_read, + } + ], + ) + issues(state, scan_dir, "record", destination=JIRA, receipts=[accepted]) + issues( + state, + scan_dir, + "record", + destination=JIRA, + receipts=[ + { + **accepted, + "operation": "reuse", + "readback": {"status": "verified"}, + } + ], + ) + history = issues(state, scan_dir, "inspect", destination=JIRA)["receipts"] + assert len(history) == 2 + assert history[0]["operation"] == "create" + assert history[0]["url"] == "https://issues.example.test/browse/SEC-101" + assert history[0]["readback"] == failed_read + assert history[1]["operation"] == "reuse" + assert history[1]["readback"] == {"status": "verified"} + with closing(sqlite3.connect(state / "workbench.sqlite3")) as connection: + assert connection.execute("SELECT COUNT(*) FROM scans").fetchone()[0] == 0 + + +@pytest.mark.parametrize("operation", ["create", "update", "reuse"]) +def test_readback_replay_preserves_the_accepted_receipt_url(tmp_path: Path, operation: str) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + accepted = receipt_for( + validated, operation=operation, url="https://issues.example.test/browse/SEC-101" + ) + issues(state, scan_dir, "record", receipts=[accepted]) + readback = {"status": "verified"} + replayed = issues( + state, + scan_dir, + "record", + receipts=[ + {**accepted, "url": "https://issues.example.test/browse/SEC-999", "readback": readback} + ], + ) + expected = { + **accepted, + "scanId": validated["manifest"]["scan"]["id"], + "readback": readback, + } + assert replayed["receipts"] == [expected] + assert issues(state, scan_dir, "inspect")["receipts"] == [expected] + + +@pytest.mark.parametrize( + "destination", + [ + {"type": "linear", "teamId": "different-team"}, + {"type": "linear", "teamId": "team-example", "projectId": "project-example"}, + JIRA, + {"type": "github-issue", "hostname": "github.com", "repository": "example/repo"}, + {"type": "github-advisory", "hostname": "github.com", "repository": "example/repo"}, + ], +) +def test_destination_scope_is_part_of_receipt_identity(tmp_path: Path, destination: dict) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + accepted = receipt_for(validated) + issues(state, scan_dir, "record", receipts=[accepted]) + assert issues(state, scan_dir, "inspect", destination=destination)["receipts"] == [] + issues(state, scan_dir, "record", destination=destination, receipts=[accepted]) + assert len(issues(state, scan_dir, "inspect", destination=destination)["receipts"]) == 1 + assert len(issues(state, scan_dir, "inspect")["receipts"]) == 1 + + +def test_same_issue_can_track_a_later_occurrence(tmp_path: Path) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + sources = [] + for index in range(2): + scan_dir = tmp_path / f"scan-{index}" + scan_dir.mkdir() + write_completed_contract(scan_dir, str(uuid.uuid4()), target) + VALIDATOR.FINALIZER.finalize_scan(scan_dir) + sources.append((scan_dir, VALIDATOR.validate_contract(scan_dir))) + first_dir, first = sources[0] + second_dir, second = sources[1] + created = receipt_for(first) + reused = receipt_for(second, operation="reuse", readback={"status": "verified"}) + assert created["findingId"] == reused["findingId"] + assert created["occurrenceId"] != reused["occurrenceId"] + issues(state, first_dir, "record", receipts=[created]) + assert ( + issues(state, second_dir, "inspect")["receipts"][0]["occurrenceId"] + == created["occurrenceId"] + ) + issues(state, second_dir, "record", receipts=[reused]) + history = issues(state, second_dir, "inspect")["receipts"] + assert [entry["operation"] for entry in history] == ["create", "reuse"] + assert len({entry["scanId"] for entry in history}) == 2 + + +@pytest.mark.parametrize("operation", ["create", "update", "reuse"]) +def test_issue_cannot_move_to_a_different_finding(tmp_path: Path, operation: str) -> None: + target = tmp_path / "target" + target.mkdir() + state = tmp_path / "state" + sources = [] + for index in range(2): + scan_dir = tmp_path / f"scan-{index}" + scan_dir.mkdir() + write_completed_contract( + scan_dir, str(uuid.uuid4()), target, identity_anchor=f"synthetic-finding-{index}" + ) + VALIDATOR.FINALIZER.finalize_scan(scan_dir) + sources.append((scan_dir, VALIDATOR.validate_contract(scan_dir))) + first_dir, first = sources[0] + second_dir, second = sources[1] + existing = receipt_for(first) + conflicting = receipt_for(second, operation=operation) + assert existing["findingId"] != conflicting["findingId"] + issues(state, first_dir, "record", receipts=[existing]) + + result = run_workbench( + state, + "finding-issues", + input_text=json.dumps( + { + "action": "record", + "scanDirectory": str(second_dir), + "destination": LINEAR, + "receipts": [receipt_for(second, issueIdentifier="SEC-102"), conflicting], + } + ), + check=False, + ) + assert result["returncode"] != 0 + assert "already associated with another finding" in result["stderr"] + assert issues(state, second_dir, "inspect")["receipts"] == [] + assert issues(state, first_dir, "inspect")["receipts"] == [ + {**existing, "scanId": first["manifest"]["scan"]["id"]} + ] + # Issue identifiers are scoped to their destination. + recorded = issues(state, second_dir, "record", destination=JIRA, receipts=[conflicting]) + assert recorded["receipts"] == [{**conflicting, "scanId": second["manifest"]["scan"]["id"]}] + + +@pytest.mark.parametrize("mutation", ["finding", "occurrence", "scan", "seal"]) +def test_invalid_source_is_rejected_before_creating_state(tmp_path: Path, mutation: str) -> None: + scan_dir, validated = source(tmp_path) + state = tmp_path / "state" + receipt = receipt_for(validated) + payload = { + "action": "record", + "scanDirectory": str(scan_dir), + "destination": LINEAR, + "receipts": [receipt], + } + if mutation == "finding": + payload["findingIds"] = ["not-a-finding"] + elif mutation == "occurrence": + receipt["occurrenceId"] = "not-an-occurrence" + elif mutation == "scan": + payload["expectedScanId"] = "not-this-scan" + else: + (scan_dir / "findings.json").write_text("{}") + result = run_workbench(state, "finding-issues", input_text=json.dumps(payload), check=False) + assert result["returncode"] != 0 + assert not state.exists() diff --git a/plugins/codex-security/tests/test_workbench_db.py b/plugins/codex-security/tests/test_workbench_db.py index 6b32083c2f..47af928671 100644 --- a/plugins/codex-security/tests/test_workbench_db.py +++ b/plugins/codex-security/tests/test_workbench_db.py @@ -26,6 +26,7 @@ fail_scan, get_scan, initialize_git_repository, + load_script, mark_handoff_delivered, mark_remediation_delivered, request_remediation, @@ -77,7 +78,7 @@ "local_finding_embeddings", "finding_locations", "finding_occurrences", - "finding_publications", + "finding_issue_receipts", "finding_remediation_attempts", "finding_repositories", "finding_triage", @@ -910,7 +911,9 @@ def test_workbench_persists_progress_and_indexes_completed_findings(tmp_path: Pa ) } assert tables == EXPECTED_TABLES - assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == (47,) + assert connection.execute("SELECT COUNT(*) FROM schema_migrations").fetchone() == ( + len(load_script("workbench_schema").MIGRATIONS), + ) assert connection.execute("SELECT COUNT(*) FROM findings").fetchone() == (1,) assert connection.execute("SELECT COUNT(*) FROM finding_locations").fetchone() == (1,) diff --git a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py index dff9d01710..20e22612fc 100644 --- a/plugins/codex-security/tests/test_workbench_setup_and_migrations.py +++ b/plugins/codex-security/tests/test_workbench_setup_and_migrations.py @@ -89,6 +89,8 @@ (45, "separate local and service embedding caches"), (46, "invalidate local embeddings when finding bodies change"), (47, "snapshot deep scan discovery context"), + (48, "share finding issue receipts across trackers"), + (49, "index finding issue receipt ownership"), ] @@ -1224,7 +1226,7 @@ def test_workbench_reconciles_monorepo_migration_lineage() -> None: ] assert ( connection.execute( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'finding_publications'" + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'finding_issue_receipts'" ).fetchone() is not None ) @@ -1232,13 +1234,11 @@ def test_workbench_reconciles_monorepo_migration_lineage() -> None: row[0] for row in connection.execute( "SELECT name FROM sqlite_master WHERE type = 'index' " - "AND tbl_name = 'finding_publications'" + "AND tbl_name = 'finding_issue_receipts'" ) } >= { - "finding_publications_by_scan", - "finding_publications_by_finding", - "finding_publications_team_only_occurrence", - "finding_publications_team_only_external_issue", + "finding_issue_receipts_by_finding", + "finding_issue_receipts_by_destination_issue", } assert [ row["name"] @@ -1252,6 +1252,69 @@ def test_workbench_reconciles_monorepo_migration_lineage() -> None: ] == ["publication_error_message"] +def test_workbench_indexes_issue_ownership_without_changing_existing_receipts() -> None: + connection, apply_migrations = create_historical_database(49) + with closing(connection): + timestamp = "2026-01-01T00:00:00Z" + connection.executemany( + "INSERT INTO finding_issue_receipts (scan_id, finding_id, occurrence_id, " + "destination_key, external_id, external_url, operation, readback_json, " + "created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + ( + f"scan-{index}", + f"finding-{index}", + f"occurrence-{index}", + f"destination-{index % 2}", + f"ISSUE-{index}", + f"https://issues.example.test/browse/ISSUE-{index}", + "create", + '{"status":"verified"}', + timestamp, + timestamp, + ) + for index in range(256) + ], + ) + connection.commit() + receipts = connection.execute("SELECT * FROM finding_issue_receipts ORDER BY id").fetchall() + history = connection.execute("SELECT * FROM schema_migrations ORDER BY version").fetchall() + + apply_migrations(connection) + apply_migrations(connection) + + assert ( + connection.execute("SELECT * FROM finding_issue_receipts ORDER BY id").fetchall() + == receipts + ) + assert ( + connection.execute( + "SELECT * FROM schema_migrations WHERE version < 49 ORDER BY version" + ).fetchall() + == history + ) + query = ( + "SELECT 1 FROM finding_issue_receipts WHERE destination_key = ? " + "AND external_id = ? AND finding_id != ? LIMIT 1" + ) + parameters = ("destination-1", "ISSUE-129", "another-finding") + plan = connection.execute("EXPLAIN QUERY PLAN " + query, parameters).fetchall() + assert any( + row[3].startswith("SEARCH ") + and "COVERING INDEX finding_issue_receipts_by_destination_issue" in row[3] + for row in plan + ), [tuple(row) for row in plan] + assert connection.execute(query, parameters).fetchone()[0] == 1 + assert ( + connection.execute(query, ("destination-1", "ISSUE-129", "finding-129")).fetchone() + is None + ) + assert ( + connection.execute(query, ("destination-0", "ISSUE-129", "another-finding")).fetchone() + is None + ) + + def test_workbench_creates_single_final_schema(tmp_path: Path) -> None: state_dir = tmp_path / "state" run_workbench(state_dir, "database-info") diff --git a/sdk/typescript/docs/cli.md b/sdk/typescript/docs/cli.md index 8827c91478..28009a21cd 100644 --- a/sdk/typescript/docs/cli.md +++ b/sdk/typescript/docs/cli.md @@ -1023,10 +1023,16 @@ creation; connected-app access is reported as `not-checked`. Each finding becomes an issue with severity, source, evidence, and remediation. Choose a destination authorized to receive that content. Local history records -successful issue IDs separately from sealed artifacts. +successful issue IDs separately from sealed artifacts. The CLI and the plugin's +finding-tracking workflow share these records when using the same Codex Security +state directory. Accepted writes remain recorded even if a later issue read fails. +The shared-history migration preserves existing Linear receipts and replaces their +old table. Upgrade CLI and plugin installations together when they share state; +older publishers cannot write to the migrated database. Republishing creates duplicates unless `--skip-existing` finds a recorded success -for that occurrence, team, and project. It does not check remote issues or cover +for that occurrence, team, and project, including issues created or reused through +the plugin. It does not check remote issues or cover unrecorded/concurrent publication. After interruption, inspect the handoff, evidence, and destination before retrying. diff --git a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs index 50ee0b0c4a..25676eb2eb 100644 --- a/sdk/typescript/scripts/fixtures/package-deep-scan.mjs +++ b/sdk/typescript/scripts/fixtures/package-deep-scan.mjs @@ -68,6 +68,11 @@ try { restoreSpawn = installFixtureSpawn(executable); } await chmod(executable, 0o700); + if (process.platform === "win32") { + // The direct SDK engine launches its preflight from this process too. + process.env.PACKAGE_DEEP_EXECUTABLE = executable; + await import(pathToFileURL(join(root, "package-deep-spawn.mjs")).href); + } const checks = await Promise.allSettled([ runInstalledSdk(installedPlugin, executable).then(() => diff --git a/sdk/typescript/src/publication-store.ts b/sdk/typescript/src/publication-store.ts index b8eb4a983e..0f178d9aec 100644 --- a/sdk/typescript/src/publication-store.ts +++ b/sdk/typescript/src/publication-store.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { findingEntry } from "./value.js"; import { stat } from "node:fs/promises"; import { join } from "node:path"; import { isRecord } from "./record.js"; @@ -12,46 +11,30 @@ import { runWorkbench, } from "./runtime.js"; +type StoredIssue = PublishedScanIssue & { scanId: string }; + export async function inspectPublicationStore( publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, signal?: AbortSignal, ): Promise { - const result = await runPublicationWorkbench( - "inspect-linear-publication", + const receipts = await findingIssues( + "inspect", publication, environment, - undefined, + [], signal, ); - const recorded = publicationRecordsSchema.safeParse(result["recorded"]); - if ( - !matchesPublication(result, publication) || - result["findingCount"] !== - (publication.sourceFindings ?? publication.issues).length || - !recorded.success - ) { - throw invalidPublicationRecords(); - } - const expected = new Map( - (publication.sourceFindings ?? publication.issues).map((issue) => [ - issue.findingId, - issue.occurrenceId, - ]), - ); - const found = new Map(); - for (const issue of recorded.data) { - if ( - expected.get(issue.findingId) !== issue.occurrenceId || - found.has(issue.findingId) - ) { - throw invalidPublicationRecords(); - } - found.set(issue.findingId, issue); - } - return publication.issues.flatMap(({ findingId }) => { - const issue = found.get(findingId); - return issue === undefined ? [] : [issue]; + return publication.issues.flatMap(({ findingId, occurrenceId }) => { + // Tracking can reuse an issue across scans. CLI skipping remains specific to + // the selected occurrence, irrespective of which workflow recorded it. + const receipt = receipts.find( + (issue) => + issue.scanId === publication.scanId && + issue.findingId === findingId && + issue.occurrenceId === occurrenceId, + ); + return receipt === undefined ? [] : [publishedIssue(receipt)]; }); } @@ -59,20 +42,7 @@ export async function preparePublicationStore( publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, ): Promise { - const result = await runPublicationWorkbench( - "prepare-linear-publication", - publication, - environment, - ); - if ( - result["scanId"] !== publication.scanId || - result["findingCount"] !== - (publication.sourceFindings ?? publication.issues).length - ) { - throw new CodexSecurityError( - "The workbench could not verify every finding selected for publication.", - ); - } + await findingIssues("prepare", publication, environment); } export async function recordPublishedIssues( @@ -80,54 +50,45 @@ export async function recordPublishedIssues( issues: readonly PublishedScanIssue[], environment: NodeJS.ProcessEnv, ): Promise { - const result = await runPublicationWorkbench( - "record-linear-publications", + const receipts = await findingIssues( + "record", publication, environment, issues, ); - const created = publicationRecordsSchema.safeParse(result["created"]); - if ( - !matchesPublication(result, publication) || - !created.success || - created.data.length !== issues.length - ) { - throw invalidPublicationRecords(); - } - - const expected = new Map(issues.map(findingEntry)); - const ordered = publication.issues.flatMap((issue) => { - const record = expected.get(issue.findingId); - return record === undefined ? [] : [record]; - }); - if (expected.size !== issues.length || ordered.length !== issues.length) { + if (receipts.length !== issues.length) { throw invalidPublicationRecords(); } - - return created.data.map((issue, index) => { - const expectedIssue = ordered[index]!; + const recorded = new Map(); + for (const [index, receipt] of receipts.entries()) { + const expectedIssue = issues[index]!; if ( - issue.findingId !== expectedIssue.findingId || - issue.occurrenceId !== expectedIssue.occurrenceId || - issue.issueIdentifier !== expectedIssue.issueIdentifier || - (expectedIssue.url !== undefined && issue.url !== expectedIssue.url) + receipt.scanId !== publication.scanId || + receipt.findingId !== expectedIssue.findingId || + receipt.occurrenceId !== expectedIssue.occurrenceId || + receipt.issueIdentifier !== expectedIssue.issueIdentifier || + (expectedIssue.url !== undefined && receipt.url !== expectedIssue.url) || + recorded.has(receipt.findingId) ) { throw invalidPublicationRecords(); } - return issue; + recorded.set(receipt.findingId, receipt); + } + const ordered = publication.issues.flatMap(({ findingId }) => { + const receipt = recorded.get(findingId); + return receipt === undefined ? [] : [publishedIssue(receipt)]; }); + if (ordered.length !== issues.length) throw invalidPublicationRecords(); + return ordered; } -async function runPublicationWorkbench( - command: - | "inspect-linear-publication" - | "prepare-linear-publication" - | "record-linear-publications", +async function findingIssues( + action: "inspect" | "prepare" | "record", publication: PreparedScanPublication, environment: NodeJS.ProcessEnv, - issues?: readonly PublishedScanIssue[], + issues: readonly PublishedScanIssue[] = [], signal?: AbortSignal, -): Promise> { +): Promise { signal?.throwIfAborted(); const stateDirectory = codexSecurityStateDirectory(environment); const database = join(stateDirectory, "workbench.sqlite3"); @@ -146,35 +107,71 @@ async function runPublicationWorkbench( const [python, pluginRoot] = await resolveWorkbenchRuntime({ environment, protectedRoot: publication.scanDirectory, - ...(signal === undefined ? {} : { signal }), + signal, }); signal?.throwIfAborted(); - const findings = (publication.sourceFindings ?? publication.issues).map( - ({ findingId, occurrenceId }) => ({ - findingId, - occurrenceId, - }), - ); - return await runWorkbench( + const result = await runWorkbench( { python, pluginRoot, environment, - ...(signal === undefined ? {} : { signal }), + signal, failureMessage: - command === "record-linear-publications" + action === "record" ? "Could not persist created Linear issues in the local Codex Security scan history" : "Cannot publish findings without their existing local Codex Security scan history", }, - [command], + ["finding-issues"], JSON.stringify({ - scanId: publication.scanId, + action, scanDirectory: publication.scanDirectory, + expectedScanId: publication.scanId, destination: publication.destination, - findings, - ...(issues === undefined ? {} : { publications: issues }), + findingIds: publication.issues.map(({ findingId }) => findingId), + requireHistory: true, + ...(action === "record" + ? { + receipts: issues.map((issue) => ({ + ...issue, + operation: "create", + })), + } + : {}), }), ); + const receipts = publicationRecordsSchema.safeParse(result["receipts"]); + if ( + !matchesPublication(result, publication) || + result["findingCount"] !== publication.issues.length || + !receipts.success + ) { + throw invalidPublicationRecords(); + } + const selected = new Map( + publication.issues.map(({ findingId, occurrenceId }) => [ + findingId, + occurrenceId, + ]), + ); + for (const receipt of receipts.data) { + if ( + !selected.has(receipt.findingId) || + (receipt.scanId === publication.scanId && + selected.get(receipt.findingId) !== receipt.occurrenceId) + ) { + throw invalidPublicationRecords(); + } + } + return receipts.data; +} + +function publishedIssue(issue: StoredIssue): PublishedScanIssue { + return { + findingId: issue.findingId, + occurrenceId: issue.occurrenceId, + issueIdentifier: issue.issueIdentifier, + ...(issue.url === undefined ? {} : { url: issue.url }), + }; } function matchesPublication( @@ -193,6 +190,7 @@ function matchesPublication( const publicationRecordsSchema = z .object({ + scanId: z.string(), findingId: z.string(), occurrenceId: z.string(), issueIdentifier: z.string().refine((value) => Boolean(value.trim())), diff --git a/sdk/typescript/src/publication.ts b/sdk/typescript/src/publication.ts index 59cefab4f3..56a6b46262 100644 --- a/sdk/typescript/src/publication.ts +++ b/sdk/typescript/src/publication.ts @@ -57,11 +57,6 @@ export interface PreparedScanPublication { scanDirectory: string; destination: LinearPublicationDestination; issues: PreparedPublicationIssue[]; - /** Complete sealed membership, retained when only some findings become issues. */ - sourceFindings?: Pick< - PreparedPublicationIssue, - "findingId" | "occurrenceId" - >[]; } export function linearPublicationArguments( @@ -142,13 +137,6 @@ export async function prepareScanPublication( return { scanId, scanDirectory: canonicalScanDirectory, - ...(classification === undefined && options.findingIds === undefined - ? {} - : { - sourceFindings: contract.findings.findings.map( - ({ findingId, occurrenceId }) => ({ findingId, occurrenceId }), - ), - }), destination: { type: options.destination, teamId: options.teamId, diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index dd7867934e..8cbd5cc11a 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -1604,22 +1604,6 @@ def before_archive(): workbench["main"](before_archive=before_archive) `; -// Internal publication callers use only the SDK's bundled workbench. -const PUBLICATION_PROGRAM = String.raw` -import json, runpy, sys -from contextlib import closing -workbench = runpy.run_path(sys.argv[1]) -context = workbench["_WORKBENCH_PUBLICATION_CONTEXT"] -payload = json.load(sys.stdin, parse_constant=workbench["reject_non_finite_json"]) -handler = getattr(workbench["publication"], sys.argv[2].replace("-", "_")) -if sys.argv[2] == "inspect-linear-publication": - result = handler(context, payload) -else: - with closing(workbench["connect"]()) as connection: - result = handler(context, connection, payload) -print(json.dumps(result, allow_nan=False, sort_keys=True)) -`; - export async function runWorkbench( options: Omit & { python?: string }, args: readonly string[], @@ -1668,22 +1652,10 @@ export async function runWorkbench( ? (options.stateDirectory ?? codexSecurityStateDirectory(options.environment)) : undefined; - const publicationInput = - input !== undefined && - arguments_.length === 1 && - [ - "inspect-linear-publication", - "prepare-linear-publication", - "record-linear-publications", - ].includes(arguments_[0]!); // OS argv cannot carry NUL, but workbench text fields can. const framedArguments = !native && arguments_.some((argument) => argument.includes("\0")); - let program = archiveHandshake - ? ARCHIVE_REGISTRATION_PROGRAM - : publicationInput - ? PUBLICATION_PROGRAM - : undefined; + let program = archiveHandshake ? ARCHIVE_REGISTRATION_PROGRAM : undefined; if (framedArguments) program = WORKBENCH_ARGUMENTS_PROGRAM + (program ?? WORKBENCH_SCRIPT_PROGRAM); diff --git a/sdk/typescript/tests-ts/publication-integration.test.ts b/sdk/typescript/tests-ts/publication-integration.test.ts index 84cb636204..e6082c63ef 100644 --- a/sdk/typescript/tests-ts/publication-integration.test.ts +++ b/sdk/typescript/tests-ts/publication-integration.test.ts @@ -269,7 +269,7 @@ function storedPublications(fixture: PublicationFixture): StoredPublication[] { "import json, sqlite3, sys", "connection = sqlite3.connect(sys.argv[1])", "connection.row_factory = sqlite3.Row", - "rows = connection.execute('SELECT scan_id, finding_id, occurrence_id, destination_type, team_id, project_id, external_id, external_url FROM finding_publications ORDER BY id').fetchall()", + `rows = connection.execute("SELECT scan_id, finding_id, occurrence_id, json_extract(destination_key, '$[0]') AS destination_type, json_extract(destination_key, '$[1]') AS team_id, json_extract(destination_key, '$[2]') AS project_id, external_id, external_url FROM finding_issue_receipts ORDER BY id").fetchall()`, "print(json.dumps([dict(row) for row in rows]))", ].join("\n"); return JSON.parse( @@ -291,6 +291,119 @@ function receiptPath(fixture: PublicationFixture): string { } describe("database-backed Linear publication integration", () => { + test("skips a Linear issue recorded through the shared tracking workbench", async () => { + const completed = await fixture(1); + const finding = completed.findings[0]!; + const sealed = await artifactDigests(completed.scanDirectory); + const issue = { + findingId: finding.findingId, + occurrenceId: finding.occurrenceId, + issueIdentifier: "EXAMPLE-711", + url: "https://linear.app/example/issue/EXAMPLE-711", + }; + await runWorkbench( + { + python: completed.python, + pluginRoot: PLUGIN_ROOT, + environment: completed.environment, + }, + ["finding-issues"], + JSON.stringify({ + action: "record", + scanDirectory: completed.scanDirectory, + destination: { + type: "linear", + teamId: OPTIONS.teamId, + projectId: OPTIONS.projectId, + }, + receipts: [ + { + ...issue, + operation: "create", + readback: { + status: "failed", + error: "Synthetic readback unavailable", + }, + }, + ], + }), + ); + const result = await publishScanInternal( + completed.scanDirectory, + { + ...OPTIONS, + skipExisting: true, + }, + { + environment: completed.environment, + resolveCodex: throwing( + "A recorded tracker issue must not be created again.", + ), + }, + ); + expect(result.skipped).toEqual([issue]); + expect(result.created).toEqual([]); + expect(result.counts).toEqual({ + findings: 1, + created: 0, + failed: 0, + skipped: 1, + }); + expect(await artifactDigests(completed.scanDirectory)).toEqual(sealed); + }); + + test("exposes a CLI-created issue to the shared tracking workbench", async () => { + const completed = await fixture(1); + type LinearClient = ReturnType< + NonNullable + >; + const result = await publishScanInternal( + completed.scanDirectory, + { + ...OPTIONS, + linearApiKey: "lin_api_SYNTHETIC_INTEROPERABILITY", + }, + { + environment: completed.environment, + linearClient: () => + ({ + createIssue: async () => ({ + success: true, + issue: Promise.resolve({ + identifier: "EXAMPLE-712", + url: "https://linear.app/example/issue/EXAMPLE-712", + }), + }), + }) as unknown as LinearClient, + }, + ); + expect(result.created).toHaveLength(1); + const inspected = await runWorkbench( + { + python: completed.python, + pluginRoot: PLUGIN_ROOT, + environment: completed.environment, + }, + ["finding-issues"], + JSON.stringify({ + action: "inspect", + scanDirectory: completed.scanDirectory, + destination: { + type: "linear", + teamId: OPTIONS.teamId, + projectId: OPTIONS.projectId, + }, + }), + ); + expect(inspected["receipts"]).toEqual([ + expect.objectContaining({ + ...result.created[0], + scanId: SCAN_ID, + operation: "create", + }), + ]); + }); + test("publishes classified selections while verifying the complete scan history and preserving earlier tickets", async () => { const { classifyScanDirectorySeverity } = await import("../src/classify-scan-severity.js"); diff --git a/sdk/typescript/tests-ts/publication-store.test.ts b/sdk/typescript/tests-ts/publication-store.test.ts index 570404a807..53a6687467 100644 --- a/sdk/typescript/tests-ts/publication-store.test.ts +++ b/sdk/typescript/tests-ts/publication-store.test.ts @@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { chmod, + cp, mkdir, readFile, readdir, @@ -21,7 +22,12 @@ import { prepareScanPublication, type PreparedScanPublication, } from "../src/publication.js"; -import type { FindingsDocument, ScanManifest } from "../src/models.js"; +import type { + CoverageDocument, + FindingsDocument, + ScanManifest, +} from "../src/models.js"; +import { setFindingIdentity, sha256 } from "./support/finding-identity.js"; import type { PublishedScanIssue } from "../src/publish.js"; import { runWorkbench } from "../src/runtime.js"; import * as runtime from "../src/runtime.js"; @@ -62,22 +68,11 @@ async function publicationFixture( CODEX_SECURITY_STATE_DIR: stateDirectory, PYTHON: python, }; - const publication: PreparedScanPublication = { - scanId: SCAN_ID, + const publication = await sealedPublication( scanDirectory, - destination: { - type: "linear", - teamId: "team-example", - projectId: "project-example", - }, - issues: Array.from({ length: options.count ?? 2 }, (_, index) => ({ - findingId: `finding-${index + 1}`, - occurrenceId: `occurrence-${index + 1}`, - title: `[Codex Security][HIGH] Example finding ${index + 1}`, - description: `Example finding ${index + 1}`, - priority: 2, - })), - }; + environment, + options.count ?? 2, + ); const fixture = { environment, publication, python, stateDirectory }; if (options.createDatabase !== false) { await mkdir(stateDirectory, { mode: 0o700 }); @@ -89,6 +84,52 @@ async function publicationFixture( return fixture; } +async function sealedPublication( + scanDirectory: string, + environment: NodeJS.ProcessEnv, + count: number, + scanId = SCAN_ID, +): Promise { + await copyCompletedScanFixture(scanDirectory); + const manifestPath = join(scanDirectory, "scan-manifest.json"); + const findingsPath = join(scanDirectory, "findings.json"); + const coveragePath = join(scanDirectory, "coverage.json"); + const manifest = JSON.parse( + await readFile(manifestPath, "utf8"), + ) as ScanManifest; + const findings = JSON.parse( + await readFile(findingsPath, "utf8"), + ) as FindingsDocument; + const coverage = JSON.parse( + await readFile(coveragePath, "utf8"), + ) as CoverageDocument; + manifest.scan.id = scanId; + findings.scanId = scanId; + coverage.scanId = scanId; + const example = findings.findings[0]!; + findings.findings = Array.from({ length: count }, (_, index) => { + const finding = structuredClone(example); + finding.identity.anchor = `${example.identity.anchor}-${index + 1}`; + finding.title = `Synthetic finding ${index + 1}`; + setFindingIdentity(manifest.scan, finding); + return finding; + }); + await writeFile(findingsPath, `${JSON.stringify(findings, null, 2)}\n`); + await writeFile(coveragePath, `${JSON.stringify(coverage, null, 2)}\n`); + for (const artifact of manifest.scan.artifacts) { + artifact.sha256 = sha256( + await readFile(join(scanDirectory, artifact.path)), + ); + } + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + return prepareScanPublication(scanDirectory, { + destination: "linear", + teamId: "team-example", + projectId: "project-example", + environment, + }); +} + function seedPublicationScan( fixture: PublicationFixture, publication: PreparedScanPublication, @@ -173,37 +214,6 @@ describe("read-only publication history", () => { }), ).resolves.toEqual([]); } - - const check = runNodePython(fixture.python, [ - "-c", - [ - "import sys", - "from copy import copy", - "from pathlib import PureWindowsPath", - "from urllib.parse import unquote, urlsplit", - "sys.path.insert(0, sys.argv[1])", - "import workbench_db as workbench", - "import workbench_publication as publication", - "class Captured(Exception): pass", - "def capture(filename, **options):", - " parsed = urlsplit(filename)", - " assert parsed.scheme == 'file' and parsed.netloc == ''", - " assert unquote(parsed.path) == str(path)", - " assert parsed.query == 'mode=ro' and options == {'uri': True, 'timeout': 5}", - " raise Captured", - "publication.sqlite3.connect = capture", - "publication.linear_publication_input = lambda *_args, **_options: ({}, {}, [])", - "for value in ['C:/state/history.sqlite3', '//server/share/state/history.sqlite3', '//?/C:/state/history.sqlite3', '//?/UNC/server/share/history.sqlite3']:", - " path = PureWindowsPath(value)", - " context = copy(workbench._WORKBENCH_PUBLICATION_CONTEXT)", - " context.database_path = lambda path=path: path", - " try: publication.inspect_linear_publication(context, None)", - " except Captured: pass", - " else: raise AssertionError('SQLite connection was not attempted')", - ].join("\n"), - join(PLUGIN_ROOT, "scripts"), - ]); - expect(check.status, check.stderr).toBe(0); }); test("matches the manifest recorded when the scan completed", async () => { @@ -278,39 +288,7 @@ describe("read-only publication history", () => { } }); - test.each([ - ["-", "{}", /unexpected or missing fields/u], - ["broken.json", "{", /broken.json: invalid JSON/u], - ["array.json", "[]", /array.json: expected a JSON object/u], - ["nonfinite.json", '{"value": NaN}', /nonfinite.json: invalid JSON/u], - ] as const)( - "preserves public publication file input: %s", - async (filename, contents, diagnostic) => { - const fixture = await publicationFixture({ createDatabase: false }); - const directory = await temporaryDirectory(); - await writeFile(join(directory, filename), contents); - const result = runNodePython( - fixture.python, - [ - join(PLUGIN_ROOT, "scripts", "workbench_db.py"), - "inspect-linear-publication", - "--input-file", - filename, - ], - { - cwd: directory, - env: fixture.environment, - input: "THIS IS NOT JSON AND MUST NOT BE READ", - }, - ); - expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toMatch(diagnostic); - expect(existsSync(fixture.stateDirectory)).toBe(false); - }, - ); - - test("forwards cancellation to Python discovery and the workbench", async () => { + test("forwards inspection cancellation through Python discovery and the shared workbench", async () => { const fixture = await publicationFixture(); const controller = new AbortController(); const reason = new Error("Synthetic inspection cancellation."); @@ -325,9 +303,12 @@ describe("read-only publication history", () => { async (options, args, input) => { inspecting.resolve(); expect(options.signal).toBe(controller.signal); - expect(args).toEqual(["inspect-linear-publication"]); + expect(args).toEqual(["finding-issues"]); expect(JSON.parse(input!)).toMatchObject({ - scanId: fixture.publication.scanId, + action: "inspect", + scanDirectory: fixture.publication.scanDirectory, + destination: fixture.publication.destination, + requireHistory: true, }); await once(options.signal!, "abort"); throw options.signal!.reason; @@ -364,6 +345,64 @@ describe("read-only publication history", () => { expect(existsSync(fixture.stateDirectory)).toBe(false); }); + test("rejects inspection responses for another destination or occurrence", async () => { + const fixture = await publicationFixture({ count: 1 }); + const receipt = { + ...publishedIssue(fixture.publication, 0), + scanId: SCAN_ID, + }; + const response = { + scanId: SCAN_ID, + destination: { ...fixture.publication.destination }, + findingCount: 1, + receipts: [receipt], + }; + const workbench = spyOn(runtime, "runWorkbench"); + try { + for (const invalidResponse of [ + { + ...response, + destination: { ...response.destination, teamId: "another-team" }, + }, + { + ...response, + receipts: [ + { ...receipt, occurrenceId: "occ_000000000000000000000000" }, + ], + }, + ]) { + workbench.mockResolvedValue(invalidResponse); + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).rejects.toThrow(/invalid persisted Linear publication records/u); + } + } finally { + workbench.mockRestore(); + } + }); + + test("rejects a persisted receipt that differs from the accepted issue", async () => { + const fixture = await publicationFixture({ count: 1 }); + const issue = publishedIssue(fixture.publication, 0, "EXAMPLE-101"); + const workbench = spyOn(runtime, "runWorkbench").mockResolvedValue({ + scanId: SCAN_ID, + destination: { ...fixture.publication.destination }, + findingCount: 1, + receipts: [{ ...issue, scanId: SCAN_ID, issueIdentifier: "EXAMPLE-102" }], + }); + try { + await expect( + recordPublishedIssues( + fixture.publication, + [issue], + fixture.environment, + ), + ).rejects.toThrow(/invalid persisted Linear publication records/u); + } finally { + workbench.mockRestore(); + } + }); + test("does not create a missing database or migrate old history", async () => { const missing = await publicationFixture({ createDatabase: false }); await expect( @@ -372,12 +411,15 @@ describe("read-only publication history", () => { expect(existsSync(missing.stateDirectory)).toBe(false); const fixture = await publicationFixture(); - databaseRows(fixture, "DROP TABLE finding_publications"); - databaseRows( + const version = databaseRows( fixture, - "DELETE FROM schema_migrations WHERE version >= ?", - [29], - ); + "SELECT version FROM schema_migrations WHERE name = ?", + ["share finding issue receipts across trackers"], + )[0]!["version"]; + databaseRows(fixture, "DROP TABLE finding_issue_receipts"); + databaseRows(fixture, "DELETE FROM schema_migrations WHERE version >= ?", [ + version, + ]); const database = join(fixture.stateDirectory, "workbench.sqlite3"); const before = await readFile(database); const mode = (await stat(database)).mode; @@ -395,13 +437,13 @@ describe("read-only publication history", () => { databaseRows( fixture, "SELECT version FROM schema_migrations WHERE version >= ?", - [29], + [version], ), ).toEqual([]); expect( databaseRows( fixture, - "SELECT name FROM sqlite_master WHERE name = 'finding_publications'", + "SELECT name FROM sqlite_master WHERE name = 'finding_issue_receipts'", ), ).toEqual([]); }); @@ -484,25 +526,50 @@ describe("read-only publication history", () => { const scanDirectory = join(fixture.stateDirectory, "another-scan"); await mkdir(scanDirectory, { mode: 0o700 }); - const otherScan: PreparedScanPublication = { - ...fixture.publication, - scanId: OTHER_SCAN_ID, + const otherScan = await sealedPublication( scanDirectory, - issues: fixture.publication.issues.map((issue) => ({ - ...issue, - occurrenceId: `other-${issue.occurrenceId}`, - })), - }; + fixture.environment, + 2, + OTHER_SCAN_ID, + ); seedPublicationScan(fixture, otherScan); await expect( inspectPublicationStore(otherScan, fixture.environment), ).resolves.toEqual([]); + const candidates = await runWorkbench( + { + python: fixture.python, + pluginRoot: PLUGIN_ROOT, + environment: fixture.environment, + }, + ["finding-issues"], + JSON.stringify({ + action: "inspect", + scanDirectory, + destination: otherScan.destination, + findingIds: [otherScan.issues[0]!.findingId], + }), + ); + expect(candidates["receipts"]).toEqual([ + expect.objectContaining({ + ...first, + scanId: SCAN_ID, + operation: "create", + }), + expect.objectContaining({ + issueIdentifier: "EXAMPLE-201", + scanId: SCAN_ID, + }), + ]); await expect( inspectPublicationStore( - { ...fixture.publication, issues: [fixture.publication.issues[0]!] }, + { + ...fixture.publication, + issues: [fixture.publication.issues[0]!], + }, fixture.environment, ), - ).rejects.toThrow(/exactly match/u); + ).resolves.toEqual([first]); }); test("includes committed associations that are still in the WAL", async () => { @@ -521,7 +588,7 @@ describe("read-only publication history", () => { "import os, sqlite3, sys", "connection = sqlite3.connect(sys.argv[1])", "connection.execute('PRAGMA wal_autocheckpoint = 0')", - "connection.execute('UPDATE finding_publications SET external_id = ?, external_url = ?', (sys.argv[2], sys.argv[3]))", + "connection.execute('UPDATE finding_issue_receipts SET external_id = ?, external_url = ?', (sys.argv[2], sys.argv[3]))", "connection.commit()", "os._exit(0)", ].join("\n"), @@ -538,6 +605,127 @@ describe("read-only publication history", () => { }); describe("persisted finding publication associations", () => { + test("persists only the selected sealed findings while retaining complete scan history", async () => { + const fixture = await publicationFixture(); + const first = publishedIssue(fixture.publication, 0); + const excluded = publishedIssue(fixture.publication, 1); + const selected = await prepareScanPublication( + fixture.publication.scanDirectory, + { + destination: "linear", + teamId: fixture.publication.destination.teamId, + projectId: fixture.publication.destination.projectId, + environment: fixture.environment, + findingIds: [first.findingId], + }, + ); + + await expect( + inspectPublicationStore(selected, fixture.environment), + ).resolves.toEqual([]); + await preparePublicationStore(selected, fixture.environment); + await expect( + recordPublishedIssues(selected, [first, excluded], fixture.environment), + ).rejects.toThrow(/selected sealed findings/u); + expect( + databaseRows( + fixture, + "SELECT COUNT(*) AS count FROM finding_issue_receipts", + ), + ).toEqual([{ count: 0 }]); + await expect( + recordPublishedIssues(selected, [first], fixture.environment), + ).resolves.toEqual([first]); + await expect( + inspectPublicationStore(selected, fixture.environment), + ).resolves.toEqual([first]); + + databaseRows(fixture, "DELETE FROM finding_occurrences WHERE id = ?", [ + excluded.occurrenceId, + ]); + for (const action of [ + () => inspectPublicationStore(selected, fixture.environment), + () => preparePublicationStore(selected, fixture.environment), + () => recordPublishedIssues(selected, [first], fixture.environment), + ]) { + await expect(action()).rejects.toThrow(/do not exactly match local/u); + } + expect( + databaseRows( + fixture, + "SELECT COUNT(*) AS count FROM finding_issue_receipts", + ), + ).toEqual([{ count: 1 }]); + }); + + test("reads legacy Linear receipts without migration and preserves them when preparing shared history", async () => { + const fixture = await publicationFixture({ + count: 1, + createDatabase: false, + }); + await mkdir(fixture.stateDirectory, { mode: 0o700 }); + const database = join(fixture.stateDirectory, "workbench.sqlite3"); + const setup = runNodePython(fixture.python, [ + "-c", + [ + "import sqlite3, sys", + "sys.path.insert(0, sys.argv[1])", + "from workbench_schema import MIGRATIONS, apply_migrations", + "connection = sqlite3.connect(sys.argv[2])", + "connection.row_factory = sqlite3.Row", + "receipt_version = next(version for version, name, _ in MIGRATIONS if name == 'share finding issue receipts across trackers')", + "apply_migrations(connection, tuple(item for item in MIGRATIONS if item[0] < receipt_version), lambda: '2026-08-01T00:00:00Z', lambda _: None)", + "connection.close()", + ].join("\n"), + join(PLUGIN_ROOT, "scripts"), + database, + ]); + expect(setup.status, setup.stderr).toBe(0); + seedPublicationScan(fixture, fixture.publication); + const original = publishedIssue(fixture.publication, 0, "EXAMPLE-450"); + databaseRows( + fixture, + "INSERT INTO finding_publications (scan_id, finding_id, occurrence_id, destination_type, team_id, project_id, external_id, external_url, created_at) VALUES (?, ?, ?, 'linear', ?, ?, ?, ?, '2026-08-01T00:00:00Z')", + [ + SCAN_ID, + original.findingId, + original.occurrenceId, + fixture.publication.destination.teamId, + fixture.publication.destination.projectId, + original.issueIdentifier, + original.url, + ], + ); + const before = await readFile(database); + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([original]); + expect(await readFile(database)).toEqual(before); + + await preparePublicationStore(fixture.publication, fixture.environment); + await expect( + inspectPublicationStore(fixture.publication, fixture.environment), + ).resolves.toEqual([original]); + expect( + databaseRows( + fixture, + "SELECT operation, external_id, external_url FROM finding_issue_receipts", + ), + ).toEqual([ + { + operation: "create", + external_id: original.issueIdentifier, + external_url: original.url, + }, + ]); + expect( + databaseRows( + fixture, + "SELECT name FROM sqlite_master WHERE name = 'finding_publications'", + ), + ).toEqual([]); + }); + test("rolls back failed migrations without losing populated scan history", async () => { const fixture = await publicationFixture({ count: 1 }); await recordPublishedIssues( @@ -585,7 +773,7 @@ db.apply_migrations(connection) assert list(connection.iterdump()) == before, "Reapplying migrations changed existing history" assert list(connection.execute("PRAGMA foreign_key_check")) == [] assert connection.execute("PRAGMA integrity_check").fetchone()[0] == "ok" -print(json.dumps({table: connection.execute("SELECT COUNT(*) FROM " + table).fetchone()[0] for table in ("scans", "finding_occurrences", "finding_triage", "finding_publications")})) +print(json.dumps({table: connection.execute("SELECT COUNT(*) FROM " + table).fetchone()[0] for table in ("scans", "finding_occurrences", "finding_triage", "finding_issue_receipts")})) connection.close() `, join(PLUGIN_ROOT, "scripts"), @@ -598,44 +786,10 @@ connection.close() scans: 1, finding_occurrences: 1, finding_triage: 1, - finding_publications: 1, + finding_issue_receipts: 1, }); }); - test("upgrades existing scan history and verifies every completed finding before publication", async () => { - const fixture = await publicationFixture(); - databaseRows(fixture, "DROP TABLE finding_publications"); - databaseRows( - fixture, - "DELETE FROM schema_migrations WHERE version IN (?, ?)", - [29, 30], - ); - - await expect( - preparePublicationStore(fixture.publication, fixture.environment), - ).resolves.toBeUndefined(); - - expect( - databaseRows( - fixture, - "SELECT version, name FROM schema_migrations WHERE version BETWEEN ? AND ? ORDER BY version", - [29, 30], - ), - ).toEqual([ - { version: 29, name: "persist finding publication associations" }, - { - version: 30, - name: "preserve team-only finding publication associations", - }, - ]); - expect( - databaseRows( - fixture, - "SELECT COUNT(*) AS count FROM finding_publications", - ), - ).toEqual([{ count: 0 }]); - }); - test("upgrades scan history from before stopped-result preservation", async () => { const fixture = await publicationFixture(); databaseRows( @@ -680,58 +834,6 @@ connection.close() ).toEqual([{ name: "publication_error_message" }]); }); - test("upgrades existing project-scoped associations without changing recorded issues", async () => { - const fixture = await publicationFixture({ count: 1 }); - const original = publishedIssue(fixture.publication, 0, "EXAMPLE-401"); - await recordPublishedIssues( - fixture.publication, - [original], - fixture.environment, - ); - - databaseRows( - fixture, - "DROP INDEX finding_publications_team_only_occurrence", - ); - databaseRows( - fixture, - "DROP INDEX finding_publications_team_only_external_issue", - ); - databaseRows( - fixture, - "DELETE FROM schema_migrations WHERE version = ?", - [30], - ); - - await expect( - preparePublicationStore(fixture.publication, fixture.environment), - ).resolves.toBeUndefined(); - - expect( - databaseRows( - fixture, - "SELECT version, name FROM schema_migrations WHERE version = ?", - [30], - ), - ).toEqual([ - { - version: 30, - name: "preserve team-only finding publication associations", - }, - ]); - expect( - databaseRows( - fixture, - "SELECT project_id, external_id FROM finding_publications", - ), - ).toEqual([ - { - project_id: "project-example", - external_id: original.issueIdentifier, - }, - ]); - }); - test("rejects missing and non-regular publication history without initializing it", async () => { const fixture = await publicationFixture({ createDatabase: false }); @@ -789,7 +891,7 @@ connection.close() expect( databaseRows( fixture, - "SELECT COUNT(*) AS count FROM finding_publications", + "SELECT COUNT(*) AS count FROM finding_issue_receipts", ), ).toEqual([{ count: 0 }]); }); @@ -809,7 +911,10 @@ connection.close() test("rejects a selected directory that differs from its recorded scan", async () => { const fixture = await publicationFixture(); const anotherDirectory = join(fixture.stateDirectory, "another-scan"); - await mkdir(anotherDirectory, { mode: 0o700 }); + await cp(fixture.publication.scanDirectory, anotherDirectory, { + recursive: true, + }); + if (process.platform !== "win32") await chmod(anotherDirectory, 0o700); await expect( preparePublicationStore( @@ -819,56 +924,44 @@ connection.close() ).rejects.toThrow(/directory does not match/u); }); - test("rejects missing, mismatched, or omitted scan findings before publication", async () => { - const fixture = await publicationFixture(); - - for (const issues of [ - [ - { - ...fixture.publication.issues[0]!, - occurrenceId: "occurrence-not-in-scan", - }, - fixture.publication.issues[1]!, - ], - [ - { ...fixture.publication.issues[0]!, findingId: "finding-not-in-scan" }, - fixture.publication.issues[1]!, - ], - [fixture.publication.issues[0]!], - ]) { + test.each(["missing", "mismatched"])( + "rejects %s history occurrences for a sealed scan", + async (kind) => { + const fixture = await publicationFixture(); + const occurrenceId = fixture.publication.issues[0]!.occurrenceId; + if (kind === "missing") { + databaseRows(fixture, "DELETE FROM finding_occurrences WHERE id = ?", [ + occurrenceId, + ]); + } else { + databaseRows( + fixture, + "UPDATE finding_occurrences SET id = ? WHERE id = ?", + ["occ_000000000000000000000000", occurrenceId], + ); + } await expect( - preparePublicationStore( - { ...fixture.publication, issues }, - fixture.environment, - ), + preparePublicationStore(fixture.publication, fixture.environment), ).rejects.toThrow(/finding|occurrence/u); - } - }); - - test("rejects a real finding occurrence that belongs to another scan", async () => { - const fixture = await publicationFixture({ count: 1 }); - const anotherDirectory = join(fixture.stateDirectory, "another-scan"); - await mkdir(anotherDirectory, { mode: 0o700 }); - const otherScan: PreparedScanPublication = { - ...fixture.publication, - scanId: OTHER_SCAN_ID, - scanDirectory: anotherDirectory, - issues: [ - { - ...fixture.publication.issues[0]!, - findingId: "finding-other-scan", - occurrenceId: "occurrence-other-scan", - }, - ], - }; - seedPublicationScan(fixture, otherScan); + }, + ); + test("rejects a selection absent from the sealed scan", async () => { + const fixture = await publicationFixture(); await expect( preparePublicationStore( - { ...fixture.publication, issues: otherScan.issues }, + { + ...fixture.publication, + issues: [ + { + ...fixture.publication.issues[0]!, + findingId: "csf_000000000000000000000000", + }, + ], + }, fixture.environment, ), - ).rejects.toThrow(/does not belong to the completed scan/u); + ).rejects.toThrow(/finding/u); }); test("returns only database-backed current results in original finding order", async () => { @@ -886,7 +979,8 @@ connection.close() expect( databaseRows( fixture, - "SELECT scan_id, finding_id, occurrence_id, destination_type, team_id, project_id, external_id, external_url FROM finding_publications ORDER BY finding_id", + "SELECT scan_id, finding_id, occurrence_id, json_extract(destination_key, '$[0]') AS destination_type, json_extract(destination_key, '$[1]') AS team_id, json_extract(destination_key, '$[2]') AS project_id, external_id, external_url FROM finding_issue_receipts ORDER BY CASE WHEN finding_id = ? THEN 0 ELSE 1 END", + [first.findingId], ), ).toEqual([ { @@ -921,11 +1015,11 @@ connection.close() recordPublishedIssues(fixture.publication, [issue], fixture.environment), ).resolves.toEqual([issue]); expect( - databaseRows(fixture, "SELECT external_url FROM finding_publications"), + databaseRows(fixture, "SELECT external_url FROM finding_issue_receipts"), ).toEqual([{ external_url: null }]); }); - test("persists team-only issues with a null project and rejects conflicting associations", async () => { + test("persists and replays team-only issues without inventing a project", async () => { const fixture = await publicationFixture(); const publication: PreparedScanPublication = { ...fixture.publication, @@ -950,32 +1044,17 @@ connection.close() expect( databaseRows( fixture, - "SELECT project_id, external_id FROM finding_publications", + "SELECT json_extract(destination_key, '$[2]') AS project_id, external_id FROM finding_issue_receipts", ), ).toEqual([{ project_id: null, external_id: first.issueIdentifier }]); - await expect( - recordPublishedIssues( - publication, - [{ ...second, issueIdentifier: first.issueIdentifier }], - fixture.environment, - ), - ).rejects.toThrow(/already associated with a different finding/u); - await expect( - recordPublishedIssues( - publication, - [{ ...first, url: "https://linear.app/example/issue/EXAMPLE-OTHER" }], - fixture.environment, - ), - ).rejects.toThrow(/already associated with a different URL/u); - await expect( recordPublishedIssues(publication, [first, second], fixture.environment), ).resolves.toEqual([first, second]); expect( databaseRows( fixture, - "SELECT project_id, external_id FROM finding_publications ORDER BY id", + "SELECT json_extract(destination_key, '$[2]') AS project_id, external_id FROM finding_issue_receipts ORDER BY id", ), ).toEqual([ { project_id: null, external_id: first.issueIdentifier }, @@ -1014,13 +1093,13 @@ connection.close() expect( databaseRows( fixture, - "SELECT COUNT(*) AS count FROM finding_publications", + "SELECT COUNT(*) AS count FROM finding_issue_receipts", ), ).toEqual([{ count: 3 }]); expect( databaseRows( fixture, - "SELECT external_id FROM finding_publications WHERE finding_id = ? ORDER BY id", + "SELECT external_id FROM finding_issue_receipts WHERE finding_id = ? ORDER BY id", [original.findingId], ), ).toEqual([ @@ -1029,15 +1108,13 @@ connection.close() ]); }); - test("rejects swapped occurrences, duplicate mappings, and malformed issue IDs", async () => { + test("rejects mismatched occurrence identities and missing issue identifiers", async () => { const fixture = await publicationFixture(); const first = publishedIssue(fixture.publication, 0); const second = publishedIssue(fixture.publication, 1); for (const records of [ [{ ...first, occurrenceId: second.occurrenceId }], - [first, { ...first, issueIdentifier: "EXAMPLE-999" }], - [first, { ...second, issueIdentifier: first.issueIdentifier }], [{ ...first, issueIdentifier: " " }], ]) { await expect( @@ -1051,41 +1128,31 @@ connection.close() expect( databaseRows( fixture, - "SELECT COUNT(*) AS count FROM finding_publications", + "SELECT COUNT(*) AS count FROM finding_issue_receipts", ), ).toEqual([{ count: 0 }]); }); - test("rolls back the entire import when an issue belongs to another finding", async () => { + test("rolls back a receipt batch containing an occurrence outside the sealed scan", async () => { const fixture = await publicationFixture(); - const existing = publishedIssue(fixture.publication, 0, "EXAMPLE-301"); - await recordPublishedIssues( - fixture.publication, - [existing], - fixture.environment, - ); - await expect( recordPublishedIssues( fixture.publication, [ publishedIssue(fixture.publication, 0, "EXAMPLE-302"), - publishedIssue(fixture.publication, 1, existing.issueIdentifier), + { + ...publishedIssue(fixture.publication, 1, "EXAMPLE-303"), + occurrenceId: "occ_000000000000000000000000", + }, ], fixture.environment, ), - ).rejects.toThrow(/already associated with a different finding/u); - + ).rejects.toThrow(/finding|occurrence/u); expect( databaseRows( fixture, - "SELECT finding_id, external_id FROM finding_publications ORDER BY id", + "SELECT COUNT(*) AS count FROM finding_issue_receipts", ), - ).toEqual([ - { - finding_id: existing.findingId, - external_id: existing.issueIdentifier, - }, - ]); + ).toEqual([{ count: 0 }]); }); }); diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index d723426aa0..60724d020b 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -2383,7 +2383,7 @@ for await (const line of createInterface({ input: process.stdin })) { ), "utf8", ), - ).toContain('"inspect-linear-publication"'); + ).toContain('"finding-issues"'); }); test("rejects plugin installs without the selected path and version", async () => { @@ -5308,7 +5308,7 @@ describe("runtime directories and plugin Python boundary", () => { "--message=\0high\ud800.low\udfff.pair😀", "path-\udcff", ], - ["inspect-linear-publication", "--input-file", "-"], + ["finding-issues"], ].map((args) => ({ args })), )( "runs custom workbench $args without output limits, credentials, or generated bytecode",