Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dd61f0d
fix(sdk): redact standalone keys and structured credentials
mldangelo-oai Aug 8, 2026
552c382
fix(sdk): preserve diagnostics after structured auth headers
mldangelo-oai Aug 8, 2026
e8b9589
fix(sdk): redact structured auth and credential collections
mldangelo-oai Aug 8, 2026
bf34227
fix(sdk): redact suffixed credential collections
mldangelo-oai Aug 8, 2026
c2d786b
fix(sdk): close credential redaction review edge cases
mldangelo-oai Aug 8, 2026
68b11bf
refactor(sdk): remove local credential redaction
mldangelo-oai Aug 10, 2026
a75ea4a
Merge main and preserve safe post-scan warnings
mldangelo-oai Aug 10, 2026
4de6057
fix(sdk): preserve redaction and cover standalone private keys
mldangelo-oai Aug 10, 2026
179b234
fix(sdk): match private-key boundaries before redaction
mldangelo-oai Aug 10, 2026
ae623dc
Match assigned private-key opening and closing labels
mldangelo-oai Aug 10, 2026
6577fa6
Simplify private-key redaction and preserve overlapping credentials
mldangelo-oai Aug 10, 2026
9051e66
Merge overlapping private-key and quoted-credential redactions
mldangelo-oai Aug 10, 2026
9fec366
fix(sdk): redact overlapping private-key blocks safely
mldangelo-oai Aug 10, 2026
be6012e
fix(sdk): redact nested private keys in a single pass
mldangelo-oai Aug 10, 2026
ef23ce1
fix(sdk): verify private-key delimiter boundaries
mldangelo-oai Aug 10, 2026
1461d02
fix(sdk): bind key delimiters to serialized line depth
mldangelo-oai Aug 10, 2026
9e2a6e5
fix(sdk): isolate credential and serialized key boundaries
mldangelo-oai Aug 10, 2026
a444919
fix(sdk): protect framed private keys and nearby auth values
mldangelo-oai Aug 10, 2026
de6293a
fix(sdk): preserve serialized private-key framing
mldangelo-oai Aug 10, 2026
124a84e
fix(sdk): retain valid serialized credential diagnostics
mldangelo-oai Aug 10, 2026
09a5e0b
merge: sync current repository guidance
mldangelo-oai Aug 10, 2026
a40e8c0
refactor(sdk): remove credential redaction
mldangelo-oai Aug 11, 2026
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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,18 @@ incomplete or their original location was not reviewed.

## Verbose diagnostics

Add `--verbose` to print redacted scan diagnostics to stderr:
Add `--verbose` to print scan diagnostics to stderr:

```bash
npx @openai/codex-security scan . --verbose
```

`CODEX_SECURITY_LOG_LEVEL=debug` also enables diagnostics;
`LOG_LEVEL=debug` is its fallback. JSON results remain on stdout, and
credentials and provider identifiers remain redacted.
`LOG_LEVEL=debug` is its fallback. JSON results remain on stdout.

Diagnostics, errors, and saved scan history are not redacted and may contain
credentials or other sensitive data. Restrict access to local logs and scan
state, and review them before sharing them.

## TypeScript SDK

Expand Down
5 changes: 5 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ credential. Other variables, such as `GITHUB_TOKEN` or `AWS_SECRET_ACCESS_KEY`,
can remain available to local subprocesses. Run a scan with only the
environment credentials it needs.

Local diagnostics, error messages, and saved scan history are not automatically
redacted. They can contain credentials, repository contents, or other sensitive
data from upstream failures. Limit access to logs and scan state, and remove
sensitive information before sharing or uploading them.

### Security boundaries

