Skip to content
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ Scan history is stored in the Codex Security workbench state directory. If that
directory cannot be written, set `CODEX_SECURITY_STATE_DIR` to a writable
directory outside the repository.

`findings list [repository]` shows open findings across a repository's scans
and identifies findings not confirmed in its latest scan.

`scans compare BEFORE_SCAN_ID AFTER_SCAN_ID` automatically matches findings by
root cause, reuses saved matches, and identifies new, persisting, reopened,
resolved, or unknown findings. Missing findings remain unknown when coverage is
Expand Down
5 changes: 5 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ Use `security.preflight()` to validate local inputs, `onWorkerStatus` and
`onReconnect` to observe long-running scans, and an `AbortSignal` to cancel a
scan.

Successful results include open repository findings in `repositoryFindings`,
when available; `findings` remains the current scan. Matching earlier findings
can make one additional model call, including with a scan cost limit.

Results can contain source excerpts, vulnerability details, and reproduction
steps. Keep result directories and saved reports outside the repository and
limit access to authorized reviewers.
Expand Down Expand Up @@ -223,6 +227,7 @@ npx @openai/codex-security scans rerun SCAN_ID
npx @openai/codex-security scans match PREVIOUS_SCAN_ID CURRENT_SCAN_ID
npx @openai/codex-security scans match --all
npx @openai/codex-security scans compare PREVIOUS_SCAN_ID CURRENT_SCAN_ID
npx @openai/codex-security findings list /path/to/repository
npx @openai/codex-security findings false-positive OCCURRENCE_ID --reason "The route already checks permissions"
npx @openai/codex-security export /path/outside/repository/results --export-format sarif --output /path/outside/repository/results.sarif
npx @openai/codex-security export /path/outside/repository/results --export-format csv --output /path/outside/repository/findings.csv
Expand Down
143 changes: 100 additions & 43 deletions sdk/typescript/_bundled_plugin/scripts/workbench_native_indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,13 @@ def list_global_findings(
return {
"findings": [
{
"confirmedInLatestScan": row["confirmed_in_latest_scan"],
"createdAt": row["created_at"],
"findingId": row["finding_id"],
"knownSince": row["known_since"],
"knownScanIds": row["known_scan_ids"],
"locationPath": row["location_path"],
"matchedFindingIds": row["matched_finding_ids"],
"occurrenceCount": row["occurrence_count"],
"occurrenceId": row["occurrence_id"],
"scanId": row["scan_id"],
Expand All @@ -70,64 +74,117 @@ def list_global_findings(
}


def _indexed_findings(connection: sqlite3.Connection) -> Iterator[sqlite3.Row]:
yield from connection.execute(
def _indexed_findings(connection: sqlite3.Connection) -> Iterator[dict[str, Any]]:
parents: dict[tuple[str, str], tuple[str, str]] = {}

def group(identity: tuple[str, str]) -> tuple[str, str]:
while identity in parents:
identity = parents[identity]
return identity

for match in connection.execute(
"""
WITH ranked_findings AS (
SELECT
occurrences.id AS occurrence_id,
occurrences.finding_id,
occurrences.severity,
occurrences.created_at,
scans.id AS scan_id,
scans.target_id,
targets.current_path AS target_path,
scans.scope,
MAX(scans.updated_at, COALESCE(triage.updated_at, '')) AS updated_at,
COALESCE(triage.status, 'open') AS status,
COUNT(*) OVER (
PARTITION BY scans.target_id, occurrences.finding_id
) AS occurrence_count,
ROW_NUMBER() OVER (
PARTITION BY scans.target_id, occurrences.finding_id
ORDER BY occurrences.created_at DESC, occurrences.id DESC
) AS occurrence_rank
FROM finding_occurrences AS occurrences
JOIN scans ON scans.id = occurrences.scan_id
JOIN security_targets AS targets ON targets.id = scans.target_id
LEFT JOIN finding_triage AS triage ON triage.occurrence_id = occurrences.id
SELECT before_scans.target_id, before.finding_id AS before_finding_id,
after.finding_id AS after_finding_id
FROM scan_comparison_matches AS matches
JOIN finding_occurrences AS before ON before.id = matches.before_occurrence_id
JOIN scans AS before_scans ON before_scans.id = before.scan_id
JOIN finding_occurrences AS after ON after.id = matches.after_occurrence_id
JOIN scans AS after_scans ON after_scans.id = after.scan_id
WHERE before_scans.target_id = after_scans.target_id
"""
):
before = group((match["target_id"], match["before_finding_id"]))
after = group((match["target_id"], match["after_finding_id"]))
if before != after:
parents[after] = before

latest_scan_by_target = dict(
connection.execute(
"SELECT target_id, id FROM scans "
"WHERE status = 'complete' ORDER BY started_at, id"
)
)

grouped: dict[tuple[str, str], list[sqlite3.Row]] = {}
for row in connection.execute(
"""
SELECT
selected_findings.*,
occurrences.id AS occurrence_id,
occurrences.finding_id,
occurrences.severity,
occurrences.created_at,
scans.id AS scan_id,
scans.started_at AS scan_started_at,
scans.target_id,
targets.current_path AS target_path,
scans.scope,
MAX(scans.updated_at, COALESCE(triage.updated_at, '')) AS updated_at,
triage.status AS decision_status,
triage.close_reason,
triage.updated_at AS decision_updated_at,
occurrences.title,
occurrences.summary,
(
SELECT locations.relative_path
FROM finding_locations AS locations
WHERE locations.occurrence_id = selected_findings.occurrence_id
WHERE locations.occurrence_id = occurrences.id
ORDER BY
CASE WHEN locations.role = 'root_control' THEN 0 ELSE 1 END,
locations.sort_order
LIMIT 1
) AS location_path
FROM ranked_findings AS selected_findings
JOIN finding_occurrences AS occurrences
ON occurrences.id = selected_findings.occurrence_id
WHERE selected_findings.occurrence_rank = 1
ORDER BY
CASE selected_findings.status WHEN 'open' THEN 0 ELSE 1 END,
CASE selected_findings.severity
WHEN 'critical' THEN 0
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
WHEN 'informational' THEN 4
ELSE 5
END,
selected_findings.created_at DESC,
selected_findings.occurrence_id
FROM finding_occurrences AS occurrences
JOIN scans ON scans.id = occurrences.scan_id
JOIN security_targets AS targets ON targets.id = scans.target_id
LEFT JOIN finding_triage AS triage ON triage.occurrence_id = occurrences.id
""",
):
grouped.setdefault(group((row["target_id"], row["finding_id"])), []).append(row)

findings = []
for occurrences in grouped.values():
latest = max(occurrences, key=lambda row: (row["created_at"], row["occurrence_id"]))
decision = max(
(row for row in occurrences if row["decision_status"] is not None),
key=lambda row: (row["decision_updated_at"], row["occurrence_id"]),
default=None,
)
status = decision["decision_status"] if decision is not None else "open"
if (
status == "closed"
and decision["close_reason"] == "already_fixed"
and latest["created_at"] > decision["decision_updated_at"]
):
status = "open"
scans = sorted({(row["scan_started_at"], row["scan_id"]) for row in occurrences})
findings.append(
{
**dict(latest),
"confirmed_in_latest_scan": latest_scan_by_target.get(latest["target_id"])
== latest["scan_id"],
"known_since": scans[0][0],
"known_scan_ids": [scan_id for _, scan_id in scans],
"matched_finding_ids": sorted({row["finding_id"] for row in occurrences}),
"occurrence_count": len(occurrences),
"status": status,
"updated_at": max(
latest["updated_at"],
decision["decision_updated_at"] if decision is not None else "",
),
}
)

findings.sort(key=lambda finding: finding["occurrence_id"])
findings.sort(
key=lambda finding: (
finding["status"] == "open",
-scan_history.SEVERITY_ORDER.get(finding["severity"], 5),
finding["created_at"],
),
reverse=True,
)
yield from findings


def list_repositories(
Expand Down
72 changes: 71 additions & 1 deletion sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,17 @@ import {
prepareKnowledgeBase,
type PreparedKnowledgeBase,
} from "./knowledge-base.js";
import { ScanResult, type TurnResultMetadata } from "./result.js";
import {
ScanResult,
type RepositoryFinding,
type TurnResultMetadata,
} from "./result.js";
import type { SeverityLevel } from "./models.js";
import { scanActivitiesFromEvent, type ScanActivity } from "./scan-activity.js";
import {
matchCompletedScan,
type matchScanFindings,
} from "./scan-comparison.js";
import {
scanProgressUpdatesFromEvent,
workerStatusFromEvent,
Expand Down Expand Up @@ -277,6 +285,7 @@ interface ClientDependencies {
repositoryRevision?: typeof repositoryRevision;
resolveCodexCommand?: () => CodexCommand;
runWorkbench?: typeof runWorkbench;
matchFindings?: typeof matchScanFindings;
}

const DEFAULT_DEPENDENCIES: ClientDependencies = {
Expand Down Expand Up @@ -1133,6 +1142,44 @@ export class CodexSecurity {
});
checkOpen();
}
try {
const runWorkbench = (args: readonly string[]) =>
workbench(workbenchOptions, args);
const previousFindings = await listRepositoryFindings(
runWorkbench,
targetId,
"all",
);
if (previousFindings !== undefined) {
await matchCompletedScan({
scanId,
repository: repo,
previousFindings: previousFindings.filter(
(finding) =>
finding["scanId"] !== scanId &&
finding["targetId"] === targetId,
),
falsePositives: falsePositiveExamples as Record<string, unknown>[],
findings: result.findings.findings,
workbench: runWorkbench,
matchFindings: this.#dependencies.matchFindings,
environment,
model,
signal,
});
result.repositoryFindings = (await listRepositoryFindings(
runWorkbench,
targetId,
)) as RepositoryFinding[] | undefined;
}
} catch (error) {
notifyObserver(
"onWarning",
options.onWarning,
options.onObserverError,
`Could not update repository findings: ${errorMessage(error)}`,
);
}
return result;
} catch (error) {
// Recorded first: everything below can throw a different error for this same failed
Expand Down Expand Up @@ -1633,6 +1680,29 @@ export class CodexSecurity {
}
}

export async function listRepositoryFindings(
workbench: (args: readonly string[]) => Promise<JsonObject>,
targetId: string,
status: "open" | "all" = "open",
): Promise<JsonObject[] | undefined> {
const findings: JsonObject[] = [];
let offset: number | undefined;
do {
const page = await workbench([
"list-global-findings",
"--target-id",
targetId,
...(status === "open" ? ["--status", "open"] : []),
...(offset === undefined ? [] : ["--offset", String(offset)]),
]);
if (!Array.isArray(page["findings"])) return undefined;
findings.push(...(page["findings"] as JsonObject[]));
offset =
typeof page["nextOffset"] === "number" ? page["nextOffset"] : undefined;
} while (offset !== undefined);
return findings;
}

function deepScanOptions(options: ScanOptions): DeepScanOptions {
const selected: DeepScanOptions = {};
for (const [name, , minimum] of DEEP_SCAN_SETTINGS) {
Expand Down
53 changes: 50 additions & 3 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
classifyConnectionFailure,
CodexSecurity,
createSecurityInternal,
listRepositoryFindings,
scanAuthentication,
type DeepScanOptions,
type ScanAuthMode,
Expand Down Expand Up @@ -824,6 +825,44 @@ export async function main(
]);
},
});
findingFeedback.command("list", {
description: "List open findings for a repository across its scans.",
mcp: false,
args: z.object({
repository: z
.string()
.optional()
.describe("Repository to inspect (default: current directory)."),
}),
output: z.record(z.string(), z.unknown()).optional(),
async run({ args, format }) {
const repository = resolve(
dependencies.currentDirectory(),
args.repository ?? ".",
);
return presentHistory(
await history(
["list-repositories"],
async (value): Promise<JsonObject> => {
const target = (value["repositories"] as JsonObject[]).find(
(entry) => entry["targetPath"] === repository,
);
const findings =
target === undefined
? []
: await listRepositoryFindings(
dependencies.runWorkbench,
target["targetId"] as string,
);
return { repository, findings: findings ?? [] };
},
),
"findings",
format,
{ repository },
);
},
});
const scanHistory = Cli.create("scans", {
description:
"List, inspect, rerun, match, and compare saved Codex Security scans.",
Expand Down Expand Up @@ -3360,8 +3399,10 @@ function printScanSummary(
): void {
const paint = (value: string, code: number | string): string =>
color ? `\u001B[${code}m${value}\u001B[0m` : value;
const repositoryFindings = result.repositoryFindings;
const findings = repositoryFindings ?? result.findings.findings;
const severities = new Map<SeverityLevel, number>();
for (const finding of result.findings.findings) {
for (const finding of findings) {
severities.set(
finding.severity.level,
(severities.get(finding.severity.level) ?? 0) + 1,
Expand All @@ -3386,7 +3427,13 @@ function printScanSummary(
elapsed < 60
? `${elapsed}s`
: `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`;
const findingCount = result.findings.findings.length;
const findingCount = findings.length;
const confirmedCount =
repositoryFindings?.filter((finding) => finding.confirmedInLatestScan)
.length ?? 0;
const findingSummary = repositoryFindings?.length
? `${confirmedCount} confirmed this scan; ${findingCount - confirmedCount} previously found; ${severitySummary}`
: severitySummary;
const findingColor =
findingCount === 0
? 32
Expand All @@ -3397,7 +3444,7 @@ function printScanSummary(
: 36;
errorOutput.write(
`\n ${paint("REPORT", "1;36")} ${paint(errorMessage(result.reportPath), 4)}\n\n` +
` ${paint("FINDINGS", 1)} ${paint(`${findingCount}${severitySummary === "" ? "" : ` (${severitySummary})`}`, findingColor)}\n` +
` ${paint("FINDINGS", 1)} ${paint(`${findingCount}${findingSummary === "" ? "" : ` (${findingSummary})`}`, findingColor)}\n` +
` ${paint("COVERAGE", 1)} ${result.coverage.completeness}\n` +
` ${paint("ELAPSED", 1)} ${duration}\n`,
);
Expand Down
Loading
Loading