Skip to content
Closed
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
5 changes: 3 additions & 2 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,8 +504,9 @@ Results remain under `--output-dir`; rerun the same command to resume.

`npx @openai/codex-security scans list` lists scans for the current repository. Pass a
repository path to inspect another checkout, `--scan-root DIR` to list scans
whose artifacts are under a particular root. `scans show SCAN_ID` includes the
scan configuration, results, coverage, and artifact locations. Add
whose artifacts are under a particular root. `scans show SCAN_ID` includes scan
configuration, results, coverage, artifact locations, the Python interpreter,
available validation tools, and recorded limitations or deferred work. Add
`--show-linked-findings` to include finding links from previous scans.

Every scan history command accepts a full scan ID or a unique prefix of at
Expand Down
36 changes: 36 additions & 0 deletions sdk/typescript/_bundled_plugin/scripts/workbench_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import json
import os
import re
import shutil
import sqlite3
import stat
import sys
Expand Down Expand Up @@ -1326,6 +1327,18 @@ def register_cli_scan(connection: sqlite3.Connection, args: argparse.Namespace)
raise SystemExit("The scan artifact directory must be empty before the scan starts.")

recipe = parse_scan_recipe(args.recipe_json, repository)
recipe["validationEnvironment"] = {
"python": sys.executable,
"availableTools": [
tool
for tool in (
"python python3 pip uv poetry pytest node npm npx pnpm yarn bun "
"java javac mvn gradle go cargo rustc ruby bundle php composer "
"dotnet gcc clang make cmake gdb lldb valgrind docker"
Comment on lines +1335 to +1337

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

where did this list come from?

).split()
if shutil.which(tool)
],
}
requested_target = recipe["target"]
paths = requested_target["paths"]
scope = paths[0] if len(paths) == 1 else "."
Expand Down Expand Up @@ -2692,6 +2705,7 @@ def scan_result(
)
if sarif_path is not None:
artifacts["sarifReport"] = str(sarif_path)
validation = scan_validation_environment(scan, artifacts)
occurrence_rows = scan_history.finding_occurrence_rows(
connection, scan["id"], offset=0, limit=FINDINGS_RESULT_LIMIT
)
Expand Down Expand Up @@ -2777,6 +2791,7 @@ def scan_result(
"targetPath": scan["target_path"],
"targetRevision": scan["target_revision"],
"targetSummary": scan["target_summary"],
**({"validationEnvironment": validation} if validation is not None else {}),
"updatedAt": max(
scan["updated_at"],
progress["updated_at"],
Expand All @@ -2788,6 +2803,27 @@ def scan_result(
}


def scan_validation_environment(
scan: sqlite3.Row, artifacts: dict[str, str]
) -> dict[str, Any] | None:
if scan["recipe_json"] is None:
return None
environment = json.loads(scan["recipe_json"]).get("validationEnvironment")
if environment is None or not {"manifest", "coverage"}.issubset(artifacts):
return environment
try:
scope = read_json_object(Path(artifacts["manifest"]))["scan"]["scope"]
coverage = read_json_object(Path(artifacts["coverage"]))
except (KeyError, SystemExit):
return environment
blockers = list(
dict.fromkeys(
[*scope.get("limitations", []), *(item["reason"] for item in coverage["deferred"])]
)
)
return {**environment, "blockers": blockers} if blockers else environment


def remediation_availability(scan: sqlite3.Row) -> tuple[bool, str | None]:
try:
current_revision = git_revision(require_scan_target_identity(scan))
Expand Down
18 changes: 18 additions & 0 deletions sdk/typescript/src/scan-history-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,24 @@ export function renderScanHistory(
.join(` ${accent("·")} `)}`,
);
}
const validation = result["validationEnvironment"] as
| JsonObject
| undefined;
if (validation) {
lines.push(
` ${strong("VALIDATION PYTHON")} ${clean(validation["python"])}`,
);
const tools = validation["availableTools"] as string[];
wrap(
tools.map(clean).join(", ") || "none",
19,
` ${strong("AVAILABLE TOOLS")} `,
);
for (const blocker of (validation["blockers"] as string[] | undefined) ??
[]) {
wrap(blocker, 15, ` ${paint("LIMITATION", 33)} `);
}
}
const coverage = (result["progress"] as JsonObject)["coverage"] as
| JsonObject
| undefined;
Expand Down
24 changes: 24 additions & 0 deletions sdk/typescript/tests-ts/scan-history-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,30 @@ describe("scan history renderer", () => {
expect(output).not.toContain("ERROR");
});

test("shows observed validation tools and recorded blockers", () => {
const output = stripVTControlCharacters(
renderScanHistory(
{
scanId: "12345678-abcd-4567-abcd-1234567890ab",
targetPath: "/demo/juice-shop",
mode: "standard",
progress: { status: "complete" },
findings: [],
validationEnvironment: {
python: "/managed/python",
availableTools: ["node", "npm", "pytest"],
blockers: ["Docker daemon is unavailable."],
},
},
"show",
),
);

expect(output).toContain("VALIDATION PYTHON /managed/python");
expect(output).toContain("AVAILABLE TOOLS node, npm, pytest");
expect(output).toContain("LIMITATION Docker daemon is unavailable.");
});

test("renders match-all results from the original workbench data", () => {
const output = stripVTControlCharacters(
renderScanHistory(
Expand Down
104 changes: 103 additions & 1 deletion sdk/typescript/tests-ts/scan-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { runWorkbench } from "../src/runtime.js";
import { PLUGIN_ROOT } from "./plugin-root.js";
Expand Down Expand Up @@ -305,6 +305,108 @@ describe("malformed scan artifact recovery", () => {
});
});

test("keeps the Python and tools observed when each scan was registered", async () => {
const fixture = await startDraftScan();
const running = await workbench(fixture, [
"get-scan",
"--scan-id",
fixture.scanId,
]);
const python = (
running["scan"] as { validationEnvironment: { python: string } }
).validationEnvironment.python;
expect(await realpath(python)).toBe(await realpath(fixture.python));
const available = {
python,
availableTools: expect.arrayContaining(["node"]),
};
expect(running["scan"]).toMatchObject({
validationEnvironment: available,
});
expect(running["recipe"]).toMatchObject({
validationEnvironment: available,
});

const restrictedScanDir = join(dirname(fixture.scanDir), "restricted-scan");
await mkdir(restrictedScanDir, { mode: 0o700 });
const restricted = await runWorkbench(
{
python: fixture.python,
pluginRoot: PLUGIN_ROOT,
environment: {
PATH: "",
CODEX_SECURITY_STATE_DIR: fixture.stateDir,
},
},
[
"register-cli-scan",
"--repository",
fixture.repository,
"--scan-dir",
restrictedScanDir,
"--recipe-json",
JSON.stringify({
config: {},
mode: "standard",
repository: fixture.repository,
target: { kind: "repository", paths: [] },
}),
],
);
const historical = await workbench(fixture, [
"get-scan",
"--scan-id",
String(restricted["scanId"]),
]);

expect(historical["scan"]).toMatchObject({
validationEnvironment: {
python,
availableTools: [],
},
});
});

test("shows recorded limitations and deferred work without changing the saved recipe", async () => {
const fixture = await startDraftScan();
const manifestPath = join(fixture.scanDir, "scan-manifest.json");
const manifest = await readJson<{
scan: { scope: { limitations?: string[] } };
}>(manifestPath);
manifest.scan.scope.limitations = ["Docker daemon is unavailable."];
await writeJson(manifestPath, manifest);

const coveragePath = join(fixture.scanDir, "coverage.json");
const coverage = await readJson<CoverageDocument>(coveragePath);
coverage.completeness = "partial";
coverage.deferred = [
{ id: "docker", reason: "Docker daemon is unavailable." },
{ id: "database", reason: "The PostgreSQL test service is not running." },
];
await writeJson(coveragePath, coverage);
await completeScan(fixture);

const context = await workbench(fixture, [
"get-scan",
"--scan-id",
fixture.scanId,
]);
const recipe = context["recipe"] as {
validationEnvironment: { python: string };
};
expect(context["scan"]).toMatchObject({
validationEnvironment: {
python: recipe.validationEnvironment.python,
availableTools: expect.arrayContaining(["node"]),
blockers: [
"Docker daemon is unavailable.",
"The PostgreSQL test service is not running.",
],
},
});
expect(recipe.validationEnvironment).not.toHaveProperty("blockers");
});

test("returns authoritative clean, dirty, and nested Git target contracts", async () => {
for (const kind of ["clean", "dirty", "nested"] as const) {
const fixture = await startDraftScan(kind);
Expand Down
Loading