A security issue must cross a boundary the product actually provides:
Expand Down
12 changes: 7 additions & 5 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ The CLI and SDK recognize the following user-configurable environment:
| Variable | Effect |
| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `OPENAI_API_KEY`, `CODEX_API_KEY` | Scan authentication; `OPENAI_API_KEY` wins when both are present. |
| `CODEX_SECURITY_LOG_LEVEL` | CLI-only; set to `debug` for redacted diagnostics. |
| `CODEX_SECURITY_LOG_LEVEL` | CLI-only; set to `debug` for verbose diagnostics. |
| `LOG_LEVEL` | CLI-only fallback when `CODEX_SECURITY_LOG_LEVEL` is unset. |
| `CODEX_SECURITY_STATE_DIR` | Override the private scan-history, workbench, and default artifact directory. |
| `CODEX_HOME` | Set the ambient Codex home for file-backed sign-in and default state; defaults to `~/.codex`. |
Expand Down Expand Up @@ -451,11 +451,12 @@ token and worker counts, estimated cost, the results directory, and the next
useful command.
Progress and summaries use stderr; structured scan results remain on stdout.

Add `--verbose` or set `CODEX_SECURITY_LOG_LEVEL=debug` to print redacted
Add `--verbose` or set `CODEX_SECURITY_LOG_LEVEL=debug` to print
lifecycle, authentication, progress, and cost diagnostics to stderr.
`LOG_LEVEL=debug` is used only when `CODEX_SECURITY_LOG_LEVEL` is unset.
Credentials and provider identifiers remain redacted, and structured JSON
results remain on stdout.
Structured JSON results remain on stdout. Diagnostics and error messages are
not redacted and may contain credentials or other sensitive data; restrict
access to logs and review them before sharing them.

Each scan records its model, tokens, and estimated cost in its JSON result,
scan history, and bulk-scan receipt. Estimates use
Expand Down Expand Up @@ -514,7 +515,8 @@ least eight characters.
Scan history uses the existing Codex Security workbench database at
`$CODEX_HOME/state/plugins/codex-security/workbench.sqlite3`. Set
`CODEX_SECURITY_STATE_DIR` to place the database elsewhere. Scan credentials
are never stored in the scan configuration.
are never stored in the scan configuration, but recorded upstream errors and
other scan-history fields are not redacted and may contain credentials.

