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
24 changes: 24 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,26 @@ a project. Destination flags override `CODEX_SECURITY_LINEAR_TEAM` and
`CODEX_SECURITY_LINEAR_PROJECT`. `--dry-run` previews issue titles without
contacting Linear; `--json` returns structured results.

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

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

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

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

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

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

### Classify finding severity

Classify findings after a scan or dedupe without repeating discovery or changing
Expand Down
20 changes: 19 additions & 1 deletion sdk/typescript/scripts/fixtures/package-consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
classifyScanDirectorySeverity,
deduplicateScan,
estimateScanCost,
publishScan,
type PublishScanOptions,
type PublishScanResult,
planComponents,
publishScanToCustom,
publishScan,
runComponentScans,
type ComponentScanOptions,
type DeduplicateScanResult,
Expand Down Expand Up @@ -119,6 +121,22 @@ export const cost: ScanCost | null = estimateScanCost("gpt-5.6-sol", {
output_tokens: 2,
});

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

export async function previewPublication(
scanDirectory: string,
): Promise<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 @@ -532,6 +532,8 @@ try {
assert.equal(publication.counts.findings, 1);
assert.equal(publication.counts.created, 0);
assert.match(publication.issues[0].title, /^\[Codex Security\]\[HIGH\] /u);
assert.match(publication.payloadDigest, /^[a-f0-9]{64}$/u);
assert.doesNotMatch(publication.issues[0].description, /\*\*Uploaded:\*\*/u);
assert.match(
run(process.execPath, [launcher, "publish", "scan", "--help"], {
cwd: consumer,
Expand Down Expand Up @@ -575,6 +577,42 @@ try {
networkGuard,
'globalThis.fetch = async () => { throw new Error("Publication dry runs must not make network requests."); };\n',
);
assert.deepEqual(
JSON.parse(
run(
process.execPath,
[
"--require",
networkGuard,
launcher,
"publish",
"scan",
publicationScan,
"--to",
"linear",
"--linear-team",
"team-example",
"--finding",
publication.issues[0].findingId,
"--expect-digest",
publication.payloadDigest,
"--dry-run",
"--json",
],
{
cwd: consumer,
capture: true,
env: {
...process.env,
CODEX_SECURITY_LINEAR_PROJECT: "",
CODEX_SECURITY_LINEAR_API_KEY: "",
CODEX_SECURITY_STATE_DIR: join(consumer, "publication-state"),
},
},
),
),
publication,
);
const directPublicationText = run(
process.execPath,
[
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 @@ -309,6 +309,8 @@ const VALUE_OPTIONS = new Set([
"--linear-api-key",
"--project",
"--linear-assignee",
"--finding",
"--expect-digest",
]);
const PROVIDER_OPTION = z
.enum(["openai", "openrouter", "fireworks", "amazon-bedrock"])
Expand Down Expand Up @@ -2127,6 +2129,15 @@ export async function main(
.describe(
"External completed scan directory; Linear and custom accept one scan.",
),
finding: z
.array(optionValue("--finding"))
.optional()
Comment on lines +2132 to +2134

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse the existing finding selector

When callers supply the already-documented --finding-id A together with the new --finding B, the latter silently overwrites findingIds in the publication options, so finding A is ignored rather than combined or rejected. Since --finding-id already provides repeatable Linear finding selection and remains documented later in the README, introducing a second selector makes the public CLI ambiguous; reuse the existing flag instead.

AGENTS.md reference: AGENTS.md:L39-L45

Useful? React with 👍 / 👎.

.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 @@ -2272,6 +2283,8 @@ export async function main(
if (
options.to !== "linear" &&
(options.skipExisting ||
options.finding !== undefined ||
options["expect-digest"] !== undefined ||
[
options.linearTeam,
options.linearApiKey,
Expand Down Expand Up @@ -2643,6 +2656,12 @@ export async function main(
? {}
: { expectedScanId: selectedScans[0].scanId }),
dryRun: options.dryRun,
...(options.finding === undefined
? {}
: { findingIds: options.finding }),
...(options["expect-digest"] === undefined
? {}
: { expectedDigest: options["expect-digest"] }),
signal: controller.signal,
...(options.skipExisting ? { skipExisting: true } : {}),
...(options.dryRun
Expand Down
10 changes: 1 addition & 9 deletions sdk/typescript/src/publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ export async function prepareScanPublication(
...(options.signal === undefined ? {} : { signal: options.signal }),
expectedScanId: options.expectedScanId,
});
const uploadedAt = options.uploadedAt ?? new Date().toISOString();
const scanId = contract.manifest.scan.id;
let classification: SeverityClassification | undefined;
if (options.classification !== undefined) {
Expand Down Expand Up @@ -174,12 +173,7 @@ export async function prepareScanPublication(
findingId: finding.findingId,
occurrenceId: finding.occurrenceId,
title: `[Codex Security][${level.toUpperCase()}] ${finding.title}`,
description: renderFindingDescription(
contract,
finding,
uploadedAt,
assessment,
),
description: renderFindingDescription(contract, finding, assessment),
...(priority === undefined ? {} : { priority }),
};
}),
Expand All @@ -189,7 +183,6 @@ export async function prepareScanPublication(
function renderFindingDescription(
contract: LoadedContract,
finding: Finding,
uploadedAt: string,
assessment?: SeverityAssessment,
): string {
const { coverage } = contract;
Expand Down Expand Up @@ -282,7 +275,6 @@ function renderFindingDescription(
`**Scan mode:** ${scanMode(coverage.mode)}`,
`**Started:** ${scan.startedAt}`,
`**Completed:** ${scan.completedAt}`,
`**Uploaded:** ${uploadedAt}`,
"",
"### Affected locations",
"",
Expand Down
116 changes: 99 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 @@ -73,6 +73,7 @@ export interface PublishScanOptions {
projectId?: string;
linearApiKey?: string;
assigneeId?: string;
expectedDigest?: string;
dryRun?: boolean;
skipExisting?: boolean;
signal?: AbortSignal;
Expand Down Expand Up @@ -127,6 +128,7 @@ export interface PublishScanResult {
issues?: PreparedPublicationIssue[];
indeterminate?: boolean;
warnings?: string[];
payloadDigest: string;
}

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

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

function selectPublicationFindings(
publication: PreparedScanPublication,
findingIds: readonly string[] | undefined,
): PreparedScanPublication {
if (findingIds === undefined) return publication;
if (
!Array.isArray(findingIds) ||
findingIds.some((id) => typeof id !== "string" || !id.trim())
) {
throw new ConfigurationError(
"Publication finding IDs must be nonempty strings.",
);
}
const selected = new Set(findingIds);
const known = new Set(publication.issues.map((issue) => issue.findingId));
for (const findingId of selected) {
if (!known.has(findingId)) {
Comment on lines +613 to +615

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow explicitly selected excluded findings to be omitted

When an explicitly selected finding has a saved classification with decision: "excluded", prepareScanPublication intentionally omits it from preparedScan.issues; this second validation then treats the ID as unknown and aborts the entire publication. A classified subset containing an excluded finding therefore fails instead of omitting that finding as documented; rely on preparation's selection or validate membership against sourceFindings rather than only publishable issues.

AGENTS.md reference: sdk/typescript/AGENTS.md:L14-L20

Useful? React with 👍 / 👎.

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
1 change: 1 addition & 0 deletions sdk/typescript/tests-ts/cli-classify-severity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ test("publication forwards selected finding IDs only to Linear", async () => {
return {
scanId: "scan-example",
uploadId: "scan-example",
payloadDigest: "a".repeat(64),
destination: { type: "linear", teamId: "team-example" },
created: [],
failed: [],
Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/tests-ts/cli-cloud-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,8 @@ describe("publish scan to Cloud", () => {
["--linear-assignee", "synthetic-value"],
["--linear-api-key", "synthetic-value"],
["--skip-existing"],
["--finding", "finding-example"],
["--expect-digest", "0".repeat(64)],
]) {
const deps = dependencies();
let calls = 0;
Expand Down
Loading
Loading