Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,20 @@ added to successful publication results, scan history, or sealed scan artifacts.
Error messages are preserved as returned. `--dry-run` never contacts Linear in
either mode.

Use `publish check` to verify that the completed scan and its findings match
local history, and to see which findings already have recorded Linear issues:

```bash
npx @openai/codex-security publish check /path/to/completed-scan \
--to linear --linear-team TEAM_ID --json
```

The check does not create issues, migrate scan history, or change sealed scan
artifacts. With a Linear API key, it also makes read-only authentication, team,
optional project, and assignee checks. Without a key, connected-app access is
reported as `not-checked`. Issue-creation permission is always `not-tested`;
successful read access does not prove write permission.

Each finding creates a separate new issue titled
`[Codex Security][HIGH] Finding title`. The issue includes the scan ID,
repository, scanned scope, source locations and code snippets, severity,
Expand All @@ -618,9 +632,18 @@ Verified immutable Git revisions include source links. Findings are published
concurrently in batches of up to 20. Successful issue identifiers are linked
to their findings in the local scan-history database, and structured results
are read back from that database rather than generated by Codex. The completed
scan must already exist in the local scan history. Running publication again
creates another set of issues for the same scan; existing issues are not
matched, updated, or reused.
scan must already exist in the local scan history. By default, running
publication again creates another set of issues for the same scan. Add
`--skip-existing` to skip findings with a recorded issue for the exact scan
occurrence, team, and optional project. Combine it with `--dry-run` to preview
only the remaining findings. Results distinguish newly `created` issues from
previously recorded `skipped` issues.

This option uses local publication history; it does not search, update, or
verify the continued existence of remote issues. Recover any retained handoff
from an interrupted or uncertain publication before retrying. Concurrent
publishers and remote creations that were never recorded can still create
duplicates.

Issue descriptions contain source code and vulnerability details. Select a
Linear destination authorized to receive that information. Publication receipts
Expand Down Expand Up @@ -648,7 +671,9 @@ console.log(publication.created.length);
```

Add `projectId: "PROJECT_ID"` to the options to publish into a specific Linear
project instead of directly to the team.
project instead of directly to the team. Pass `skipExisting: true` to skip
recorded successes, or import `checkScanPublication` and call it with the same
destination options for a read-only preflight.

Pass `linearApiKey` to publish directly through the Linear API. Omit
`assigneeId` to leave issues unassigned, or supply a Linear user ID or email
Expand Down
6 changes: 5 additions & 1 deletion sdk/typescript/_bundled_plugin/scripts/workbench_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,11 @@ def parse_args(description: str) -> argparse.Namespace:
export_findings.add_argument("--scan-id", required=True)
export_findings.add_argument("--format", choices=EXPORT_FORMATS, required=True)

for command in ("prepare-linear-publication", "record-linear-publications"):
for command in (
"inspect-linear-publication",
"prepare-linear-publication",
"record-linear-publications",
):
publication = subparsers.add_parser(command)
publication.add_argument("--input-file", required=True)

Expand Down
51 changes: 51 additions & 0 deletions sdk/typescript/_bundled_plugin/scripts/workbench_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2507,6 +2507,53 @@ def verify_linear_publication_scan(
return scan


def inspect_linear_publication(args: argparse.Namespace) -> dict[str, Any]:
payload, destination, findings = linear_publication_input(args, recording=False)
with closing(
sqlite3.connect(f"{database_path().as_uri()}?mode=ro", uri=True, timeout=5)
) as connection:
connection.row_factory = sqlite3.Row
connection.execute("BEGIN")
scan = verify_linear_publication_scan(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"],
{
"findingId": row["finding_id"],
"occurrenceId": row["occurrence_id"],
"issueIdentifier": row["external_id"],
**({"url": row["external_url"]} if row["external_url"] is not None else {}),
},
)
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(
connection: sqlite3.Connection, args: argparse.Namespace
) -> dict[str, Any]:
Expand Down Expand Up @@ -3844,6 +3891,10 @@ def main() -> None:
result = inspect_setup(args)
print(json.dumps(result, allow_nan=False, sort_keys=True))
return
if args.command == "inspect-linear-publication":
result = inspect_linear_publication(args)
print(json.dumps(result, allow_nan=False, sort_keys=True))
return
with closing(connect()) as connection:
if args.command == "create-workspace":
result = create_workspace(connection, args)
Expand Down
39 changes: 38 additions & 1 deletion sdk/typescript/scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ try {
[
"--input-type=module",
"--eval",
`const sdk = await import(${JSON.stringify(packageManifest.name)}); if (typeof sdk.CodexSecurity !== "function") throw new Error("The installed package does not export CodexSecurity."); if (typeof sdk.publishScan !== "function") throw new Error("The installed package does not export publishScan.");`,
`const sdk = await import(${JSON.stringify(packageManifest.name)}); for (const name of ["CodexSecurity", "publishScan", "checkScanPublication"]) if (typeof sdk[name] !== "function") throw new Error("The installed package does not export " + name + ".");`,
],
{ cwd: consumer },
);
Expand Down Expand Up @@ -445,6 +445,43 @@ try {
assert.equal(publication.counts.findings, 1);
assert.equal(publication.counts.created, 0);
assert.match(publication.issues[0].title, /^\[Codex Security\]\[HIGH\] /u);
assert.match(
run(process.execPath, [launcher, "publish", "scan", "--help"], {
cwd: consumer,
capture: true,
}),
/--skip-existing/u,
);
const missingHistory = spawnSync(
process.execPath,
[
launcher,
"publish",
"check",
publicationScan,
"--to",
"linear",
"--linear-team",
"team-example",
"--json",
],
{
cwd: consumer,
encoding: "utf8",
env: {
...process.env,
CODEX_SECURITY_LINEAR_API_KEY: "",
CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"),
},
timeout: PACKAGE_SMOKE_TIMEOUT_MS,
windowsHide: true,
},
);
assert.equal(missingHistory.status, 2, missingHistory.stderr);
assert.match(missingHistory.stderr, /scan-history database does not exist/u);
await assert.rejects(stat(join(consumer, "publication-state")), {
code: "ENOENT",
});

const networkGuard = join(consumer, "reject-publication-network.cjs");
await writeFile(
Expand Down
Loading
Loading