The scan sandbox permits writes to the selected state directory so SQLite can
maintain its database and journal files. If the host itself cannot write to the
Expand Down
17 changes: 8 additions & 9 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import {
OutputDirectoryError,
OutputInsideProtectedRootError,
type ProtectedScanPathKind,
redactedErrorMessage,
errorMessage,
ScanCostLimitExceededError,
ScanInterruptedError,
} from "./errors.js";
Expand Down Expand Up @@ -684,7 +684,7 @@ export class CodexSecurity {
"onWarning",
options.onWarning,
options.onObserverError,
`Could not track scan activity: ${redactedErrorMessage(error)}`,
`Could not track scan activity: ${errorMessage(error)}`,
);
};
const tracker = new ScanCostTracker({
Expand Down Expand Up @@ -1108,10 +1108,9 @@ export class CodexSecurity {
"fail-scan",
"--scan-id",
activeScan.id,
// Redact before truncating: the stored message is read back by
// `scans show` and travels inside the results directory.
// Preserve the upstream message within the workbench field limit.
"--message",
redactedErrorMessage(failure).slice(0, 2400),
errorMessage(failure).slice(0, 2400),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Redact credentials before persisting failed scan messages

Passing the original upstream error directly to fail-scan --message permanently stores API keys, client secrets, authorization values, and proxy credentials in workbench.sqlite3, then returns them through scan-history commands. The changed test explicitly checks that synthetic credential text is present in the database and get-scan response. This contradicts both repository AGENTS.md instructions to preserve credential protections and SECURITY.md's existing boundary against disclosing credentials through logs or reports. Redact before truncating and persisting the failure message.

...(snapshot?.cost
? ["--cost-json", JSON.stringify(snapshot.cost)]
: []),
Expand All @@ -1130,7 +1129,7 @@ export class CodexSecurity {
"onWarning",
options.onWarning,
options.onObserverError,
`Could not run post-scan instructions: ${redactedErrorMessage(postScanError)}`,
`Could not run post-scan instructions: ${errorMessage(postScanError)}`,
);
}
}
Expand Down Expand Up @@ -1705,7 +1704,7 @@ export async function initialCredentialsAvailable(

// Reports a cleanup failure without letting it decide the result of the scan. Only the
// message is forwarded, and it reaches the onWarning observer alone: unlike the fail-scan
// path it is never written to the workbench, so it adds no persisted, unredacted text.
// path it is never written to the workbench, so it adds no persisted warning text.
function warnCleanupFailed(
options: Pick<ScanOptions, "onWarning" | "onObserverError">,
reason: unknown,
Expand Down Expand Up @@ -2402,8 +2401,8 @@ function reconnectDetails(message: string): ScanReconnectDetails | undefined {
//
// Only `error.message` is reused, because that is the single shape the previous
// code already surfaced. No other shape is forwarded or stringified: this message
// reaches `fail-scan --message` and is stored in `scans.failure_message` without
// redaction, so widening what is copied out of the payload would add a new
// reaches `fail-scan --message` and is stored unchanged in `scans.failure_message`,
// so widening what is copied out of the payload would add a new
// credential-disclosure path to persistent scan history.
function turnFailureMessage(error: unknown): string {
if (isRecord(error) && typeof error["message"] === "string") {
Expand Down
69 changes: 32 additions & 37 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import {
OutputDirectoryError,
OutputInsideProtectedRootError,
PluginPythonUnavailableError,
redactedErrorMessage,
errorMessage,
ScanCostLimitExceededError,
ScanInterruptedError,
} from "./errors.js";
Expand Down Expand Up @@ -715,7 +715,7 @@ export async function main(
try {
return await select(await dependencies.runWorkbench(args));
} catch (error) {
errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`);
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
exitCode = 2;
return undefined;
}
Expand Down Expand Up @@ -921,7 +921,7 @@ export async function main(
scanArguments = scanArgumentsFromRecipe(recipe, args.scanId);
scanArguments.verbose = options.verbose;
} catch (error) {
const message = redactedErrorMessage(error);
const message = errorMessage(error);
errorOutput.write(`codex-security: ${message}\n`);
exitCode = 2;
return incurError({
Expand Down Expand Up @@ -984,7 +984,7 @@ export async function main(
format,
);
} catch (error) {
errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`);
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
exitCode = 2;
return undefined;
}
Expand Down Expand Up @@ -1037,7 +1037,7 @@ export async function main(
verbose: z
.boolean()
.default(false)
.describe("Print redacted scan diagnostics to stderr."),
.describe("Print scan diagnostics to stderr."),
path: z
.array(optionValue("--path"))
.default([])
Expand Down Expand Up @@ -1298,7 +1298,7 @@ export async function main(
failOnSeverity: options.failOnSeverity,
};
} catch (error) {
errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`);
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
exitCode = 2;
return undefined;
}
Expand Down Expand Up @@ -1460,7 +1460,7 @@ export async function main(
onProgress: ({ repository, status, attempt, error, warning }) => {
const detail = error ?? warning;
errorOutput.write(
`codex-security: ${repository} ${status} (attempt ${attempt})${detail === undefined ? "" : `: ${redactedErrorMessage(detail)}`}\n`,
`codex-security: ${repository} ${status} (attempt ${attempt})${detail === undefined ? "" : `: ${errorMessage(detail)}`}\n`,
);
},
});
Expand All @@ -1474,7 +1474,7 @@ export async function main(
(error instanceof Error && error.name === "ExitPromptError"
? 130
: 2);
errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`);
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
} finally {
dependencies.removeSignalListener("SIGINT", onInterrupt);
dependencies.removeSignalListener("SIGTERM", onTerminate);
Expand Down Expand Up @@ -1578,7 +1578,7 @@ export async function main(
);
} catch (error) {
exitCode = 2;
errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`);
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
}
},
})
Expand Down Expand Up @@ -1614,7 +1614,7 @@ export async function main(
);
} catch (error) {
exitCode = 2;
errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`);
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
}
},
})
Expand Down Expand Up @@ -1803,7 +1803,7 @@ export async function main(
if (frameworkExit !== undefined) {
if (exitCode !== 0) return exitCode;
errorOutput.write(
`codex-security: ${redactedErrorMessage(incurErrorMessage(frameworkOutput))}\n`,
`codex-security: ${errorMessage(incurErrorMessage(frameworkOutput))}\n`,
);
return 2;
}
Expand All @@ -1812,7 +1812,7 @@ export async function main(
await writeCliOutput(output, renderedHistory ?? frameworkOutput);
return exitCode;
} catch (error) {
errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`);
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
return 2;
}
}
Expand Down Expand Up @@ -2533,20 +2533,18 @@ async function runExport(
}
return 0;
} catch (error) {
errorOutput.write(`codex-security: ${redactedErrorMessage(error)}\n`);
errorOutput.write(`codex-security: ${errorMessage(error)}\n`);
return 2;
}
}

type VerboseDiagnosticValue = string | number | boolean | null | undefined;

function sanitizeDiagnosticValue(value: unknown): string {
return redactedErrorMessage(value)
.replaceAll(
/(\b(?:tenant(?:[_-]?id)?|org(?:anization)?(?:[_-]?id)?|project(?:[_-]?id)?|(?:x[_-]?)?(?:request|trace|correlation)[_-]?id)\b(?:\\*["'])?\s*[:=]\s*)(?!\[redacted\])(?:(\\*)(['"])(?:(?!(?<!\\)\2\3)(?:\\.|[^\\]))*(?:(?<!\\)\2\3|$)|[^\s"',;&}\]]+)/giu,
"$1$2$3[redacted]$2$3",
)
.replaceAll(/[\u0000-\u001F\u007F\u0085\u2028\u2029]/gu, " ");
function diagnosticValue(value: unknown): string {
return errorMessage(value).replaceAll(
/[\u0000-\u001F\u007F\u0085\u2028\u2029]/gu,
" ",
);
}

async function runScan(
Expand Down Expand Up @@ -2589,7 +2587,7 @@ async function runScan(
value === undefined
? []
: [
`${name}=${JSON.stringify(typeof value === "string" ? sanitizeDiagnosticValue(value) : value)}`,
`${name}=${JSON.stringify(typeof value === "string" ? diagnosticValue(value) : value)}`,
],
);
writeAboveProgress(() => {
Expand Down Expand Up @@ -2758,7 +2756,6 @@ async function runScan(
: { maxCostUsd: arguments_.maxCostUsd }),
clock: dependencies,
color: dependencies.environment["NO_COLOR"] === undefined,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Preserve credential sanitization in the default interactive dashboard

Removing the dashboard's sanitize callback exposes raw model/worker activity during successful, nonverbose scans. The revised CLI test now explicitly expects a complete bearer API key to appear in rendered terminal output, and ScanDashboard also embeds unsanitized Markdown link targets in hidden terminal hyperlinks. This leaks credentials to terminals, session recordings, and potentially clicked external destinations independently of failed-scan history. Restore credential sanitization at the dashboard boundary.

sanitize: redactedErrorMessage,
input: process.stdin,
onInterrupt,
});
Expand Down Expand Up @@ -2858,13 +2855,13 @@ async function runScan(
diagnostic("scan.output_archived", { archive_dir: archiveDir });
if (dashboard !== null) {
dashboard.note(
`Moved existing results to: ${redactedErrorMessage(archiveDir)}`,
`Moved existing results to: ${errorMessage(archiveDir)}`,
);
return;
}
progress?.stopTimer();
errorOutput.write(
`Moved existing results to: ${redactedErrorMessage(archiveDir)}\n`,
`Moved existing results to: ${errorMessage(archiveDir)}\n`,
);
},
signal: preparationAbortController.signal,
Expand Down Expand Up @@ -3017,7 +3014,7 @@ async function runScan(
progress.startTimer(runningMessage());
},
onWarning: (warning, details) => {
const message = sanitizeDiagnosticValue(warning);
const message = diagnosticValue(warning);
if (details?.kind === "target_changed") {
targetWarnings.push(message);
}
Expand All @@ -3031,7 +3028,7 @@ async function runScan(
observer,
classification: classifyConnectionFailure(error),
});
const warning = `${observer} observer failed: ${sanitizeDiagnosticValue(error)}`;
const warning = `${observer} observer failed: ${diagnosticValue(error)}`;
if (dashboard === null) {
writeAboveProgress(() => {
errorOutput.write(`codex-security: warning: ${warning}\n`);
Expand Down Expand Up @@ -3089,7 +3086,7 @@ async function runScan(
failure instanceof ScanCostLimitExceededError ? failure : undefined;
const message =
failure instanceof OutputInsideProtectedRootError
? redactedErrorMessage(protectedRootErrorMessage(failure))
? errorMessage(protectedRootErrorMessage(failure))
: scanFailureMessage(failure, selectedAuthentication);
diagnostic("scan.failed", {
classification:
Expand All @@ -3108,7 +3105,7 @@ async function runScan(
}
if (scanDir !== null) {
errorOutput.write(
`Partial output was kept at ${redactedErrorMessage(scanDir)}.\n`,
`Partial output was kept at ${errorMessage(scanDir)}.\n`,
);
}
return { exitCode: 2, error: message };
Expand Down Expand Up @@ -3245,7 +3242,7 @@ function scanFailureMessage(
// appending it. That is deliberate: upstream authentication and authorization
// errors can name the organization or project, which must not reach stderr or
// the JSON error field.
if (isLocalScanFailure(error)) return sanitizeDiagnosticValue(error);
if (isLocalScanFailure(error)) return diagnosticValue(error);
switch (classifyConnectionFailure(error)) {
case "unauthorized":
if (authentication?.method === "aws_credentials") {
Expand Down Expand Up @@ -3277,7 +3274,7 @@ function scanFailureMessage(
case "network_error":
case "timeout":
case "unknown":
return sanitizeDiagnosticValue(error);
return diagnosticValue(error);
}
}

Expand All @@ -3291,9 +3288,7 @@ function scanScope(arguments_: ScanArguments): string | null {
portable.startsWith("//")
? portable.split("/").at(-1) ?? portable
: portable;
return redactedErrorMessage(
scoped.replaceAll(/[\u0000-\u001F\u007F]/gu, " "),
);
return errorMessage(scoped.replaceAll(/[\u0000-\u001F\u007F]/gu, " "));
});
return `${displayed.join(", ")}${arguments_.paths.length > displayed.length ? `, +${arguments_.paths.length - displayed.length} more` : ""}`;
}
Expand Down Expand Up @@ -3359,7 +3354,7 @@ function printScanSummary(
? 33
: 36;
errorOutput.write(
`\n ${paint("REPORT", "1;36")} ${paint(redactedErrorMessage(result.reportPath), 4)}\n\n` +
`\n ${paint("REPORT", "1;36")} ${paint(errorMessage(result.reportPath), 4)}\n\n` +
` ${paint("FINDINGS", 1)} ${paint(`${findingCount}${severitySummary === "" ? "" : ` (${severitySummary})`}`, findingColor)}\n` +
` ${paint("COVERAGE", 1)} ${result.coverage.completeness}\n` +
` ${paint("ELAPSED", 1)} ${duration}\n`,
Expand All @@ -3375,7 +3370,7 @@ function printScanSummary(
);
}
errorOutput.write(
` ${paint("RESULTS", 1)} ${redactedErrorMessage(result.scanDir)}\n`,
` ${paint("RESULTS", 1)} ${errorMessage(result.scanDir)}\n`,
);
}

Expand Down Expand Up @@ -3710,7 +3705,7 @@ function interruptedExit(
errorOutput.write(
scanDir === null
? "codex-security: No partial output was kept.\n"
: `codex-security: Partial output was kept at ${redactedErrorMessage(scanDir)}.\n`,
: `codex-security: Partial output was kept at ${errorMessage(scanDir)}.\n`,
);
return ctrlC ? 130 : 143;
}
Expand All @@ -3736,7 +3731,7 @@ if (invokedAsMain()) {
process.exitCode = exitCode;
},
(error: unknown) => {
process.stderr.write(`codex-security: ${redactedErrorMessage(error)}\n`);
process.stderr.write(`codex-security: ${errorMessage(error)}\n`);
process.exitCode = 2;
},
);
Expand Down
Loading
Loading