Skip to content
Open
24 changes: 24 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,26 @@ a project. Destination flags override `CODEX_SECURITY_LINEAR_TEAM` and
`CODEX_SECURITY_LINEAR_PROJECT`. `--dry-run` previews issue titles without
contacting Linear; `--json` returns structured results.

Repeat `--finding FINDING_ID` to select findings; omitting it publishes all
findings. Review `--dry-run --json`, then pass its `payloadDigest` as
`--expect-digest` to require the same pending issue payload:

```bash
npx @openai/codex-security publish scan /path/to/completed-scan \
--to linear --linear-team TEAM_ID --finding csf_example --dry-run --json

npx @openai/codex-security publish scan /path/to/completed-scan \
--to linear --linear-team TEAM_ID --finding csf_example \
--expect-digest DIGEST_FROM_PREVIEW
```

The digest binds the scan, destination, pending issue content and requested
assignee. A mismatch stops before publication writes. Assigned approvals require
the same assignee and API credential; unassigned digests are credential-independent.
The digest is not a permission check or remote readback. Keep previews private:
they contain finding descriptions and source snippets. Descriptions omit the
wall-clock upload timestamp so unchanged inputs produce stable previews.

Sign in to Codex and connect Linear to publish with your existing Codex
configuration; publication doesn't use the isolated scan home. To use the
Linear API directly, set a personal API key:
Expand Down Expand Up @@ -747,6 +767,10 @@ Options include `projectId`, `skipExisting`, `linearApiKey` for direct API
publication, and `assigneeId` (user ID or email). `checkScanPublication` accepts
the same destination options for a read-only check.

Use `findingIds: ["csf_example"]` to select findings. With `dryRun: true`, the
result includes selected `issues` and `payloadDigest`; pass that digest as
`expectedDigest` when publishing.

### Scan history and reruns

