diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 1fe6764e..d2f6cbc4 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -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 diff --git a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py index b7c7395f..0d2a16f2 100644 --- a/sdk/typescript/_bundled_plugin/scripts/workbench_db.py +++ b/sdk/typescript/_bundled_plugin/scripts/workbench_db.py @@ -11,6 +11,7 @@ import json import os import re +import shutil import sqlite3 import stat import sys @@ -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" + ).split() + if shutil.which(tool) + ], + } requested_target = recipe["target"] paths = requested_target["paths"] scope = paths[0] if len(paths) == 1 else "." @@ -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 ) @@ -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"], @@ -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)) diff --git a/sdk/typescript/src/scan-history-renderer.ts b/sdk/typescript/src/scan-history-renderer.ts index f8d2fe6d..8704f3e8 100644 --- a/sdk/typescript/src/scan-history-renderer.ts +++ b/sdk/typescript/src/scan-history-renderer.ts @@ -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; diff --git a/sdk/typescript/tests-ts/scan-history-renderer.test.ts b/sdk/typescript/tests-ts/scan-history-renderer.test.ts index fe0a3e3b..a8ddac71 100644 --- a/sdk/typescript/tests-ts/scan-history-renderer.test.ts +++ b/sdk/typescript/tests-ts/scan-history-renderer.test.ts @@ -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( diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index 1dfb7cba..f48416fa 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -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"; @@ -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(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);