Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
14 changes: 8 additions & 6 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,16 +557,18 @@ policy locations and reporting instructions, headless use, and saved artifacts.

### Exports and CI

Export saved findings or a threat model without starting another analysis:
Export a completed scan's findings to a SARIF file:

```bash
cs export --scan SCAN_ID --export-format sarif --output results.sarif
cs export --scan SCAN_ID --artifact threat-model --output threatmodel.md
cs export /path/to/scan-results --output results.sarif
```

The SDK provides `exportArtifact()` for the same offline operations. See
[exports and CI](docs/cli.md#exports-and-ci) for details. For repository or pull
request scans in GitHub Actions, use the
Replace `/path/to/scan-results` with the results directory printed by `scan`.
Export reads saved results, so you don't need to rerun the scan or sign in.

See [exports and CI](docs/cli.md#exports-and-ci) for source fingerprints, other
formats, threat models, and the `exportArtifact()` SDK method. For repository or
pull request scans in GitHub Actions, use the
[GitHub Action guide](https://github.com/openai/codex-security/blob/main/github-action/README.md).
Separate examples cover
[GitHub Actions with Bedrock](https://github.com/openai/codex-security/blob/main/examples/github-actions/README.md)
Expand Down
114 changes: 91 additions & 23 deletions sdk/typescript/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,11 +354,15 @@ SARIF), PDF, DOCX, or directories. Directory traversal skips other binary files;
explicit unsupported binary files are rejected. Bulk scans share this context
with every repository.

Output directories must be empty and outside the scanned directory and enclosing
Git worktree. On macOS/Linux, existing directories must be private (`chmod 700`).
Use `--output-dir DIR` to choose where a scan saves its results. The directory
must be empty and outside the scanned directory and enclosing Git worktree. On
macOS/Linux, existing directories must be private (`chmod 700`).
`--archive-existing` moves earlier output to
`<output-dir>.previous-<timestamp>-<id>`; `--dry-run` previews the move.
SARIF, when produced, is saved at `<scan-dir>/exports/results.sarif`.

Scans also attempt to write SARIF to `<scan-dir>/exports/results.sarif`. If that
optional export fails, the scan can still complete. Use the
[export command](#export-findings) to write or retry SARIF from the saved results.

Completed scans exit with `0` by default. Configure `--fail-on-severity high`
to return `1` for high or critical findings. Scope exclusions, skipped files, and follow-up notes
Expand Down Expand Up @@ -1375,31 +1379,80 @@ Progress callback failures do not stop matching.

## Exports and CI

`export` reads saved results without Codex or credentials. It defaults to the
current repository's latest completed scan. Choose either a positional result
directory or `--scan ID`. Findings require sealed results and support
`--export-format csv|json|sarif` (default SARIF). `--source-root PATH` adds SARIF
source-line fingerprints.
Export saved findings and threat models without rerunning a scan or signing in.

### Export findings

To save a completed scan's findings as SARIF, run:

```bash
codex-security export /path/to/scan-results --output results.sarif
```

Replace `/path/to/scan-results` with the results directory printed by `scan`.
The command writes `results.sarif` in the current directory. The output directory
must already exist.

SARIF is the default format, and `./results.sarif` is the default output path.
Use `--export-format json` or `--export-format csv` to choose another format.
To select a saved scan by its ID and export JSON, run:

```bash
codex-security export --scan SCAN_ID --export-format json --output findings.json
codex-security export --artifact threat-model --output docs/threatmodel.md
codex-security export /path/to/policy-results --artifact threat-model --output -
```

Threat-model exports use Markdown and default to `./threatmodel.md`. Standard,
Deep, Diff, and policy workflows retain models when available; component/bulk
runs save one per child. Explicit results may expose a model before completion
or after later failure. Missing models return an error rather than generating
or substituting one. Historical models in sealed scans need a matching manifest
digest.
Replace `SCAN_ID` with the ID printed by `scan`. If you omit both the results
directory and `--scan`, `export` uses the current repository's latest completed
scan.

JSON preserves the saved findings. CSV marks them open, omits local triage state,
and retains distinct occurrence IDs. It escapes spreadsheet-formula prefixes and
leading apostrophes; import removes the escape. Use JSON to recover ambiguous
apostrophes from older CSV exports.

For SARIF, `--source-root` adds source-line fingerprints that help tools match
findings across scans. Before changing the scanned files or applying patches,
run the export from the scanned repository's root:

```bash
codex-security export /path/to/scan-results --source-root . --output results.sarif
```

The `.` selects the current directory as the source root. Its source files must
be unchanged since the scan. `--source-root` is supported only for SARIF.

If export fails, it exits with `2`. Fix the reported error and rerun the export;
the saved scan remains available, so you don't need to run another scan.

### Export a threat model

To save a scan's threat model as Markdown, run:

```bash
codex-security export --scan SCAN_ID --artifact threat-model --output threatmodel.md
```

Threat-model exports default to `./threatmodel.md`. Standard, Deep, Diff, and
policy workflows retain models when available; component/bulk runs save one per
child. With an explicit results directory, you can export a saved threat model
even if the workflow has not completed or later failed. Missing models return
an error rather than generating or substituting one. Historical models in sealed
scans need a matching manifest digest.

To print a policy workflow's saved threat model, pass its results directory and
`--output -`:

```bash
codex-security export /path/to/policy-results --artifact threat-model --output -
```

`--output -` emits only artifact content, with diagnostics on stderr. Markdown
and CSV stdout cannot combine with JSON command output. Exporting into a checkout
does not make the model scan input; pass it with `--knowledge-base`.

`ScanResult.threatModel` and `threatModelPath` expose the retained model and
document, or null. Export still works if the document could not be written.
### Export with the SDK

Use `exportArtifact()` to export saved results from TypeScript:

```ts
import { exportArtifact } from "@openai/codex-security";
Expand All @@ -1416,12 +1469,12 @@ Use `artifact: "findings"` with `format: "csv" | "json" | "sarif"` for findings.
`output: "-"` streams stdout and returns null path/provenance. `pythonPath`
selects an interpreter; `signal` cancels.

JSON preserves sealed findings. CSV marks them open, omits local triage state,
and retains distinct occurrence IDs. It escapes spreadsheet-formula prefixes and
leading apostrophes; import removes the escape. Use JSON to recover ambiguous
apostrophes from older CSV exports.
`ScanResult.threatModel` and `threatModelPath` expose the retained model and
document, or null. Export still works if the document could not be written.

For CI, place output outside the checkout and set a severity threshold:
### Use exports in CI

Save each scan's results outside the checkout and set a severity threshold:

```bash
SCAN_ROOT="$(mktemp -d)"
Expand All @@ -1437,6 +1490,21 @@ codex-security scan . --diff origin/main \
| `2` | Invalid input, runtime error, target change, or required-output failure. |
| `130` / `143` | Interrupt / termination. |

If the scan exits with `2`, `130`, or `143`, skip export and upload. After a scan
exits with `0` or `1`, use these steps:

1. Export from that run's results directory. Using the explicit directory keeps
concurrent scans from changing which results are selected:

```bash
codex-security export "$SCAN_ROOT/results" --source-root . \
--output "$SCAN_ROOT/results.sarif"
```

2. If export succeeds, upload the SARIF file.
3. If the scan exited with `1`, keep the CI job failed so the severity threshold
remains enforced.

JSON scans have no interactive controls. `validate`, `login`, and `logout` reject
`--json`. Use `validate --output-schema FILE` for a schema-checked JSON assessment.
For GitHub Actions setup, see the
Expand Down
83 changes: 50 additions & 33 deletions sdk/typescript/src/artifact-export.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { workbenchArgs } from "./workbench-arguments.js";
import { spawn } from "node:child_process";
import { realpath as callbackRealpath } from "node:fs";
import { lstat, realpath } from "node:fs/promises";
import { basename, dirname, join, relative, resolve } from "node:path";
import { Writable as NodeWritable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { promisify } from "node:util";
import type { JsonObject } from "./config.js";
import { CodexSecurityError } from "./errors.js";
import { relativePathIsOutside as isOutsidePath } from "./targets.js";
import {
type PluginPythonOptions,
bundledPluginRoot,
codexSecurityStateDirectory,
expandHome,
Expand All @@ -16,6 +19,9 @@ import {
runWorkbench,
} from "./runtime.js";

// Bun's promise-based realpath can return drive-relative Windows roots.
const nativeRealpath = promisify(callbackRealpath.native);

export type ArtifactOutput = Pick<NodeJS.WriteStream, "write">;

export const ARTIFACT_EXPORT_FILENAMES = {
Expand Down Expand Up @@ -61,7 +67,7 @@ export interface ArtifactExportResult {

interface HelperOptions {
pythonPath?: string;
protectedRoot?: string;
protectedRoot?: PluginPythonOptions["protectedRoot"];
pluginRoot?: string;
signal?: AbortSignal;
output?: ArtifactOutput;
Expand Down Expand Up @@ -204,6 +210,10 @@ export async function runArtifactExport(
],
{
pythonPath: arguments_.pythonPath,
protectedRoot:
arguments_.sourceRoot === undefined
? undefined
: [process.cwd(), arguments_.sourceRoot],
Comment thread
mldangelo-oai marked this conversation as resolved.
signal: arguments_.signal,
failureMessage:
artifact === "findings"
Expand Down Expand Up @@ -257,45 +267,52 @@ export async function resolveArtifactExportOutput(
basename(arguments_.output),
);
if (arguments_.output !== "-") {
const outputFromCurrent = relative(currentDirectory, arguments_.output);
if (outputFromCurrent !== "" && !isOutsidePath(outputFromCurrent)) {
let existingParent: string | undefined;
for (
let directory = dirname(arguments_.output);
relative(currentDirectory, directory) !== "";
directory = dirname(directory)
) {
const metadata = await lstat(directory).catch(
(error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return undefined;
throw error;
},
const canonicalCurrent = await realpath(currentDirectory).catch(
() => currentDirectory,
);
let traversedSymlink = false;
let expectedOutput: string | undefined;
for (
let directory = dirname(arguments_.output);
;
directory = dirname(directory)
) {
const metadata = await lstat(directory).catch(
(error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return undefined;
throw error;
},
);
const canonicalDirectory =
relative(currentDirectory, directory) === ""
? canonicalCurrent
: metadata === undefined
? undefined
: await nativeRealpath(directory);
if (canonicalDirectory !== undefined) {
expectedOutput ??= resolve(
canonicalDirectory,
relative(directory, arguments_.output),
);
if (metadata?.isSymbolicLink()) {
if (
!isOutsidePath(relative(canonicalCurrent, canonicalDirectory)) &&
(traversedSymlink ||
isOutsidePath(relative(canonicalCurrent, outputPath)) ||
relative(expectedOutput, outputPath) !== "")
) {
throw new CodexSecurityError(
"The export output path cannot traverse a repository symlink.",
);
}
if (metadata !== undefined) existingParent ??= directory;
}
const canonicalCurrent = await realpath(currentDirectory).catch(
() => currentDirectory,
);
const expectedOutput =
existingParent === undefined
? resolve(canonicalCurrent, outputFromCurrent)
: resolve(
await realpath(existingParent),
relative(existingParent, arguments_.output),
);
// An external alias may select the repository, but links below it are
// repository-owned. Check ancestors before counting the current link.
traversedSymlink ||= metadata?.isSymbolicLink() ?? false;
if (
isOutsidePath(relative(canonicalCurrent, outputPath)) ||
relative(expectedOutput, outputPath) !== ""
) {
throw new CodexSecurityError(
"The export output path cannot traverse a repository symlink.",
);
}
relative(currentDirectory, directory) === "" ||
dirname(directory) === directory
)
break;
}
}
return { ...arguments_, scanDir: canonicalScan, output: outputPath };
Expand Down
Loading
Loading