Commands default to the current repository. Select scans by full ID or a
Expand Down
19 changes: 19 additions & 0 deletions sdk/typescript/scripts/fixtures/package-consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {
CodexSecurity,
DiffTarget,
estimateScanCost,
publishScan,
type PublishScanOptions,
type PublishScanResult,
planComponents,
runComponentScans,
type ComponentScanOptions,
Expand Down Expand Up @@ -35,6 +38,22 @@ export const cost: ScanCost | null = estimateScanCost("gpt-5.6-sol", {
output_tokens: 2,
});

const publicationOptions: PublishScanOptions = {
destination: "linear",
teamId: "team-example",
findingIds: ["finding-example"],
expectedDigest: "0".repeat(64),
dryRun: true,
};

export async function previewPublication(
scanDirectory: string,
): Promise<PublishScanResult> {
const result = await publishScan(scanDirectory, publicationOptions);
result.payloadDigest satisfies string;
return result;
}

interface ImportedFinding {
id: string;
title: string;
Expand Down
38 changes: 38 additions & 0 deletions sdk/typescript/scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,8 @@ try {
assert.equal(publication.counts.findings, 1);
assert.equal(publication.counts.created, 0);
assert.match(publication.issues[0].title, /^\[Codex Security\]\[HIGH\] /u);
assert.match(publication.payloadDigest, /^[a-f0-9]{64}$/u);
assert.doesNotMatch(publication.issues[0].description, /\*\*Uploaded:\*\*/u);
assert.match(
run(process.execPath, [launcher, "publish", "scan", "--help"], {
cwd: consumer,
Expand Down Expand Up @@ -558,6 +560,42 @@ try {
networkGuard,
'globalThis.fetch = async () => { throw new Error("Publication dry runs must not make network requests."); };\n',
);
assert.deepEqual(
JSON.parse(
run(
process.execPath,
[
"--require",
networkGuard,
launcher,
"publish",
"scan",
publicationScan,
"--to",
"linear",
"--linear-team",
"team-example",
"--finding",
publication.issues[0].findingId,
"--expect-digest",
publication.payloadDigest,
"--dry-run",
"--json",
],
{
cwd: consumer,
capture: true,
env: {
...process.env,
CODEX_SECURITY_LINEAR_PROJECT: "",
CODEX_SECURITY_LINEAR_API_KEY: "",
CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"),
},
},
),
),
publication,
);
const directPublicationText = run(
process.execPath,
[
Expand Down
19 changes: 19 additions & 0 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ const VALUE_OPTIONS = new Set([
"--linear-api-key",
"--project",
"--linear-assignee",
"--finding",
"--expect-digest",
]);
const PROVIDER_OPTION = z
.enum(["openai", "openrouter", "fireworks", "amazon-bedrock"])
Expand Down Expand Up @@ -2083,6 +2085,15 @@ export async function main(
.describe(
"External completed scan directory; repeat for multiple scans (Linear accepts one).",
),
finding: z
.array(optionValue("--finding"))
.optional()
.describe(
"Finding ID to publish; repeat to select several. Defaults to all findings.",
),
"expect-digest": optionValue("--expect-digest")
.optional()
.describe("Require the payload digest from a reviewed dry run."),
// Cloud remains an internal destination, omitted from public discovery.
to: z
.string()
Expand Down Expand Up @@ -2212,6 +2223,8 @@ export async function main(
if (
options.to === "cloud" &&
(options.skipExisting ||
options.finding !== undefined ||
options["expect-digest"] !== undefined ||
[
options.linearTeam,
options.linearApiKey,
Expand Down Expand Up @@ -2542,6 +2555,12 @@ export async function main(
? {}
: { expectedScanId: selectedScans[0].scanId }),
dryRun: options.dryRun,
...(options.finding === undefined
? {}
: { findingIds: options.finding }),
...(options["expect-digest"] === undefined
? {}
: { expectedDigest: options["expect-digest"] }),
signal: controller.signal,
...(options.skipExisting ? { skipExisting: true } : {}),
...(options.dryRun
Expand Down
5 changes: 1 addition & 4 deletions sdk/typescript/src/publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ export async function prepareScanPublication(
...(options.signal === undefined ? {} : { signal: options.signal }),
expectedScanId: options.expectedScanId,
});
const uploadedAt = options.uploadedAt ?? new Date().toISOString();
const scanId = contract.manifest.scan.id;

return {
Expand All @@ -98,7 +97,7 @@ export async function prepareScanPublication(
findingId: finding.findingId,
occurrenceId: finding.occurrenceId,
title: `[Codex Security][${finding.severity.level.toUpperCase()}] ${finding.title}`,
description: renderFindingDescription(contract, finding, uploadedAt),
description: renderFindingDescription(contract, finding),
...(priority === undefined ? {} : { priority }),
};
}),
Expand All @@ -108,7 +107,6 @@ export async function prepareScanPublication(
function renderFindingDescription(
contract: LoadedContract,
finding: Finding,
uploadedAt: string,
): string {
const { coverage } = contract;
const { scan } = contract.manifest;
Expand Down Expand Up @@ -141,7 +139,6 @@ function renderFindingDescription(
`**Scan mode:** ${scanMode(coverage.mode)}`,
`**Started:** ${scan.startedAt}`,
`**Completed:** ${scan.completedAt}`,
`**Uploaded:** ${uploadedAt}`,
"",
"### Affected locations",
"",
Expand Down
117 changes: 100 additions & 17 deletions sdk/typescript/src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
spawnSync,
type ChildProcessWithoutNullStreams,
} from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { createHash, createHmac, randomUUID } from "node:crypto";
import {
appendFile,
mkdir,
Expand Down Expand Up @@ -69,6 +69,8 @@ export interface PublishScanOptions {
projectId?: string;
linearApiKey?: string;
assigneeId?: string;
findingIds?: readonly string[];
expectedDigest?: string;
dryRun?: boolean;
skipExisting?: boolean;
signal?: AbortSignal;
Expand Down Expand Up @@ -123,6 +125,7 @@ export interface PublishScanResult {
issues?: PreparedPublicationIssue[];
indeterminate?: boolean;
warnings?: string[];
payloadDigest: string;
}

export type CheckScanPublicationOptions = Pick<
Expand Down Expand Up @@ -268,39 +271,59 @@ export async function publishScanInternal(
options.signal?.throwIfAborted();
const environment = dependencies.environment ?? process.env;
const linearApiKey = publicationApiKey(options, environment);
const approvedAssignee =
options.assigneeId === undefined
? undefined
: { id: options.assigneeId, key: linearApiKey! };

const preparedScan = await (dependencies.prepare ?? prepareScanPublication)(
scanDirectory,
options,
);
let prepared = preparedScan;
options.signal?.throwIfAborted();
let prepared = selectPublicationFindings(preparedScan, options.findingIds);
const findingCount = prepared.issues.length;
let skipped: PublishedScanIssue[] | undefined;
if (options.skipExisting) {
const selected = new Set(prepared.issues.map((issue) => issue.findingId));
skipped = (
await (dependencies.inspectPublicationStore ?? inspectPublicationStore)(
preparedScan,
environment,
options.signal,
)
).filter((issue) => selected.has(issue.findingId));
const recorded = new Set(skipped.map((issue) => issue.findingId));
prepared = {
...prepared,
issues: prepared.issues.filter((issue) => !recorded.has(issue.findingId)),
};
options.signal?.throwIfAborted();
}
const payloadDigest = publicationPayloadDigest(prepared, approvedAssignee);
if (
options.expectedDigest !== undefined &&
options.expectedDigest !== payloadDigest
) {
throw new ConfigurationError(
"The prepared Linear publication does not match the expected digest. Review a new dry run before publishing.",
);
}
const result: PublishScanResult = {
scanId: prepared.scanId,
uploadId: prepared.scanId,
destination: prepared.destination,
payloadDigest,
created: [],
failed: [],
...(skipped === undefined ? {} : { skipped }),
counts: {
findings: prepared.issues.length,
findings: findingCount,
created: 0,
failed: 0,
...(skipped === undefined ? {} : { skipped: skipped.length }),
},
};
if (options.skipExisting) {
result.skipped = await (
dependencies.inspectPublicationStore ?? inspectPublicationStore
)(preparedScan, environment, options.signal);
result.counts.skipped = result.skipped.length;
const recorded = new Set(result.skipped.map((issue) => issue.findingId));
prepared = {
...preparedScan,
issues: preparedScan.issues.filter(
(issue) => !recorded.has(issue.findingId),
),
};
options.signal?.throwIfAborted();
}
const saveReceipt = dependencies.writeReceipt ?? writePublicationReceipt;
if (options.dryRun) {
return { ...result, dryRun: true, issues: prepared.issues };
Expand Down Expand Up @@ -568,6 +591,66 @@ export async function publishScanInternal(
return result;
}

function selectPublicationFindings(
publication: PreparedScanPublication,
findingIds: readonly string[] | undefined,
): PreparedScanPublication {
if (findingIds === undefined) return publication;
if (
!Array.isArray(findingIds) ||
findingIds.some((id) => typeof id !== "string" || !id.trim())
) {
throw new ConfigurationError(
"Publication finding IDs must be nonempty strings.",
);
}
const selected = new Set(findingIds);
const known = new Set(publication.issues.map((issue) => issue.findingId));
for (const findingId of selected) {
if (!known.has(findingId)) {
throw new ConfigurationError(
`Unknown publication finding ID: ${JSON.stringify(findingId)}.`,
);
}
}
return {
...publication,
issues: publication.issues.filter((issue) => selected.has(issue.findingId)),
};
}

function publicationPayloadDigest(
publication: PreparedScanPublication,
assignee: { id: string; key: string } | undefined,
): string {
const { destination } = publication;
const digest =
assignee === undefined
? createHash("sha256")
: createHmac("sha256", assignee.key);
return digest
.update(
JSON.stringify({
version: assignee === undefined ? 1 : 2,
scanId: publication.scanId,
destination: {
type: destination.type,
teamId: destination.teamId,
projectId: destination.projectId ?? null,
},
assigneeId: assignee?.id ?? null,
issues: publication.issues.map((issue) => ({
findingId: issue.findingId,
occurrenceId: issue.occurrenceId,
title: issue.title,
description: issue.description,
priority: issue.priority ?? null,
})),
}),
)
.digest("hex");
}

export async function checkScanPublication(
scanDirectory: string,
options: CheckScanPublicationOptions,
Expand Down
Loading
Loading